Skip to content

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.