The order application
examples/order-domain, examples/order-application, examples/order-infrastructure — the three layers every deployment on the following pages boots unchanged — plus order-api-contract, order-temporal-contract and order-amqp-contract, the shared artifacts a client takes without the server.
pnpm turbo run test --filter=@btravstack/example-order-domain \
--filter=@btravstack/example-order-application \
--filter=@btravstack/example-order-infrastructure \
--filter="@btravstack/example-order-*-contract"Nothing here starts a process. order-infrastructure's suite runs against a real Prisma client over in-memory SQLite (the client is generated by turbo's generate task before test runs — nothing to install), and each contract package's suite proves a client can use it with nothing from its server in scope.
The domain: an entity, and three failures as values
order-domain depends on @btravstack/entity, unthrown and zod — domain modelling tools, no framework. The Order is an entity whose one rule is an invariant re-checked on every path that produces one, and placeOrder names the structural failure in the layer's own vocabulary:
export class Order extends Entity("Order")(
{
id: Entity.field(OrderId, { immutable: true }),
quantity: Quantity,
},
{
invariants: [
Entity.invariant(
(d) => d.quantity > 0,
(d) =>
`order ${d.id} asks for ${d.quantity} items, which is not a positive quantity`,
),
],
},
) {}
export const placeOrder = (
id: string,
quantity: number,
): Result<Order, InvalidQuantity> =>
Order.make({ id, quantity }).mapErrCases((matcher) =>
matcher.with(
P.tag("InvalidEntity"),
() => new InvalidQuantity({ id, quantity }),
),
);InvalidQuantity is the only failure this layer can raise. OrderNotFound and DuplicateOrder are declared here too but raised by whoever owns the storage: the domain names them so every outer layer speaks about them in the same terms, which is what stops a Prisma error code or an HTTP status from leaking inwards. fulfillment.ts adds OutOfStock, ShippingUnavailable and PaymentDeclined for the two sagas on the same grounds.
The application: ports declared by the caller
order-application declares, as di ports, what its use cases need from the outside world — spelled in the domain's vocabulary, so no adapter can widen what the use cases have to handle:
export class OrderRepository extends Port("OrderRepository")<{
readonly save: (order: Order) => AsyncResult<Order, DuplicateOrder>;
readonly find: (id: string) => AsyncResult<Order, OrderNotFound>;
readonly remove: (id: string) => AsyncResult<void, OrderNotFound>;
}> {}Beside it: Outbox (the read side of the transactional outbox — pending and markPublished, both E = never, because a database that will not answer is a defect, not a domain outcome), StockService and ShippingService (the two fulfillment ports the orders saga orchestrates), PaymentService (the billing saga's own port — authorize answers with the domain's permanent PaymentDeclined, capture and refund promise never, since a compensation must not invent new ways to fail), and the two use-case ports PlaceOrder and FindOrder. The Logger the interactors write to is not declared here: it is @btravstack/observability's port, imported like any other dependency. The interactors are classes provided with di's class arm:
export const placeOrderProvider = Provider(PlaceOrder)(
[OrderRepository, Logger],
{
class: PlaceOrderInteractor,
},
);The module — one per vertical, not one for the layer — provides neither OrderRepository nor Logger:
export const OrderApplicationModule = Module("OrderApplication")({
provides: [placeOrderProvider, findOrderProvider],
exports: [PlaceOrder, FindOrder],
});
export const CustomerApplicationModule = Module("CustomerApplication")({
provides: [findCustomerProvider],
exports: [FindCustomer],
});PlaceOrderInteractor depends on both and nothing here satisfies either, so di propagates both as unmet needs — the repository because the layer below fills it, the logger because the framework does, and there is nothing to re-export in either direction. That is what makes this layer testable with no database at all — its specs provide a stub repository from a TestModule that imports observability({ sink, level: "trace" }) — and it is what makes the layering a compile error rather than a convention (see the type tests below).
One module per vertical is what makes that gate exact. CustomerApplication owes CustomerRepository and not the logger, because only PlaceOrder writes a line; a single module for the layer had to owe every port any of its use cases owed, and every consumer had to close all of them. It is also what lets order-temporal-worker and order-amqp-worker import the orders vertical without carrying the customers one.
There is no kernel touchpoint left here. The log calls are structured — this.#logger.info("placing an order", { orderId: id, quantity }), a constant message with the ids as fields — and correlation is not this layer's job: @btravstack/observability's implementation reads currentUnit() fresh on every call, so each line carries the trace id of the unit that wrote it — data from the ambient store, never a capability (see Ambient data, injected capabilities and Log and correlate).
The infrastructure: P-codes stop here
order-infrastructure fills the hole. Its database is a resourceful provider — acquired when the scope opens, released when it closes, so the kernel's teardown reaches a real $disconnect():
export const orderDatabaseProvider = Provider(OrderDatabase)({
acquire: () => openDatabase(),
release: (db) => db.$disconnect(),
});OrderDatabase lives in an internal DatabaseModule that the two persistence modules import and neither re-exports — di's exports are declared, never inherited — so no outer module can reach the client and start speaking SQL. What crosses the boundary of OrderPersistenceModule is OrderRepository and Outbox, and of CustomerPersistenceModule, CustomerRepository. One provider reference behind both, so the two verticals share one connection. The repository's save is the transactional outbox's write side — the row and the fact of the row commit together or not at all — and its mapErrCases is where Prisma's vocabulary becomes the domain's:
save: (order) =>
db
.$tryTransaction((tx) =>
tx.order.tryCreate({ data: { orderId: order.id, quantity: order.quantity } }).flatMap(() =>
tx.outboxMessage.tryCreate({
data: {
kind: "order",
subjectId: order.id,
payload: JSON.stringify({ quantity: order.quantity }),
},
}),
),
)
.mapErrCases((matcher, defect) =>
matcher
.with(P.tag("UniqueConstraintViolation"), () => new DuplicateOrder({ id: order.id }))
.with(P.tag("ForeignKeyViolation"), (violation) => defect(violation))
.with(P.tag("RecordNotFound"), (missing) => defect(missing)),
)
.map(() => order),Every P-code @unthrown/prisma puts in tryCreate's error channel is named, because the matcher has no wildcard. Only the unique-constraint violation has a meaning the application shares; the other two describe a schema this adapter does not have, so reaching them is a bug — the defect channel, not E. Adding a fourth P-code upstream breaks this file and nothing downstream.
remove is the same shape in the other direction: tryDelete then a tombstone — an outbox row with a null payload — in one transaction, so a subscriber that learned an order exists also learns it is gone, and nothing is written when there was nothing to delete. prisma-outbox.ts is the read side: pending ordered by id so the relay publishes in commit order, filtered on publishedAt: null so a crash between publish and mark re-delivers rather than loses.
The specs pin the claims that matter: a real UNIQUE index raising a real P2002 becomes DuplicateOrder; a corrupt row surfaces as a defect, not an error; the event is left in the same write as the order and rolled back with it; the client is disconnected when the scope closes.
The contracts: taken without the server
Each transport's contract is a package of its own, depending on the contract library and zod and on nothing else in the workspace. order-api-contract declares the oRPC procedures and their error codes; each code is one arm of the router's exhaustive mapErrCases, so adding a domain error without a code stops the router compiling:
export const orderContract = {
orders: {
place: oc
.input(type<{ readonly id: string; readonly quantity: number }>())
.output(type<OrderView>())
.errors({
INVALID_QUANTITY: { data: type<OrderRef>() },
CONFLICT: { data: type<OrderRef>() },
}),
find: oc
.input(type<OrderRef>())
.output(type<OrderView>())
.errors({ NOT_FOUND: { data: type<OrderRef>() } }),
},
};order-temporal-contract declares one workflow and five activities, four errors marked nonRetryable; order-amqp-contract one exchange, one event and one subscriber queue with a retry / dead-letter policy. Their specs demonstrate the payoff: an oRPC client built from RouterContractClient<typeof orderContract> over a stub fetch, a workflow input validated from the contract alone, a broadcast payload validated with no worker or broker in scope.
Two kinds of type test
layering.test-d.ts — in order-domain and in each contract package — pins a package boundary. The domain's:
// @ts-expect-error — the domain layer must not be able to reach the application
// layer: order-domain does not depend on it, so the specifier does not resolve.
import type {} from "@btravstack/example-order-application";Because each layer is its own workspace package, the wrong-direction import does not resolve (TS2307), and the @ts-expect-error turns that into an assertion: add the dependency and the directive goes unused, which test:types reports. The contract packages do the same against their transport package, so a client can always take a contract without the router, activities or handlers that implement it.
needs-gate.test-d.ts in order-application pins di'sUNSATISFIED DEPENDENCIES gate on Module.scoped — a different gate from start's, and easy to conflate with it:
// Negative: nothing provides `OrderRepository`, so the gate becomes a required
// two-element tuple and the call is an arity error naming the unmet need.
// @ts-expect-error — UNSATISFIED DEPENDENCIES: no OrderRepository is provided.
const _unwiredOrders = Module.scoped(OrderApplicationModule, (ctx) =>
ctx.get(PlaceOrder).execute("o-1", 1),
);Each vertical's gate is pinned separately, which is the split showing up in the type tests: a graph that provides OrderRepository still cannot scope CustomerApplicationModule, and one that provides the customer repository alone cannot scope the orders half — nor can the orders repository alone, which leaves Logger open. The positive halves compose each module with its own stub — plus, for orders, a Provider(Logger)({ value: createLogger(() => {}) }), since the starter is the default and not the only way — and call Module.scoped as an ordinary two-argument call. The three deployment pages carry the other kind — start's gate.
Where to go next
- The first deployment: Order API (HTTP).
- Why the arrows point this way: Compile errors, not surprises and Modules and privacy.
- Resource lifetimes: Scopes and resource safety.