One slice, end to end
The generate lines of the quick start build a vertical slice of
the ordering context: the request POST /orders crosses the four layers. The controller, the handler, the Order aggregate and
its repository are classes the generators wrote; the command bus comes from the platform that
init wrote.
- The controller (
presentation/http/orders/place-order/place-order.controller.ts) validates the body with its schema, buildsPlaceOrderCommandwith the actor of the request and sends it on the command bus (1, 2). - The command bus of the platform logs and measures the use case, lets it through only when the
actor holds
order.place, and opens the unit of work (3). - The handler (
application/commands/place-order/place-order.handler.ts) asks the aggregate:Order.place(...)checks its invariants and recordsOrderPlaced, or throws a refusal (4 to 6). - The handler saves through the
OrderRepositoryport of the domain. The Drizzle adapter (infrastructure/persistence/drizzle-order.repository.ts), bound to that port when the application starts, writes the row inside the transaction (7, 8). - The unit of work hands
OrderPlacedto its handlers, still inside the transaction, then commits (9). The controller answers201with the identifier (10, 11). A refusal answers a problem document (RFC 9457) that carries its stable code instead.
What is left to you is the business: the TODO(domain) places. Filled in, the domain and
application part of this slice reads as follows, in one block here where the generators write
one file per class (the refusal comes from generate error ordering Order empty, the event from
generate domain-event ordering Order Placed):
import { definePermissions } from '@ddd-ts-kit/auth';import { type Actor, AggregateRoot, AuthorizedCommand, type Clock, ConceptError, type DomainEvent, type IdGenerator, Repository,} from '@ddd-ts-kit/kernel';import { CommandHandler, Requires } from '@ddd-ts-kit/kernel/declarations';
// application/authorization/ordering.permissions.ts: `generate command` declared the permissionexport const orderingPermissions = definePermissions('ordering', { 'order.place': { description: 'Place order' },});
// domain/errors/order.error.ts: each refusal is a static factory with a stable codeexport class OrderError extends ConceptError { static empty() { return OrderError.refusal('ORDER_EMPTY', 'An order holds at least one product.'); }}
// domain/events/order-placed.event.ts: a fact, named in the past tenseexport class OrderPlaced implements DomainEvent { static readonly eventName = 'ordering.order.placed';
readonly name = OrderPlaced.eventName;
constructor( readonly aggregateId: string, readonly occurredAt: Date, readonly customerId: string, ) {}}
// domain/aggregates/order.aggregate.ts: the invariants live here, and nowhere elseexport interface OrderState { readonly customerId: string; readonly productIds: readonly string[]; readonly placedAt: Date;}
export class Order extends AggregateRoot<string> { private constructor( id: string, private state: OrderState, ) { super(id); }
// the customer and the products travel together: one parameter object, three parameters at most static place(id: string, request: Pick<OrderState, 'customerId' | 'productIds'>, now: Date): Order { if (request.productIds.length === 0) { throw OrderError.empty(); }
const order = new Order(id, { ...request, placedAt: now });
order.record(new OrderPlaced(id, now, request.customerId));
return order; }
static rehydrate(id: string, state: OrderState): Order { return new Order(id, state); }
toState(): OrderState { return { ...this.state }; }}
// domain/repositories/order.repository.ts: a port, an abstract class that is its own injection tokenexport abstract class OrderRepository extends Repository<Order> {}
// application/commands/place-order/place-order.command.ts: the actor comes first, then plain data@Requires(orderingPermissions['order.place'])export class PlaceOrderCommand extends AuthorizedCommand<string> { constructor( actor: Actor, readonly customerId: string, readonly productIds: readonly string[], ) { super(actor); }}
// application/commands/place-order/place-order.handler.ts: time and identifiers come from ports@CommandHandler(PlaceOrderCommand)export class PlaceOrderHandler implements CommandHandler<PlaceOrderCommand> { constructor( private readonly orderRepository: OrderRepository, private readonly ids: IdGenerator, private readonly clock: Clock, ) {}
async execute({ customerId, productIds }: PlaceOrderCommand): Promise<string> { const order = Order.place(this.ids.next(), { customerId, productIds }, this.clock.now());
await this.orderRepository.save(order);
return order.id; }}The handler never opens a transaction, never checks the permission and never publishes the event: the command bus and the repository do, around it. Its spec runs it through the test application with the fakes of its ports; Which command, when follows this first feature to the end, and the example application shows a whole product built this way.