Eleven small packages, none of them published, all of them in the gate.
The order-* ten are one application booted three ways: a clean
architecture split across four layers, deployed once as an oRPC API, once as a
Temporal worker and once as an AMQP consumer, with each transport's contract in a
package of its own — and, at the same time, exercising @btravstack/core end to
end from a consumer's own workspace, workspace:* and all.
The eleventh, hexagonal-order-api, came with
@btravstack/di and is the container's own: it composes a Module and never
calls start.
| Package | Layer | Shows |
|---|---|---|
order-domain |
domain | Entities and rules with no dependencies at all: branded fields, an Entity.invariant re-checked on every path, failures as values. |
order-application |
use cases | Ports declared by the caller, interactors, and an ApplicationModule whose OrderRepository is deliberately an unmet need. |
order-infrastructure |
adapters | A Prisma-backed repository over in-memory SQLite, translating P-codes into the domain's vocabulary and closing the application's one need. |
order-config |
config | The one environment-variable idiom the three deployments share: a non-empty string piped into a coercion, validated as a value, with the seven cases pinned once. |
order-api-contract |
contract | The oRPC contract on its own — wire shapes and declared error codes — taken by the server that implements it and by any client. |
order-api |
runtime | The first deployment: an oRPC router over node:http, a scope forked per request, and Result → ORPCError. |
order-temporal-contract |
contract | The Temporal contract on its own — one workflow, five activities, four declared nonRetryable errors — read by the worker, the sandbox and the client. |
order-temporal-worker |
runtime | The orchestration deployment: a fulfillment saga on @btravstack/temporal — place, reserve, ship, and compensation in reverse on a permanent no. |
order-amqp-contract |
contract | The AMQP contract on its own — one exchange, one event, one subscriber queue with a retry/dead-letter policy — read by the relay and by any subscriber. |
order-amqp-worker |
runtime | The broadcast deployment: a transactional outbox relayed onto RabbitMQ by @btravstack/amqp's worker — every committed write becomes an event. |
order-api order-temporal-worker order-amqp-worker ← one runtime each; one process each
└────────────────┼──────────────────┘ ─────▶ order-config ← how all three read the environment
▼
order-infrastructure ← Prisma, SQLite, P-codes
│ provides OrderRepository
▼
order-application ← use cases, and the ports they declare
│
▼
order-domain ← entities and rules; depends on nothing
Every arrow points inwards, and the one that looks like it goes the wrong
way is the whole idea: order-infrastructure imports order-application,
because the port it implements — OrderRepository, spelled in the domain's
vocabulary — is declared by the caller that needs it, not by the database that
happens to satisfy it. ApplicationModule therefore leaves that need unmet,
which is not documentation but a type: Module.scoped(ApplicationModule, …)
does not compile until an outer module provides one.
A transport's contract is a shared artifact, so each one is a package of its own:
order-api any API client order-temporal-worker any workflow client order-amqp-worker any publisher
└──────────────┬───────┘ └───────────────┬───────┘ └────────────┬────────┘
▼ ▼ ▼
order-api-contract order-temporal-contract order-amqp-contract
← @orpc/contract ← @temporal-contract/contract, zod ← @amqp-contract/contract, zod
Every arrow points at a contract and none points out of one. That is the whole
of contract-first design: a client is entitled to the wire shapes and the
declared errors without the router or activity that implements them, the di
wiring behind it, the Prisma-backed repository behind that, or the kernel
booting the lot. The api and temporal contracts sat inside
order-api/src/contract.ts and order-temporal-worker/src/contract.ts before being
extracted, so no client could take one without the others until then;
order-amqp-contract started as its own package from the outset, the same
shape without the detour. None of the three depends on @btravstack/core, on
@btravstack/di, or on any other example — the transports depend on them.
The rule is enforced by the compiler rather than by review: each contract
package's src/layering.test-d.ts imports its transport package under a
@ts-expect-error, so adding the implementation to the contract's dependencies
makes the directive unused and fails test:types — the same shape
order-domain uses to keep the application layer out of the domain.
And the payoff is demonstrated rather than asserted. order-api-contract's own
spec builds a real oRPC client from RouterContractClient<typeof orderContract>
and drives it over a stub fetch, with nothing from order-api in scope;
order-temporal-contract's runs the workflow's input schema as a validator
returning a Result, which is the check a caller makes before starting an
execution — all a Temporal client can do without a running service.
order-amqp-contract's runs the placement message's own payload schema the
same way, with no worker, no connection and no broker in scope — the check a
publisher makes before sending a message.
Every composition root imports the same pair — ApplicationModule,
PersistenceModule — and exports its own selection of ports: nothing in
order-application or order-infrastructure differs between deployments, and
nothing could. What differs is what each transport is for:
order-apianswers a caller: a request arrives, a typed answer leaves.order-temporal-workerowns a journey: the fulfillment saga runs steps in order and compensates in reverse when one answers a permanent no — orchestration, which needs a durable owner.order-amqp-workertells everyone what happened: every committed write leaves an event through a transactional outbox — and a cancellation leaves a tombstone — broadcast, which needs no addressee at all.
The use cases return a Result, and what a Result means to a transport is
the transport's business — the same Err becomes different outcomes where
a caller exists to hear it:
| unthrown | order-api |
order-temporal-worker |
|---|---|---|
Ok(order) |
the procedure's output | the workflow's output |
Err(InvalidQuantity) |
INVALID_QUANTITY |
InvalidQuantity, non-retryable |
Err(DuplicateOrder) |
CONFLICT |
OrderAlreadyPlaced, non-retryable |
Defect |
INTERNAL_SERVER_ERROR |
retried by the platform, then fails |
order-amqp-worker is deliberately absent from that table: on a broadcast
there is no caller waiting to be told, so a placement's Err never crosses the
broker — only the committed fact does. The kernel appears in none of the
columns either way. RunUnit hands a runtime the work's own Result and stays
out of what it means.
The fourth and fifth columns carry something the second and third do not.
Naming a failure on a Temporal contract decides not only what the caller sees
but whether the platform retries it — both domain errors are declared
nonRetryable, so Temporal asks exactly once, while an unmodelled failure
stays unnamed and the retry policy takes over. A hand-rolled worker spells that
distinction as an attempt budget; on Temporal it is a line of contract, and
on AMQP it is too, in the broker's own vocabulary: order-notifications's
retry: { mode: "ttl-backoff", maxRetries: 3 } is contract configuration the
broker itself enforces, not a runtime constant. The count means something
different, though — maxRetries: 3 is retries on top of the first
attempt, so an unmodelled failure is attempted four times in total before
it is parked, not the three maximumAttempts: 3 names on Temporal.
The three deployments disagree about what one piece of work is, and the kernel
does not care — which is the point of RunUnit being parameterised by nothing
but UnitMeta:
| one unit is | id |
traceId |
|
|---|---|---|---|
order-api |
one HTTP request | a fresh randomUUID() |
an inbound x-request-id, if any |
order-temporal-worker |
one activity attempt | Temporal's task token | the workflow id |
order-amqp-worker |
one delivery | a minted randomUUID() |
the publisher's messageId |
All three are answering the same obligation — UnitMeta.id must be unique per
unit, because traceId defaults to it — and all three land on "the attempt, not
the logical thing", because a retry is a second unit and the same trace.
order-amqp-worker mints its id rather than reusing the broker's: a delivery
tag is not unique per attempt (see
@btravstack/amqp's README for why), where a
queue job id or a task token already is.
order-api's httpRuntime call declares [PlaceOrder, FindOrder, Logger],
temporalWorkerRuntime declares the five ports its activities resolve, and
orderAmqpRuntime declares [Outbox, Logger] — each a selection of what the
module exports, because a
runtime declares what it needs. The kernel's own testRuntime needs nothing,
so these three are what exercise start's phantom rest-tuple gate and
RuntimeHost's Context<InstanceType<Needs>> — where a runtime names port
classes while di parameterises contexts by port instances — against a real
module here. @btravstack/http's own AppModule/Greeting fixture
(packages/http/src/test-fixtures.ts, driving its 12
http-runtime.spec.ts specs) exercises the same runtime-side path a second
way now. examples/ stays the only place the gate is pinned by a type
test: @btravstack/http ships no *.test-d.ts.
All three directions are pinned, in order-api/src/needs-gate.test-d.ts,
order-temporal-worker/src/needs-gate.test-d.ts
and order-amqp-worker/src/needs-gate.test-d.ts: the wired call is an ordinary
two-argument one, and a module one port short fails on arity, naming the
missing need.
Each package reads as application code, and each is covered by real specs — 86
of them, run by the repository's own pnpm test:
pnpm install
pnpm test # every example's specs, alongside the kernel's own
pnpm typecheck # includes the compile-time-only guarantees pinned with @ts-expect-errorNothing is faked at the boundaries that matter. order-infrastructure runs
against a real Prisma client over in-memory SQLite, so a DuplicateOrder comes
from an actual UNIQUE index raising an actual P2002. order-api runs a real
node:http server and a real oRPC client over it, so the collapse of a Defect
to INTERNAL_SERVER_ERROR happens where it really happens. order-temporal-worker
runs a real TypedWorker polling a real task queue, so a drain that lets an
in-flight activity finish is the SDK's own DRAINING state and not a mock of
it. The fixtures reach for the cheapest thing that tests the real behaviour: the
Prisma client is generated by the test script itself, Temporal's time-skipping
test server is a local binary rather than a container, and where neither exists —
order-amqp-worker needs a real broker — the suite starts one with testcontainers.
Two suites need more than a checkout. order-temporal-worker needs network access on
a cold cache to fetch that 64 MB binary once (cached at
<repo>/.cache/temporal-test-server, gitignored, with a year-long ttl), which
costs about 3.5 s, once — see
order-temporal-worker's README.
order-amqp-worker needs a Docker daemon, because a real RabbitMQ is the only
honest way to test a drain against a live broker connection and real
acknowledgement — what an abandoned delivery costs once the kernel's own
deadline passes is not redelivery, only the release of a report; the broker
only redelivers once the connection itself drops, which happens when the
process actually dies, and that is not something a same-process suite can
observe. See
@btravstack/amqp's README.
Where a guarantee is compile-time only — an unmet port, a runtime's needs —
the assertion is a @ts-expect-error in a *.test-d.ts file, checked by tsc
rather than executed.
Nothing here is published: every package is "private": true and depends on the
kernel via workspace:*.
hexagonal-order-api came in with @btravstack/di when
it was merged into this repository, and it is about wiring rather than lifecycle:
it composes a Module and asserts what the container did, without booting a
process — ports named by the application, a private internal beside a public
surface, and one application module composed against a production adapter and an
in-memory one.
It is here for a second reason, and that one is load-bearing: it is the only
workspace in the repository that compiles twice. Its typecheck emits
declarations under the catalog's typescript and re-checks them under
typescript-consumer (5.9.3), because a published package has to be readable by
the stable line and the two emitters do not agree on everything.
src/emit-guards.ts is the fixture that keeps TS4020 — an unnameable private
type leaking into an emitted .d.ts — from coming back, and it is imported by
nothing on purpose: it exists to be compiled.
Two siblings came with it and were dropped in the same merge. request-scope
(a pool under Module.scoped, a transaction forked per request) is covered by
packages/di/src/fork.spec.ts and, in a real application, by
order-api's own per-request scope; plugin-registry (a
Port.many set port fed by two modules) is covered by
packages/di/src/many.spec.ts. Neither asserted anything the container's own
suite did not already pin, and an example that proves nothing new is an
illustration — which is what this directory is not.