Siddhant Deval
Siddhant Deval
backend5 min read

The Application Layer: Orchestrating Use Cases with Commands and Queries

The Application layer is the pure orchestrator — it coordinates Domain entities, calls Repository ports, and dispatches Domain Events, but contains zero business rules. This article implements concrete use cases (PlaceOrder, CancelOrder, ProcessRefund) following the Command pattern and demonstrates the discipline of keeping application services thin.

The Application Layer: Orchestrating Use Cases with Commands and Queries

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. The Application layer's job is to be the thinnest possible orchestration shell between the delivery mechanism (HTTP, Kafka, CLI) and the domain model. It loads Aggregates, invokes their methods, persists them, and dispatches Domain Events. It does not calculate business rules, it does not know about Prisma, and it does not parse HTTP requests. If your use case contains an if statement that enforces a domain rule, that rule belongs in the domain — not here.

This article implements the three primary use cases for the billing engine: PlaceOrderUseCase, CancelOrderUseCase, and GetOrderSummaryUseCase. The first two are Commands (they change state). The third is a Query (it reads state). We separate them structurally from the start, laying the foundation for the full CQRS split in Part 11.

Architectural Note

Prerequisites: Part 4 (Aggregates & Repositories) for IOrderRepository and the Order Aggregate; Part 5 (Domain Events) for IEventBus and the post-commit dispatch timing rule. The use cases here depend on all three ports: IOrderRepository, IPaymentProcessor, and IEventBus.


1. The Bloated Service Anti-Pattern

Before defining what a use case is, it helps to name what it is not — the pattern it replaces:

TYPESCRIPT
// ❌ Anti-Pattern: Monolithic service — contains HTTP parsing, domain logic, AND infrastructure calls
@Injectable()
export class OrderService {
  constructor(
    private readonly prisma: PrismaClient,       // ← Infrastructure
    private readonly stripe: Stripe,             // ← Infrastructure
    private readonly mailer: NodeMailer,         // ← Infrastructure
    private readonly redis: RedisClient,         // ← Infrastructure
    private readonly logger: Logger,             // ← Cross-cutting concern
  ) {}

  async placeOrder(req: Request): Promise<Response> {  // ← HTTP concern in a service
    const dto = req.body;                              // ← Parsing HTTP in domain service

    // ❌ Business rule in service — should be in domain
    if (dto.items.length === 0) throw new HttpException('No items', 400);
    if (dto.total < 0) throw new HttpException('Invalid total', 400);

    // ❌ Discount calculation in service — should be in domain
    const customer = await this.prisma.customer.findUnique({ where: { id: dto.customerId } });
    let discount = 0;
    if (customer?.tier === 'VIP') discount = 0.20;

    // ❌ Payment in service — should be through a Port
    const intent = await this.stripe.paymentIntents.create({
      amount: Math.round(dto.total * (1 - discount) * 100),
      currency: 'usd',
    });

    // ❌ Persistence mixed with presentation
    const order = await this.prisma.order.create({ data: { /* ... */ } });

    // ❌ Email in service
    await this.mailer.sendMail({ to: customer!.email, subject: 'Order Confirmed', /* ... */ });

    return { orderId: order.id, status: 'placed' };
  }
}

This class has at least six reasons to change (HTTP format, discount policy, payment gateway, database schema, email template, caching strategy). Testing it requires a live database, live Stripe, and a live mail server. Adding a Kafka consumer that places orders means either duplicating this code or accepting that the service handles HTTP concerns.


2. Command DTOs: The Application Layer's API Surface

A Command is a plain TypeScript interface that describes what the user intends to do — no HTTP types, no Prisma types, no framework decorations. It is the entry point to the Application layer:

TYPESCRIPT
// src/application/order/commands/PlaceOrderCommand.ts
import { CustomerId } from '../../../domain/customer/CustomerId';
import { ProductId } from '../../../domain/product/ProductId';
import { Money } from '../../../domain/payment/Money';

export interface PlaceOrderItemDto {
  readonly productId: ProductId;
  readonly quantity: number;
  readonly unitPrice: Money;
}

export interface PlaceOrderCommand {
  readonly customerId: CustomerId;
  readonly items: PlaceOrderItemDto[];
  readonly shippingAddress: ShippingAddressDto;
  readonly promoCode?: string;
}
TYPESCRIPT
// src/application/order/commands/CancelOrderCommand.ts
import { OrderId } from '../../../domain/order/OrderId';
import { CustomerId } from '../../../domain/customer/CustomerId';

export interface CancelOrderCommand {
  readonly orderId: OrderId;
  readonly requestedBy: CustomerId; // For authorization — only the owning customer can cancel
  readonly reason: string;
}

These are value objects in disguise. They carry intent, not state. The HTTP controller constructs them from req.body. The Kafka consumer constructs them from a parsed message. The CLI command constructs them from parsed arguments. The use case receives the same PlaceOrderCommand regardless of delivery mechanism.

Pro Tip & Optimization

Use readonly on every field in Command DTOs. Commands are inputs — they should never be mutated after construction. TypeScript's Readonly<T> utility type or explicit readonly field modifiers enforce this at compile time.


3. PlaceOrderUseCase: The Primary Command

TYPESCRIPT
// src/application/order/PlaceOrderUseCase.ts
import { IOrderRepository } from '../../domain/order/ports/IOrderRepository';
import { ICustomerRepository } from '../../domain/customer/ports/ICustomerRepository';
import { IEventBus } from '../shared/IEventBus';
import { Order } from '../../domain/order/Order';
import { OrderId } from '../../domain/order/OrderId';
import { LineItem } from '../../domain/order/LineItem';
import { Address } from '../../domain/customer/Address';
import { DomainError } from '../../domain/shared/DomainError';
import { PlaceOrderCommand } from './commands/PlaceOrderCommand';

export class PlaceOrderUseCase {
  constructor(
    private readonly orders: IOrderRepository,
    private readonly customers: ICustomerRepository,
    private readonly eventBus: IEventBus,
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderId> {
    // ── Step 1: Load related Aggregates needed for validation ──
    const customer = await this.customers.findById(command.customerId);
    if (!customer) throw new DomainError(`Customer ${command.customerId} not found`);
    if (!customer.isActive())
      throw new DomainError(`Customer ${command.customerId} account is suspended`);

    // ── Step 2: Create the Aggregate ──
    const order = Order.create(command.customerId);

    // ── Step 3: Invoke domain methods — ALL invariant checks happen inside Order ──
    const shippingAddress = Address.create(command.shippingAddress);
    order.setShippingAddress(shippingAddress);

    for (const itemDto of command.items) {
      order.addItem(itemDto.productId, itemDto.quantity, itemDto.unitPrice);
    }

    if (command.promoCode) {
      // Domain Service handles discount application (Part 10)
      // this.discountService.apply(order, customer, command.promoCode);
    }

    order.place(); // ← Enforces: items > 0, address set, status is PENDING

    // ── Step 4: Persist ── (Transaction boundary is here)
    await this.orders.save(order);

    // ── Step 5: Dispatch domain events AFTER commit ──
    const events = order.pullDomainEvents();
    for (const event of events) {
      await this.eventBus.publish(event);
    }

    return order.id;
  }
}

Count the lines of business logic in this use case: zero. order.place() enforces all invariants. Address.create() validates the address. order.addItem() enforces quantity and price guards. The use case is pure orchestration — load, invoke, save, dispatch.

The use case is also completely testable without a database:

TYPESCRIPT
// tests/unit/application/PlaceOrderUseCase.test.ts
describe('PlaceOrderUseCase', () => {
  let orderRepo: InMemoryOrderRepository;
  let customerRepo: InMemoryCustomerRepository;
  let eventBus: SpyEventBus;
  let useCase: PlaceOrderUseCase;

  beforeEach(() => {
    orderRepo   = new InMemoryOrderRepository();
    customerRepo = new InMemoryCustomerRepository();
    eventBus    = new SpyEventBus();
    useCase     = new PlaceOrderUseCase(orderRepo, customerRepo, eventBus);
  });

  it('should place an order and return its ID', async () => {
    const customer = Customer.create('cust_001' as CustomerId, 'Alice', EmailAddress.create('alice@example.com'));
    await customerRepo.save(customer);

    const orderId = await useCase.execute({
      customerId: 'cust_001' as CustomerId,
      items: [{ productId: 'prod_A' as ProductId, quantity: 2, unitPrice: Money.of('USD', 49.99) }],
      shippingAddress: { street: '123 Main St', city: 'NYC', state: 'NY', postalCode: '10001', countryCode: 'US' },
    });

    expect(orderId).toBeDefined();
    const saved = await orderRepo.findById(orderId);
    expect(saved!.status).toBe(OrderStatus.PLACED);
    expect(eventBus.wasPublished('order.placed')).toBe(true);
  });

  it('should reject an order for a suspended customer', async () => {
    const customer = Customer.createSuspended('cust_002' as CustomerId);
    await customerRepo.save(customer);

    await expect(useCase.execute({
      customerId: 'cust_002' as CustomerId,
      items: [{ productId: 'prod_B' as ProductId, quantity: 1, unitPrice: Money.of('USD', 20.00) }],
      shippingAddress: testAddress(),
    })).rejects.toThrow(DomainError);

    expect(eventBus.publishedCount).toBe(0);
    expect(orderRepo.size).toBe(0);
  });

  it('should reject placement when no items are provided', async () => {
    const customer = Customer.create('cust_003' as CustomerId, 'Bob', EmailAddress.create('bob@example.com'));
    await customerRepo.save(customer);

    await expect(useCase.execute({
      customerId: 'cust_003' as CustomerId,
      items: [],
      shippingAddress: testAddress(),
    })).rejects.toThrow(/no line items/i);
  });
});

Three tests, zero external dependencies, runs in under 10ms.


4. CancelOrderUseCase

TYPESCRIPT
// src/application/order/CancelOrderUseCase.ts
import { IOrderRepository } from '../../domain/order/ports/IOrderRepository';
import { IEventBus } from '../shared/IEventBus';
import { OrderId } from '../../domain/order/OrderId';
import { CustomerId } from '../../domain/customer/CustomerId';
import { DomainError } from '../../domain/shared/DomainError';
import { CancelOrderCommand } from './commands/CancelOrderCommand';

export class CancelOrderUseCase {
  constructor(
    private readonly orders: IOrderRepository,
    private readonly eventBus: IEventBus,
  ) {}

  async execute(command: CancelOrderCommand): Promise<void> {
    // ── Step 1: Load the Aggregate ──
    const order = await this.orders.findById(command.orderId);
    if (!order) throw new DomainError(`Order ${command.orderId} not found`);

    // ── Step 2: Authorization check (Application concern, not Domain) ──
    // The Order knows its customerId — but whether this request is authorized
    // is an application-layer policy, not a domain invariant
    if (order.customerId !== command.requestedBy) {
      throw new DomainError(
        `Customer ${command.requestedBy} is not authorized to cancel Order ${command.orderId}`
      );
    }

    // ── Step 3: Invoke domain method — invariant checked inside Order ──
    order.cancel(command.reason);
    // Order.cancel() throws DomainError if status is SHIPPED or PAID — uncancellable states

    // ── Step 4: Persist ──
    await this.orders.save(order);

    // ── Step 5: Dispatch events ──
    const events = order.pullDomainEvents();
    for (const event of events) await this.eventBus.publish(event);
  }
}

Notice the authorization check sits in the Application layer, not in the domain. This is deliberate. Whether a given CustomerId is allowed to cancel OrderId is an authorization policy — it depends on context (admin users can cancel any order, customers can only cancel their own). Authorization is not a domain invariant; it is an application-layer gate.

Crucial Requirement

Domain vs. Application concern boundary: Domain invariants are universal and unconditional — "an Order cannot be shipped without confirmed payment" is true regardless of who is asking. Authorization rules are contextual and policy-driven — "only the owning customer or an admin can cancel an order" depends on the requesting party. Domain invariants live in Aggregate methods. Authorization lives in the Application layer (or a dedicated Policy object).


5. Queries vs. Commands: The CQRS Seed

Commands change state. Queries read state. Mixing them in the same method creates subtle bugs:

TYPESCRIPT
// ❌ Anti-Pattern: Command that also returns domain state (breaks CQS)
async placeOrder(command: PlaceOrderCommand): Promise<Order> {
  const order = Order.create(command.customerId);
  order.place();
  await this.orders.save(order);
  return order; // ← Returning the full domain object from a Command
}

// The caller now holds a reference to the in-memory Aggregate
// If they mutate it, those mutations are not persisted (until next save())
// The caller has no way to know the state might drift

The Command Segregation principle (half of CQRS — Part 11 covers the full split) says: commands return only the ID of the created/modified resource; queries return read-optimized view models.

5.1 GetOrderSummaryUseCase: A Read-Optimized Query

TYPESCRIPT
// src/application/order/queries/GetOrderSummaryQuery.ts
import { OrderId } from '../../../domain/order/OrderId';

export interface GetOrderSummaryQuery {
  readonly orderId: OrderId;
  readonly requestedBy: string; // For audit logging
}

export interface OrderSummaryView {
  orderId: string;
  status: string;
  customerName: string;
  total: string;           // Formatted string — display-ready
  itemCount: number;
  placedAt: string;        // ISO date string
  shippingAddress: {
    street: string;
    city: string;
    countryCode: string;
  } | null;
}
TYPESCRIPT
// src/application/order/GetOrderSummaryUseCase.ts
import { IOrderRepository } from '../../domain/order/ports/IOrderRepository';
import { ICustomerRepository } from '../../domain/customer/ports/ICustomerRepository';
import { DomainError } from '../../domain/shared/DomainError';
import { GetOrderSummaryQuery, OrderSummaryView } from './queries/GetOrderSummaryQuery';

export class GetOrderSummaryUseCase {
  constructor(
    private readonly orders: IOrderRepository,
    private readonly customers: ICustomerRepository,
  ) {}

  async execute(query: GetOrderSummaryQuery): Promise<OrderSummaryView> {
    const order = await this.orders.findById(query.orderId);
    if (!order) throw new DomainError(`Order ${query.orderId} not found`);

    const customer = await this.customers.findById(order.customerId);
    // Customer may have been deleted — use a safe fallback
    const customerName = customer?.name ?? 'Unknown Customer';

    return {
      orderId:    order.id,
      status:     order.status,
      customerName,
      total:      order.total.toString(),
      itemCount:  order.items.length,
      placedAt:   order.placedAt?.toISOString() ?? '',
      shippingAddress: order.shippingAddress
        ? {
            street:      order.shippingAddress.street,
            city:        order.shippingAddress.city,
            countryCode: order.shippingAddress.countryCode,
          }
        : null,
    };
  }
}

The query use case returns an OrderSummaryView — a plain object shaped for the UI. It does not return the Order domain object. The UI cannot accidentally call orderView.cancel() or mutate orderView.items because those methods and the mutable backing array do not exist on the view model.

In Part 11, this query use case is replaced by a direct SQL read against a materialized read model — the GetOrderSummaryUseCase will hit a dedicated order_summary_view table that is continuously updated by an OrderSummaryProjector subscribed to domain events. The Command side and Query side operate on entirely separate data models. The structure set up here makes that migration trivial.


6. Error Handling Strategy at the Application Boundary

The Application layer is where domain errors are translated into HTTP/gRPC/CLI errors. The use case throws typed DomainError instances. The Interface layer catches them and maps them to protocol-appropriate responses:

TYPESCRIPT
// src/interface/http/OrderController.ts
import { DomainError } from '../../domain/shared/DomainError';
import { PlaceOrderUseCase } from '../../application/order/PlaceOrderUseCase';

export class OrderController {
  constructor(private readonly placeOrder: PlaceOrderUseCase) {}

  async create(req: Request, res: Response): Promise<void> {
    try {
      const command = this.parseCommand(req);
      const orderId = await this.placeOrder.execute(command);
      res.status(201).json({ orderId });
    } catch (error) {
      if (error instanceof DomainError) {
        // Domain error = client sent an invalid request or violated a business rule
        res.status(422).json({ error: error.message, code: 'DOMAIN_RULE_VIOLATION' });
      } else {
        // Infrastructure error = database down, Stripe timeout, etc.
        console.error('Unexpected error in PlaceOrder:', error);
        res.status(500).json({ error: 'Internal server error' });
      }
    }
  }

  private parseCommand(req: Request): PlaceOrderCommand {
    // Validation of HTTP-specific concerns (required fields, types)
    // DomainError is not thrown here — HTTP 400 for missing fields, DomainError for rule violations
    const { customerId, items, shippingAddress } = req.body;
    if (!customerId) throw new HttpBadRequestError('customerId is required');
    if (!Array.isArray(items) || items.length === 0) throw new HttpBadRequestError('items must be non-empty array');
    return {
      customerId: customerId as CustomerId,
      items: items.map(i => ({
        productId: i.productId as ProductId,
        quantity: Number(i.quantity),
        unitPrice: Money.of(i.currency, Number(i.unitPrice)),
      })),
      shippingAddress,
    };
  }
}

The error taxonomy is:

  • HttpBadRequestError (400): malformed request — missing required field, wrong type
  • DomainError (422): well-formed request that violates a business rule — suspended customer, no items, invalid status transition
  • Unhandled Error (500): infrastructure failure — database connection, external API timeout

7. Application Layer Checklist

Before a use case is considered complete, every item must pass:

Check Verification
No business logic in the use case grep -n 'if.*order\.' PlaceOrderUseCase.ts should return zero results (all guards are in the domain)
No infrastructure imports grep -n 'prisma|stripe|redis|kafka' PlaceOrderUseCase.ts must be empty
All dependencies are interfaces Constructor parameters use IOrderRepository, not PrismaOrderRepository
Command fields are readonly TypeScript enforces this at compile time
Events dispatched post-commit pullDomainEvents() is called after save(), never before
Returns only IDs (Commands) or view models (Queries) Commands return OrderId, never Order; Queries return OrderSummaryView, never Order
Tested with in-memory doubles No test in tests/unit/ requires a live database

Summary

Concept Domain Rule
Use Case A single, named application action; one execute() method; one responsibility
Command DTO Plain TypeScript interface with readonly fields; no HTTP or ORM types
Orchestration Pattern Load → Invoke domain → Persist → Dispatch events → Return ID
Authorization Application-layer concern; never inside a domain method
Command returns ID Commands return only the created resource's ID, never the full domain object
Query returns view model Queries return a display-ready plain object; the domain Aggregate is never returned
No business logic Every if statement in a use case that enforces a domain rule is a misplacement

What's Next

In Part 7, we formalize the Port/Adapter vocabulary introduced throughout Parts 2–6 with the Hexagonal Architecture pattern — showing how the same billing engine core is simultaneously driven by HTTP, Kafka, and a CLI without any changes to domain or application code. Part 7: Ports & Adapters →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Application Layer#Use Cases#Command Pattern#TypeScript#Node.js#Clean Architecture#OOP
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.