Application Layer
The Application Layer orchestrates domain entities to implement specific business use cases. Located in the app/ directory, this layer acts as the bridge between the pure domain logic and the external world.
Overview
The Application Layer:
- Orchestrates domain entities to fulfill use cases
- Validates input using Zod schemas
- Transforms data between domain entities and DTOs
- Coordinates external services (notifications, storage, logging)
- Remains framework-agnostic (no NestJS decorators)
Use Cases
Use cases are functions that implement a single business scenario from start to finish.
Anatomy of a Use Case
// app/user/usecases.ts
async function create(
data: any, // Raw input (from HTTP request)
userRepository: UserRepository, // Domain repository
notifier: Notifier, // External service
logger: AbstractLogger, // Logging abstraction
) {
// 1. Validate input
const validator = new UserCreateValidator(userRepository, logger);
const validatedData = await validator.validate(data);
// 2. Create domain entity
const user = User.create(
userRepository.nextId(),
validatedData.fullName,
validatedData.username ||
await _generateUsername(validatedData.fullName, userRepository),
validatedData.email,
await hasher.hash(validatedData.password),
null, // lastLogin
);
// 3. Persist entity
await userRepository.add(user);
// 4. Side effects
const activationToken = getActivationToken(user.id);
await notifier.sendWelcomeMessage(activationToken, user);
// 5. Return DTO
return toUserDto(user);
}
Use case characteristics: - Single responsibility – One use case, one business scenario - Dependency injection – All dependencies passed as parameters - Orchestration only – Business rules stay in entities - Framework-agnostic – Can be tested without NestJS
Use Case Structure
// Common use case pattern
async function useCaseName(
// 1. Input data
data: any,
// 2. Required repositories
repository1: Repository1,
repository2: Repository2,
// 3. External services
notifier: Notifier,
storage: StorageService,
logger: AbstractLogger,
// 4. Optional context
currentUser?: User,
opts?: any,
) {
// Implementation
}
Validators
Validators ensure input data meets business requirements before use case execution.
Base Validator
// app/shared/validator.ts
export abstract class BaseValidator<T extends z.ZodType = z.ZodType> {
protected dependencies: any[] = [];
constructor(...dependencies: any[]) {
this.dependencies = dependencies;
}
abstract getValidator(...dependencies: any[]): Promise<T>;
async validate(data: any): Promise<z.infer<Awaited<ReturnType<typeof this.getValidator>>>> {
try {
let validator = await this.getValidator(...this.dependencies);
const validatedData = await validator.parseAsync(data);
return validatedData;
} catch (e) {
if (e instanceof z.ZodError) {
throw new ValidationError(BaseValidator._zodErrorToValidationError(e));
}
throw e;
}
}
static _zodErrorToValidationError(e: z.ZodError): ValidationErrorSet {
let issues: ValidationErrorSet = {};
e.errors.forEach((error) => {
const path = error.path.length == 0 ? "detail" : error.path.join(".");
if (!issues[path]) {
issues[path] = [];
}
issues[path].push(error.message);
});
return issues;
}
}
Key features: - Uses Zod for schema validation - Transforms Zod errors into consistent format - Supports dependency injection for complex validations - Returns typed validated data
Validation Error
// app/shared/validator.ts
export class ValidationError extends Error {
[key: string]: string[] | any;
constructor(issues: ValidationErrorSet) {
super("Validation Error");
Object.entries(issues).forEach(([key, value]) => {
this[key] = value;
});
}
json() {
const json: Record<string, string[]> = {};
Object.entries(this).forEach(([key, value]) => {
if (Array.isArray(value)) {
json[key] = value;
}
});
return json;
}
}
export type ValidationErrorSet = Record<string, string[]>;
Key features: - Structured error format (field → array of messages) - Easy conversion to JSON for HTTP responses - Consistent across all use cases
Example Validator
// app/user/validators.ts
export class UserCreateValidator extends BaseValidator {
async getValidator(userRepository: UserRepository, logger: AbstractLogger) {
return z.object({
fullName: z.string().min(2).default(""),
username: z
.string()
.min(2)
.max(30)
.refine(
(username) => /^[a-zA-Z0-9\.]+$/.test(username),
{ message: "Username must be alphanumeric and can include dots." }
)
.refine(
async (username) => {
try {
await userRepository.getBy({ username });
return false; // Username exists
} catch (e) {
if (e instanceof NotFound) {
return true; // Username available
}
logger.error(`Error checking username: ${e}`);
return false;
}
},
{ message: "Username already in use" }
)
.optional(),
password: z.string().min(8),
email: z
.string()
.email()
.transform((email) => normalizeEmail(email))
.refine(
async (email) => {
try {
await userRepository.getBy({ email });
return false; // Email exists
} catch (e) {
if (e instanceof NotFound) {
return true; // Email available
}
return false;
}
},
{ message: "Email already in use" }
),
});
}
}
Advanced features: - Async validation – Check database for uniqueness - Custom refinements – Complex business rules - Transformations – Normalize data before validation - Dependency injection – Access repositories during validation
Query Validators
// app/order/validators.ts
export class ListOrdersQueryValidator extends BaseValidator {
async getValidator() {
return z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(20),
orderBy: z.array(z.string()).optional(),
state: z.enum([
"paid",
"rejected",
"inprogress",
"cancel_requested",
"disputing",
"delivered",
"correction_requested",
"completed",
"cancelled",
]).optional(),
search: z.string().optional(),
});
}
}
Data Transfer Objects (DTOs)
DTOs transform domain entities into API-friendly formats.
DTO Functions
// app/user/usecases.ts
function toUserDto(user: User) {
return {
id: user.id,
email: user.email,
fullName: user.fullName,
username: user.username,
isActive: user.isActive,
isKYCVerified: user.getIsKYCVerified(),
isStaff: user.isStaff,
isSuperuser: user.isSuperuser,
createdAt: user.createdAt,
};
}
Key points: - Selective exposure – Only expose needed fields - Type safety – Returns plain objects, not entities - Computed properties – Can include derived values - Consistent format – Same structure across all endpoints
Complex DTOs with Relations
// app/order/usecases.dto.ts
export async function listOrdersDto(
orders: Order[],
userRepository: UserRepository,
) {
// Collect all unique user IDs
const userIds: string[] = [];
orders.forEach((order) => {
if (!userIds.includes(order.sellerId)) {
userIds.push(order.sellerId);
}
if (!userIds.includes(order.customerId)) {
userIds.push(order.customerId);
}
});
// Batch fetch users (N+1 query prevention)
const relatedUsers = await userRepository.filterByIds(userIds);
const relatedUsersMap = new Map(relatedUsers.map((user) => [user.id, user]));
// Map orders with related user data
return orders.map((order) => {
const seller = relatedUsersMap.get(order.sellerId)!;
const customer = relatedUsersMap.get(order.customerId)!;
return {
id: order.id,
serviceId: order.serviceId,
serviceTitle: order.serviceTitle,
totalPrice: order.getTotalPrice(),
deliveryTime: order.getDeliveryTime(),
state: order.state,
seller: {
id: seller.id,
fullName: seller.fullName,
},
customer: {
id: customer.id,
fullName: customer.fullName,
},
createdAt: order.createdAt,
};
});
}
Advanced techniques: - Batch loading – Fetch related entities in one query - N+1 prevention – Use maps for efficient lookups - Denormalization – Include related data inline - Computed fields – Call entity methods for derived values
Application Services
Application services encapsulate shared logic across use cases.
Storage Service
// app/shared/storage.ts
@Injectable()
export class StorageService {
constructor(
private readonly fileSystem: LocalFileSystem,
public readonly uploadRepository: UploadRepository,
) {}
async attach(
file: File,
objectType: UploadObjectType,
objectId: any,
tag: string,
order: number,
) {
const key = this.uploadRepository.nextId();
const path = await this.fileSystem.write(file);
await this.uploadRepository.add({
objectId,
objectType,
tag,
order,
key,
path,
fileName: file.name,
mimeType: file.type,
size: file.size,
});
return { key, path };
}
async attachOrReplace(
file: File,
objectType: UploadObjectType,
objectId: any,
tag: string,
) {
try {
const existing = await this.uploadRepository.retrieveBy({
tag,
objectType,
objectId,
});
await this.remove(existing.key);
} catch (e) {
if (!(e instanceof NotFound)) throw e;
}
return this.attach(file, objectType, objectId, tag, 0);
}
async remove(key: string) {
const upload = await this.uploadRepository.get(key);
await this.fileSystem.delete(upload.path);
await this.uploadRepository.delete(key);
}
}
Key features: - Orchestrates file system and database operations - Provides high-level file management API - Handles edge cases (replace existing files) - Injectable via NestJS (used in Web Layer)
Cache Service
// app/shared/cache.ts
export abstract class AbstractCache {
abstract get<T = any>(key: string): Promise<T>;
abstract set(key: string, value: any): Promise<void>;
abstract delete(key: string): Promise<void>;
abstract clear(): Promise<void>;
}
Key points: - Abstract interface for caching - Implementation provided in Adapters Layer - Used by use cases for performance optimization
Configuration
Application configuration with business rules.
// app/config.ts
export class Config {
static COMMISSION_RATE: Decimal;
static async loads() {
const commissionRate = await db.Configuration.findOne({
where: { key: "commission_rate" },
});
if (!commissionRate || !commissionRate.getDataValue("value")) {
throw new Error("Commission rate not set");
}
this.COMMISSION_RATE = Decimal(parseFloat(commissionRate.getDataValue("value")));
}
static async set(key: string, value: string, by: User) {
if (!by.isSuperuser) {
throw new ForbiddenError();
}
// Validate configuration
const validator = z.object({
key: z.string().refine((val) => ["commission_rate"].includes(val)),
value: z.string().min(1),
});
await validator.parseAsync({ key, value });
await db.Configuration.upsert({ key, value, userId: by.id });
await Config.loads(); // Reload
return { key, value };
}
static get(key: string, by: User) {
if (!by.isStaff) {
throw new ForbiddenError();
}
if (key === "commission_rate") {
return Config.COMMISSION_RATE;
}
throw new ValidationError({ detail: ["Unsupported key"] });
}
}
Key features: - Type-safe configuration values - Permission-based access control - Runtime validation - Cached values for performance
Application Exceptions
Application-specific exceptions.
// app/shared/exceptions.ts
export class ForbiddenError extends Error {
constructor() {
super("ForbiddenError");
}
}
Usage: - Thrown when authorization fails - Caught in Web Layer and converted to HTTP 403 - Clear semantic meaning
Application Module Organization
app/
├── accounts/ # Account management use cases
│ ├── account.usecases.ts
│ ├── account.validators.ts
│ ├── refund.usecases.ts
│ ├── withdraw.usecases.ts
│ ├── withdraw.validators.ts
│ └── transaction.usecases.ts
├── order/ # Order workflow use cases
│ ├── usecases.ts
│ ├── usecases.dto.ts
│ ├── validators.ts
│ ├── chat.usecases.ts
│ ├── chat.usecases.dto.ts
│ ├── chat.validators.ts
│ ├── comment.usecases.ts
│ ├── comment.usecases.dto.ts
│ ├── comment.validators.ts
│ ├── payment.usecases.ts
│ ├── payment.validators.ts
│ ├── payment.exceptions.ts
│ ├── workflow.ts
│ ├── workflow.validator.ts
│ └── order.task.ts
├── service/ # Service catalog use cases
│ ├── usecases.ts
│ ├── service.usecases.dto.ts
│ ├── validators.ts
│ ├── category.usecases.ts
│ └── option.usecases.ts
├── user/ # User management use cases
│ ├── usecases.ts
│ ├── validators.ts
│ ├── identity.usecases.ts
│ ├── identity.validators.ts
│ ├── portfolio.usecases.ts
│ └── portfolio.validators.ts
├── shared/ # Shared application logic
│ ├── cache.ts
│ ├── constants.ts
│ ├── exceptions.ts
│ ├── filesystem.ts
│ ├── pagination.ts
│ ├── repositories.ts
│ ├── storage.ts
│ ├── utils.ts
│ └── validator.ts
├── utils/ # Utility functions
└── config.ts # Application configuration
Best Practices
✅ Do
- Keep use cases focused – One use case, one responsibility
- Validate all inputs – Never trust external data
- Use DTOs for output – Don't expose entities directly
- Inject dependencies – Pass repositories and services as parameters
- Handle errors gracefully – Use domain exceptions
❌ Don't
- Don't put business logic in use cases – It belongs in entities
- Don't import from Web Layer – Application layer is framework-agnostic
- Don't skip validation – Always validate before processing
- Don't return entities – Transform to DTOs first
- Don't couple to infrastructure – Use abstractions
Testing Use Cases
// tests/app/user/usecases.test.ts
describe("User creation use case", () => {
it("should create a user with valid data", async () => {
const mockRepository = createMockUserRepository();
const mockNotifier = createMockNotifier();
const mockLogger = createMockLogger();
const result = await userUseCases.create(
{
fullName: "John Doe",
email: "john@example.com",
password: "securepassword123",
},
mockRepository,
mockNotifier,
mockLogger,
);
expect(result.email).toBe("john@example.com");
expect(mockRepository.add).toHaveBeenCalled();
expect(mockNotifier.sendWelcomeMessage).toHaveBeenCalled();
});
it("should throw ValidationError for invalid email", async () => {
await expect(
userUseCases.create(
{ fullName: "John", email: "invalid", password: "pass" },
mockRepository,
mockNotifier,
mockLogger,
)
).rejects.toThrow(ValidationError);
});
});
Summary
The Application Layer:
- Orchestrates domain logic without implementing business rules
- Validates inputs using Zod and custom validators
- Transforms outputs using DTOs
- Coordinates services (notifications, storage, logging)
- Remains testable and framework-agnostic
This layer is the glue between pure domain logic and the outside world.