Domain-Driven Design in TypeScriptPart 6

Put strategic and tactical DDD together in TypeScript

Trace one enterprise seat-allocation request through shared language, a bounded context, an aggregate, a transaction, and a domain event.

The series began with strategic questions: What does the business mean, where is each model valid, and who owns each decision? It then introduced tactical patterns that put those answers into code. This final article connects both sides of DDD by tracing one business request from an HTTP boundary to a domain decision, a transaction, and a published event.

The request comes from our enterprise onboarding example. An administrator asks for 21 more seats. The tenant already has 80, and the enterprise contract allows 100. The API must reject the request before anything is saved or published.

Change the request to 20 and the whole path should succeed: the tenant configuration advances to its next version while a SeatsAllocated event enters the outbox in the same transaction. After the request ends, a worker can publish that event so the onboarding workflow continues. Keeping the state change, publish intent, and tenant-scoped reads connected lets the team explain why the valid request progressed and why the invalid one left no partial work behind.

That one request is enough to test the architecture from this series. It crosses language, ownership, invariants, transactions, time, and isolation. This article follows the request through each boundary and shows what evidence the team needs at the end.

One business decision across the architecture

Open in Studio
The HTTP adapter parses input. The application loads facts. The domain decides. One transaction saves the accepted change and its outbox event.

Keep the first deployment simple

This does not require six services. Start with a modular monolith and one worker process:

  • the web process handles requests and calls application services
  • the worker drains the outbox and advances onboarding
  • Onboarding and Entitlements expose public module contracts
  • infrastructure applies tenant filters and persists domain state
  • one database supports local transactions and operational queries

A shared codebase can still have hard model boundaries

Open in Studio
The web process and worker use the same application model. Module APIs protect ownership before network boundaries add operational cost.

Split a module into its own service when ownership, scaling, regulation, or failure isolation makes the extra network boundary worthwhile. Boxes in a diagram are not a reason by themselves.

Parse the request without deciding the business rule

The HTTP adapter owns transport details. It checks that the body contains ids and a positive integer, then passes a command to the application:

const result = await allocateSeats(
  {
    tenantId: request.params.tenantId,
    contractId: request.body.contractId,
    requestedSeats: request.body.requestedSeats,
  },
  dependencies,
);

if (!result.ok) {
  return problemResponse(result.code);
}

return response.status(200).send(result.allocation);

The adapter does not calculate remaining seats. It also does not throw an HTTP exception from the domain. Its job is to translate the request and later translate the result.

Load facts without trusting the caller’s relationship

The application service loads the tenant configuration and enterprise contract. Those facts can come from separate repositories, so the I/O happens before the decision:

const [snapshot, contract] = await Promise.all([
  dependencies.loadTenant(input.tenantId),
  dependencies.loadContractForTenant(input.contractId, input.tenantId),
]);

if (!snapshot) return { ok: false, code: "TENANT_NOT_FOUND" };
if (!contract) return { ok: false, code: "CONTRACT_NOT_FOUND" };

The second repository method matters. Loading a contract by id alone would let a caller present an unrelated contract with a larger seat limit. loadContractForTenant returns the contract only when the tenant is covered by it. The application service then gives those facts to the aggregate:

const decision = TenantConfiguration.rehydrate(snapshot).allocateSeats(
  input.requestedSeats,
  contract,
  {
    id: dependencies.newEventId(),
    occurredAt: dependencies.now().toISOString(),
  },
);

if (!decision.ok) return decision;

TenantConfiguration.allocateSeats() owns the quota rule. It returns a new snapshot and a SeatsAllocated event without mutating the loaded object. The repository’s role is to fetch trusted facts, including the contract that actually covers this tenant; the aggregate’s role is to decide whether those facts permit the requested change. Keeping those responsibilities separate means the domain test does not need a database, while the repository can be tested specifically for tenant and contract scoping.

For the request of 21 seats, the function returns QUOTA_EXCEEDED before starting a transaction. A focused test should prove not only the error code but also the absence of a saved tenant and outbox event. That negative evidence matters because rejection is safe only when it leaves no partial state for a worker to discover later.

Save the state change and publish intent together

For the valid request, build the next tenant version and a past-tense event:

const saved = await dependencies.transaction(async (tx) => {
  const versionMatched = await tx.saveTenant(decision.allocation, snapshot.version);
  if (!versionMatched) return false;
  await tx.appendOutbox(decision.event);
  return true;
});

if (!saved) return { ok: false, code: "CONCURRENT_UPDATE" };

This transaction closes the dual-write gap. If the save fails, there is no event. If the commit succeeds and the worker is down, the outbox row waits. The HTTP process does not need the worker to be healthy to accept the business change.

The save also checks the version it loaded. If another request changed the tenant first, the update affects no rows and the application does not append the event. The unit tests cover that decision. A database integration test should go further by forcing the outbox insert to fail and proving the tenant update rolls back.

Let the workflow own what happens later

SeatsAllocated is a fact, not an instruction to call every downstream system from the request. The onboarding workflow reacts after commit. Depending on its current state, it might reserve provisioning capacity, wait for identity setup, or mark one readiness gate complete.

Store that progress. A support-facing view should be able to answer:

QuestionStored evidence
Was the seat change accepted?Tenant version and allocation history
Was follow-up work published?Outbox status and attempt history
What is onboarding waiting for?Workflow state and current step
Did a retry repeat an effect?Stable event id and receiver deduplication record
Which tenant may read the state?Execution context, query policy, and audit record

A carefully named aggregate is not enough if support still has to reconstruct progress from logs.

Prove the three promises

Run these tests before treating the design as complete:

  1. Reject an invalid allocation. Request 21 seats. Expect QUOTA_EXCEEDED, no transaction, no saved tenant, and no outbox event.
  2. Reject the wrong contract. Present a contract that does not cover this tenant. Expect CONTRACT_NOT_FOUND and no transaction.
  3. Reject a stale write. Change the tenant version between load and save. Expect CONCURRENT_UPDATE and no outbox event.
  4. Commit accepted state and intent together. Request 20 seats. Expect tenant version 5 with 100 allocated seats and one SeatsAllocated outbox event.
  5. Recover after the request. Stop the worker after the commit, restart it, and prove the event advances the workflow once.
  6. Block a cross-tenant read. Run the support query under a different tenant context and expect no result plus a useful audit signal.
  7. Explain a stalled launch. Stage a failed provisioning step and ask someone who did not write the feature to find the current state and safe recovery action.

The last exercise is a mentoring tool as much as an operational check. It reveals whether the model, telemetry, and runbook make sense to someone who did not carry the design in their head.

Use only the patterns that protect a promise

This request needed a shared language because “account” was ambiguous. It needed a context boundary because Onboarding may request seats but Entitlements owns the contract. It needed a domain rule because 101 seats must be rejected. It needed an outbox because follow-up work happens after the transaction. It needed tenant-aware infrastructure because one customer’s data must not appear in another customer’s request.

None of those promises requires a microservice for every noun or an event-sourced model. The seat allocation itself fits in one transaction, so adding a saga there would split an atomic decision into unnecessary steps. The workflow begins only for the follow-up work that genuinely continues after that transaction. This is the practical limit on pattern use: introduce a boundary when a business or failure boundary demands it, not because the pattern appeared elsewhere in the series.

That restraint is part of the DDD tradition too. The patterns are a language for reasoning about a model, not a maturity checklist. Evans’s deeper contribution was the feedback loop: people learn the domain together, put that understanding into the software, and use what implementation reveals to improve the model. Aggregates, repositories, events, and value objects are useful when they keep that loop honest. When a plain function and a database constraint communicate the rule better, use them.

Name the business promise, then use the smallest pattern that keeps it true when the system changes or fails.