Use Cases & Services
The application layer implements use cases - orchestration steps that fulfill one user intent ("Place order", "Refund invoice"). Services group related use cases when they share policies; repositories and gateways sit behind ports.
Recipe
Quick-reference recipe card - copy-paste ready.
// application/place-order.ts
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly inventory: InventoryPort
) {}
async execute(cmd: { customerId: string; sku: string; qty: number }) {
await this.inventory.reserve(cmd.sku, cmd.qty);
const order = { id: crypto.randomUUID(), ...cmd, status: "pending" as const };
await this.orders.save(order);
return order;
}
}When to reach for this:
- Route handlers grew past 30 lines with business rules
- Same workflow invoked from HTTP and queue consumer
- You need transactional boundaries documented per operation
- Unit tests require orchestration without database
Working Example
// domain/ports/order-repository.ts
export type Order = { id: string; customerId: string; sku: string; qty: number; status: string };
export interface OrderRepository {
save(order: Order): Promise<void>;
}
// domain/ports/inventory-port.ts
export interface InventoryPort {
reserve(sku: string, qty: number): Promise<void>;
}
// application/place-order.ts
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly inventory: InventoryPort
) {}
async execute(cmd: { customerId: string; sku: string; qty: number }) {
if (cmd.qty <= 0) throw new AppError("INVALID_QTY", 400);
await this.inventory.reserve(cmd.sku, cmd.qty);
const order = { id: crypto.randomUUID(), ...cmd, status: "pending" };
await this.orders.save(order);
return order;
}
}
// application/order-service.ts - optional facade for related commands
export class OrderService {
constructor(
private readonly placeOrder: PlaceOrder,
private readonly cancelOrder: CancelOrder
) {}
place(cmd: Parameters<PlaceOrder["execute"]>[0]) {
return this.placeOrder.execute(cmd);
}
cancel(orderId: string) {
return this.cancelOrder.execute(orderId);
}
}
// infrastructure/http/orders-controller.ts
import express from "express";
import type { PlaceOrder } from "../../application/place-order";
export function ordersRouter(placeOrder: PlaceOrder) {
const router = express.Router();
router.post("/", async (req, res, next) => {
try {
const order = await placeOrder.execute(req.body);
res.status(201).json({ data: toOrderDto(order) });
} catch (err) {
next(err);
}
});
return router;
}
function toOrderDto(order: { id: string; status: string }) {
return { id: order.id, status: order.status };
}
class AppError extends Error {
constructor(
public code: string,
public status: number
) {
super(code);
}
}
class CancelOrder {
async execute(_orderId: string) {
return { cancelled: true };
}
}What this demonstrates:
PlaceOrdersequences inventory then persistence- HTTP layer maps domain result to JSON DTO
OrderServiceoptional when multiple commands share module entry- Domain errors raised in use case; HTTP adapter does not branch on strings
Deep Dive
How It Works
- Use case = one application-specific operation with clear input/output
- Application service = facade exporting multiple use cases to adapters
- No I/O details - SQL, HTTP status codes, and SDK retries live outside
- Transactions - use case opens transaction boundary or delegates to unit-of-work port
Naming Conventions
| Name pattern | Example |
|---|---|
| Verb command | PlaceOrder, CancelSubscription |
| Query | GetOrderById, ListOpenInvoices |
| Policy service | PricingService (pure rules, no I/O) |
Use Case vs Domain Service
| Layer | Responsibility |
|---|---|
| Domain service | Pure business rules on entities |
| Use case | Orchestrate ports, transactions, authorization checks |
TypeScript Notes
// Input/output types explicit for adapter mapping
export type PlaceOrderInput = { customerId: string; sku: string; qty: number };
export type PlaceOrderResult = { id: string; status: "pending" };Gotchas
- God use case -
OrderEverythingwith 12 responsibilities. Fix: Split by command/query. - Use case returning HTTP Response - Couples to Express. Fix: Return domain object or Result type.
- Skipping authorization in use case - Only middleware checks roles. Fix: Use case enforces
canPlaceOrder(user, cmd). - Anemic use case - Only calls repo with no rules. Fix: Maybe route is enough; do not add layers for ceremony.
- Duplicate orchestration in worker and HTTP - Copy-paste diverges. Fix: Both call same
PlaceOrder.execute.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Transaction script in route | 3-endpoint CRUD | Rules and orchestration grow |
| CQRS handlers | Read/write scale split | Simple CRUD |
| Domain events only | Event-heavy workflows | Simple synchronous flows |
| Nest command handlers | Nest CQRS module adopted | Plain Express services |
FAQs
Class or function per use case?
Both work. Classes help DI containers; functions with deps param are fine for manual DI.
Where does Zod validation go?
HTTP adapter validates shape; use case validates business rules (inventory available, account active).
Should queries be use cases?
Yes. GetOrderById is a query use case - read-only, no side effects.
How do transactions fit?
Use case calls unitOfWork.withTransaction(async () => { ... }) port or repository method that accepts client.
Can use cases call other use cases?
Prefer composing smaller domain services or shared private helpers. Deep use-case chains hide flow.
What about Fastify?
Same pattern - route calls placeOrder.execute; use FastifyPlugin factory for injection.
How big should a use case file be?
Under 150 lines. Extract private functions or domain services when branching explodes.
Do use cases log?
Accept a Logger port or structured logger injected - log business events, not raw PII.
How do I handle idempotency?
Idempotency key check belongs in use case or dedicated IdempotentPlaceOrder decorator use case.
NestJS mapping?
OrdersService often becomes use cases or thin wrapper around them - avoid fat god OrdersService.
Related
- Modularity Basics - thin routes
- Repository Pattern - persistence port
- Dependency Injection Patterns - wiring use cases
- Hexagonal Architecture in Node - layer placement
- Error Handling in Express - map AppError to HTTP
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.