The use cases, and the ports they need to run. This layer turns the domain's
rules into operations — "place an order", "find an order" — and declares, as
@btravstack/di ports, what it needs the outside world to supply.
src/ports.ts OrderRepository, CustomerRepository, Outbox, StockService, ShippingService, PaymentService, PlaceOrder, FindOrder, FindCustomer
src/use-cases.ts the interactors, and their providers
src/module.ts OrderApplicationModule, CustomerApplicationModule
src/test-fixtures.ts the stub repositories and TestModule, as Vitest fixtures
export class OrderRepository extends Port("OrderRepository")<{
readonly save: (order: Order) => AsyncResult<Order, DuplicateOrder>;
readonly find: (id: string) => AsyncResult<Order, OrderNotFound>;
}> {}OrderRepository lives here because the use cases own the shape they need. Its
error channel is spelled in the domain's vocabulary — DuplicateOrder, not a
Postgres 23505 or a Prisma P2002 — so an adapter's job is to translate into
these terms, and no database code can widen what the use cases have to handle.
CustomerRepository is the same declaration for the customers vertical, and
read-only: nothing in this application registers a customer, so the port says
only what the use case needs. Its find answers with the domain's Customer,
never the transport's CustomerView — an adapter that spoke the wire's shape
would point the dependency arrow outwards.
PaymentService is the billing vertical's own port, the same shape as
StockService and ShippingService: authorize answers with the domain's
own permanent failure, PaymentDeclined — declared in order-domain, not
here, for the same reason OutOfStock and ShippingUnavailable are: it is a
domain answer a caller is entitled to branch on, whatever adapter happens to
produce it — and capture / refund promise never, because a compensation
must not invent new ways to fail. order-temporal-worker's BillingModule is
the adapter; nothing here
provides it.
export const OrderApplicationModule = Module("OrderApplication")({
provides: [placeOrderProvider, findOrderProvider],
exports: [PlaceOrder, FindOrder],
});
export const CustomerApplicationModule = Module("CustomerApplication")({
provides: [findCustomerProvider],
exports: [FindCustomer],
});The interactors depend on OrderRepository and CustomerRepository, and
nothing here provides either, so di propagates each as an unmet need of the
module that has it. Logger is a need of the orders half only — PlaceOrder
writes a line and nothing in the customers vertical does — and it is one for
the same reason from the other direction: it is @btravstack/observability's
port, not this layer's, so there is nothing here to provide and nothing to
re-export. Module.scoped(OrderApplicationModule, …) is therefore a compile
error — di's gate turns the module's remaining needs into a required argument
that carries them (src/needs-gate.test-d.ts pins each vertical's gate
separately). The message itself is only Expected 5 arguments, but got 2: an
arity error prints no type, so the ports are in the rest parameter rather than
in the line — hand-spelling the phantom arguments is what prints them, ending on
not assignable to parameter of type 'Logger | OrderRepository'.
The hole is not documentation; it is the type. An infrastructure module fills
it, and only then does the graph build.
Splitting the layer is what makes each gate exact rather than collective: a consumer of the orders vertical is asked for the orders repository, and a deployment that never answers a customer question — both workers — imports neither the use case nor its repository.
That hole is also why this layer's specs need no database. src/test-fixtures.ts
provides stub repositories from a module declared alongside the spec, and
injects it as a Vitest fixture:
const testModuleWith = (sink: Sink) =>
Module("Test")({
imports: [
OrderApplicationModule,
CustomerApplicationModule,
observability({ sink, level: "trace" }),
],
provides: [
stubRepository,
stubCustomerRepository,
Provider(Env)({ value: {} }),
],
exports: [PlaceOrder, FindOrder, FindCustomer],
});Seven specs cover placement, persistence, the duplicate path, the domain rule,
the log line and both arms of the customer lookup — with no Prisma, no HTTP and
no kernel booted. observability()
binds its level from the Env port start normally provides, so a kernel-free
spec provides an empty one itself; the sink is the seam a spec reads lines
back through.
this.#logger.info("placing an order", { orderId: id, quantity });The message is a constant and the ids are fields, which is what makes a line
groupable in the system that receives it — and what lets the spec assert
attributes: { orderId: "o-1", quantity: 2 } rather than match a substring.
Correlation is not this layer's job either: @btravstack/observability's logger
reads currentUnit() on every call, so each line carries the trace id of
whatever unit the runtime opened around it. In these specs there is no unit, so
line.unit is undefined — which the spec asserts.
Nothing here knows the kernel exists: the use cases, the ports and the module are all plain di, and the layer runs unchanged under a test runner, an HTTP server or a worker.
pnpm --filter @btravstack/example-order-application test # 7 specs
pnpm --filter @btravstack/example-order-application test:types # the needs gate