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 the shared PostgreSQL every workspace's tests use (the client is generated by turbo's generate task before test runs — nothing to install, but a Docker daemon is needed), isolating itself by minting a tenant of its own per test rather than a database; and each contract package's suite proves a client can use it with nothing from its server in scope.
The domain: an entity, four failures as values, and a brand
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 | InvalidOrderId> =>
Order.make({ id, quantity }).mapErrCases((matcher) =>
matcher.with(P.tag("InvalidEntity"), (invalid) =>
invalid.issues.some((issue) => Entity.keysOf(issue)[0] === "id")
? new InvalidOrderId({ id })
: // The schema PASSED for `id` — a malformed one is `InvalidOrderId`,
// checked first — so the claim is a cast, not a parse.
new InvalidQuantity({ id: id as OrderId, quantity }),
),
);InvalidQuantity and InvalidOrderId are the only failures this layer can raise, and they are told apart by which field the entity named — a schema issue carries a path, an Entity.invariant violation carries none. 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.
InvalidOrderId exists because OrderId is a z.uuidv7() brand. While the id was an unconstrained string the quantity was the only field a typed caller could get wrong, so collapsing InvalidEntity to InvalidQuantity was sound; giving the id a format made that a mislabelling — placeOrder("o-1", 2) answered "asks for 2 items, which is not a positive quantity" about a quantity the caller got right.
src/tenant.ts is the layer's other brand, with no entity behind it:
export const TenantIdSchema = z.uuidv7().brand("TenantId");
export type TenantId = z.infer<typeof TenantIdSchema>;
export const TenantId = (raw: string): TenantId => raw as TenantId;It lives here because it is vocabulary the whole system speaks, and the constructor is a cast, not a parse: every value that becomes one arrived through a contract that already validated it, and .parse() throws.
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 Tenant extends Port("Tenant")<TenantId> {}
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>;
}> {}No method names a tenant, because the tenant is the UNIT's. This deployment serves several tenants from one database, and Tenant is a port like any other: whoever opened the unit — an authenticated request, an activity attempt, a delivery — provides it once, and order-infrastructure's OrderTenantPersistence builds the repository over it inside that fork. So a call has no slot to name another tenant in, and a graph that never said which tenant it is scoped to does not compile at all — OrderApplicationModule names Tenant in needs. Nothing is read from an ambient store, and neither the kernel nor a starter knows a tenant exists.
CustomerRepository still takes one, and that is where the brand earns its keep. The customers procedures are unmarked, so a request under them opens an anonymous unit with no principal to take a tenant from and the caller names it on the input: find(tenantId, id). TenantId is order-domain's z.uuidv7().brand("TenantId"), because two strings in a fixed order are what the compiler has nothing to say about — find(id, tenantId) compiled and read the wrong tenant's rows — and branding one of the pair is enough to refuse it. The ids stay string here — they are OrderId/CustomerId on the entity, and a pair need differ in one position.
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)({
inject: { repository: OrderRepository, logger: Logger, tenant: Tenant },
class: PlaceOrderInteractor,
});The module — one per vertical, not one for the layer — provides none of OrderRepository, Logger or Tenant:
export const OrderApplicationModule = Module("OrderApplication")({
needs: [OrderRepository, Logger, Tenant],
provides: [placeOrderProvider, findOrderProvider],
exports: [PlaceOrder, FindOrder],
});
export const CustomerApplicationModule = Module("CustomerApplication")({
needs: [CustomerRepository],
provides: [findCustomerProvider],
exports: [FindCustomer],
});PlaceOrderInteractor depends on all three and nothing here satisfies any, so di propagates all three as unmet needs — the repository because the layer below fills it, the logger because the framework does, the tenant because whoever opens a unit does, and there is nothing to re-export in any direction. That is what makes this layer testable with no database at all — its specs compose tenantOf(tenant), a stub repository and observability({ sink, level: "trace" }) into one scope, which is the shape a deployment's unit module has — 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", { tenantId: this.#tenant, orderId: id, quantity }), a constant message with the ids as fields, the tenant among them because the interactor holds it as an injected capability — 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 comes from @btravstack/prisma, which owns DATABASE_URL, the pool's lifetime — acquired when the scope opens, released when it closes, so the kernel's teardown reaches a real $disconnect() — and a span, a count and a log line per query:
export const OrderDatabaseModule = prismaDatabase("OrderDatabase")({
client: createClient,
});The one thing that stays here is createClient, because the client is generated from this application's schema and @unthrown/prisma's extension is applied to it — so the port carries exactly the client the repositories will hold.
OrderDatabase is OrderDatabaseModule.port, imported by every persistence module, and one provider reference behind all of them — so the verticals share one connection whatever the tree looks like. What crosses the boundary of CustomerPersistenceModule is CustomerRepository, and of OrderPersistenceModule, Outbox and OrderDatabaseModule itself: the client is re-exported deliberately, because OrderTenantPersistence is composed inside a unit and reads it from the application scope through needs rather than importing the database module — a fork constructs every provider in its own tree, so an import there would open a Prisma client per request. OrderRepository crosses that module's boundary, not this one's. 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:
// `tenantId` is closed over: `prismaOrderRepository(db, tenantId)` is built
// inside the unit, so no method takes one.
save: (order) =>
db
.$tryTransaction((tx) =>
tx.order
.tryCreate({ data: { tenantId, orderId: order.id, quantity: order.quantity } })
.flatMap(() =>
tx.outboxMessage.tryCreate({
data: {
tenantId,
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:
import { oc } from "@orpc/contract";
import { z } from "zod";
const orderView = z.object({ id: z.uuidv7(), quantity: z.number() });
export type OrderView = z.infer<typeof orderView>;
const orderRef = z.object({ id: z.uuidv7() });
export type OrderRef = z.infer<typeof orderRef>;
// The one ref whose `id` is a bare string — the id **as received**, which is
// exactly the value that is not a UUIDv7.
const malformedRef = z.object({ id: z.string() });
export const orderContract = {
orders: {
place: oc
.input(z.object({ id: z.uuidv7(), quantity: z.number() }))
.output(orderView)
.errors({
INVALID_QUANTITY: { data: orderRef },
BAD_REQUEST: { data: malformedRef },
CONFLICT: { data: orderRef },
}),
find: oc
.input(orderRef)
.output(orderView)
.errors({ NOT_FOUND: { data: orderRef } }),
},
};All three are schemas, with the wire types inferred from them rather than declared beside them — one definition, so what a procedure checks and what the compiler believes cannot drift apart. That is why zod is in the list above: oRPC's type<T>() would type the same procedures and validate nothing, and an input nobody checks arrives typed as whatever the contract claimed.
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 typecheck 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 `DependencyGate`'s marker
// rides `Module.scoped`'s parameter and the call fails assignability.
// @ts-expect-error — UNSATISFIED DEPENDENCIES: no OrderRepository is provided.
const _unwiredOrders = Module.scoped(OrderApplicationModule, (ctx) =>
ctx.get(PlaceOrder).execute("0199a1e0-0000-7000-8000-000000000001", 1),
);What that prints ends on the ports: required in type '{ readonly "UNSATISFIED DEPENDENCIES — nothing provides": Logger | OrderRepository | Tenant; }' (measured) — the label and the missing ports in one message, where the rest-tuple arity error this gate replaced printed Expected 5 arguments, but got 2. and nothing else.
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 and Tenant open, nor the repository and the logger together, which is TenantlessOrders and the property moving the tenant into the unit bought. The positive halves compose each module with its own stub — plus, for orders, a Provider(Logger)({ inject: {}, value: createLogger(() => {}) }), since the starter is the default and not the only way, and a tenantOf(TenantId("acme")) beside it — 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.