How I think about use cases as the unit of work, where transaction boundaries belong, and the line between application and domain exceptions, after years of getting it wrong.
The first time the application layer really clicked for me was the third week of the DDD migration at the London product agency I led engineering at. We were rewriting a checkout flow on a boutique fitness product, and a teammate had stuffed every piece of the booking flow into one fat Rails controller. Studios, classes, membership counters, payment, the lot. Three hundred lines. He looked at me and said, “I know this is wrong, I just don’t know where any of it actually goes.” Yeah. That was the problem in one sentence.
So this post is about that “where does it go” question. Specifically the layer between your HTTP handlers and your domain model. I’ll lean on TypeScript and a NestJS-shaped layout because that’s what I reach for on greenfield, but the shape applies anywhere.
The simplest definition I’ve stuck with for years: an application service is one use case, and one use case is one transaction. Not “kind of one transaction”. One. If a single user action crosses two aggregates that need to commit or fail together, you’re inside one use case. If it doesn’t, you’re not.
That rule shows up in the code as a class per use case, never a god-service with twenty methods. PlaceOrder, not OrderService. CancelMembership, not MembershipManager. The class has one public method, usually execute, and it owns the transaction boundary.
// application/orders/place-order.ts
import { Inject, Injectable } from "@nestjs/common";
import { UnitOfWork } from "../shared/unit-of-work";
import { OrderRepository } from "../../domain/orders/order.repository";
import { InventoryRepository } from "../../domain/inventory/inventory.repository";
import { Order } from "../../domain/orders/order";
import { PlaceOrderInput, PlaceOrderOutput } from "./place-order.dto";
import { OutOfStock } from "../../domain/inventory/errors";
import { CheckoutFailed } from "./errors";
@Injectable()
export class PlaceOrder {
constructor(
@Inject("UnitOfWork") private readonly uow: UnitOfWork,
@Inject("OrderRepository") private readonly orders: OrderRepository,
@Inject("InventoryRepository") private readonly inventory: InventoryRepository,
) {}
async execute(input: PlaceOrderInput): Promise<PlaceOrderOutput> {
return this.uow.run(async () => {
const stock = await this.inventory.lockForCustomer(input.customerId, input.lines);
try {
stock.reserve(input.lines);
} catch (e) {
if (e instanceof OutOfStock) throw new CheckoutFailed("out_of_stock", e);
throw e;
}
const order = Order.draft(input.customerId, input.lines);
order.confirm();
await this.inventory.save(stock);
await this.orders.save(order);
return { orderId: order.id.value };
});
}
}
The transaction lives in uow.run, which I’ll come back to. The use case doesn’t know if it’s being called from HTTP, a job, or a Kafka consumer. And the only thing it returns is a DTO.
Hexagonal architecture got more press than it deserved, and I’ve watched teams turn it into a full-time religion. I don’t. Ports are plain TypeScript interfaces in the domain layer, adapters are concrete implementations in the infrastructure layer. That’s it.
// domain/orders/order.repository.ts
import { Order } from "./order";
export interface OrderRepository {
load(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
// infrastructure/orders/typeorm-order.repository.ts
import { Injectable } from "@nestjs/common";
import { DataSource } from "typeorm";
import { OrderRepository } from "../../domain/orders/order.repository";
import { Order } from "../../domain/orders/order";
import { OrderRow } from "./order.row";
import { toDomain, toRow } from "./order.mapper";
@Injectable()
export class TypeOrmOrderRepository implements OrderRepository {
constructor(private readonly ds: DataSource) {}
async load(id: string): Promise<Order | null> {
const row = await this.ds.getRepository(OrderRow).findOne({ where: { id } });
return row ? toDomain(row) : null;
}
async save(order: Order): Promise<void> {
await this.ds.getRepository(OrderRow).save(toRow(order));
}
}
The domain and application sides never import TypeORM. Only the adapter knows it exists. I’ve made that swap to Prisma twice and it was boring both times, which is the point.
This is the part teams keep getting wrong. The transaction boundary belongs to the use case, not the repository. Repositories save aggregates. The use case decides what counts as “saved together”. Put BEGIN/COMMIT inside the repository and you’ll either commit too early or quietly nest transactions and pretend that’s fine.
I model it with a tiny UnitOfWork port and a TypeORM-backed adapter.
// application/shared/unit-of-work.ts
export interface UnitOfWork {
run<T>(work: () => Promise<T>): Promise<T>;
}
// infrastructure/shared/typeorm-unit-of-work.ts
import { Injectable } from "@nestjs/common";
import { DataSource, EntityManager } from "typeorm";
import { UnitOfWork } from "../../application/shared/unit-of-work";
import { AsyncLocalStorage } from "async_hooks";
const txStore = new AsyncLocalStorage<EntityManager>();
@Injectable()
export class TypeOrmUnitOfWork implements UnitOfWork {
constructor(private readonly ds: DataSource) {}
run<T>(work: () => Promise<T>): Promise<T> {
return this.ds.transaction(async (em) => txStore.run(em, work));
}
static current(): EntityManager | undefined {
return txStore.getStore();
}
}
Repositories read TypeOrmUnitOfWork.current() to pick up the transactional EntityManager, falling back to the default outside a use case. The application layer stays ignorant of TypeORM. The transaction lives where the business decision lives.
Honestly I went back and forth on AsyncLocalStorage for this. Feels like small magic. But I’ve shipped two large NestJS systems with it and one without, and the one without ended up threading manager arguments through eleven function signatures. The magic was the better trade.
This is the one I bring up whenever someone asks why I’m strict about the boundary. Same agency, mid-DDD migration on a nightlife discovery and ticketing product. Booking flow ran through three services, and one of them helpfully wrapped its own BEGIN/COMMIT inside a method called chargeCard.
On a Thursday afternoon a teammate added retry on payment failures. The retry sat in the outer use case, already inside a transaction. The inner “transaction” inside chargeCard was actually a savepoint, but the retry didn’t know that. Card declined, retry kicked in, we double-charged about forty customers in twenty minutes. Stripe’s idempotency keys saved us from worse, but inventory count still drifted.
First wrong fix was a try/catch around the charge with a manual rollback. Made it worse, because the outer transaction was already poisoned and the rollback was happening at the wrong level. The real fix was killing the inner transaction wrapper entirely and treating chargeCard as a domain operation that takes a PaymentIntent and either succeeds or throws. About two hours of damage. The rule that stuck: exactly one place per request opens a transaction, and that place is the application service.
Domain objects don’t cross the application boundary. DTO in, DTO out. Inside, the use case talks to aggregates. Outside, callers see plain data. Sounds like ceremony until the first time a controller serializes an Order aggregate to JSON and accidentally exposes an internal customerCreditScore field on a public endpoint. Which, yes, I’ve watched happen.
// application/orders/place-order.dto.ts
import { Order } from "../../domain/orders/order";
export type PlaceOrderInput = {
customerId: string;
lines: Array<{ sku: string; quantity: number }>;
};
export type PlaceOrderOutput = {
orderId: string;
};
export const toPlaceOrderOutput = (order: Order): PlaceOrderOutput => ({
orderId: order.id.value,
});
Inputs validated at the edge with a Zod schema, outputs assembled explicitly. I don’t reach for class-transformer for this any more. A handwritten mapper is shorter, easier to grep, and never surprises anyone.
The line I draw, and have drawn for years: domain errors are facts the domain refuses to allow. Application errors are facts the application can’t complete. They live in different files and they wrap differently.
// domain/inventory/errors.ts
export class OutOfStock extends Error {
constructor(readonly sku: string) {
super(`out of stock: ${sku}`);
}
}
// application/orders/errors.ts
export class CheckoutFailed extends Error {
constructor(readonly reason: "out_of_stock" | "payment_declined", readonly cause?: Error) {
super(`checkout failed: ${reason}`);
}
}
The use case catches OutOfStock and rewraps it as CheckoutFailed. The HTTP layer maps CheckoutFailed to a 409, never to a 500. Domain errors never reach the controller. If a controller is try/catching OutOfStock directly, your layers are leaking.
The combat-sports tournament platform I CTO’d in London. Booking flow for tournament entries crossed two aggregates: TournamentSeat and AthleteRegistration. One use case, one transaction, textbook setup. The bug: aggregates raised domain events, and our event bus published them the moment raise() was called. The publisher didn’t know the transaction was still open.
A federation ran a qualifier on a Sunday morning. Athletes registered. The bus fired RegistrationConfirmed live. Downstream services started sending confirmation emails before the database commit landed. Then the transaction rolled back on a constraint violation, and now we had confirmation emails sitting in inboxes for registrations that never happened. About sixty athletes affected before we noticed. One of them tweeted his confirmation screenshot at us with “hey, why does my dashboard not show this?”
First wrong fix was to add a delay before publishing. Embarrassing, in retrospect. The real fix was an outbox pattern: aggregates raise events, the application service collects them, the unit-of-work writes them to an outbox table inside the same transaction, and a separate dispatcher publishes after commit. Took a weekend. The rule from that day: domain events never leave the database until the database says they exist. Only the application layer can enforce that, because it’s the only layer that owns the transaction.
Thanks for reading. If you’ve got thoughts, send them my way.