Wiring proven at compile time
A module that forgets a provider is a compile error naming the missing port — not a stack trace at boot. No decorators, no reflect-metadata.
Write the business code. The framework proves the wiring at compile time, keeps errors as values instead of exceptions, and shuts down the way Kubernetes expects.
btravstack is a backend framework for Node.js, written for TypeScript rather than adapted to it. You build an application out of ports and providers; the compiler checks that everything one needs, another supplies. Then a starter brings the transport — an HTTP server, a Temporal worker, an AMQP consumer — and the framework owns the process: boot, readiness, shutdown.
It is not a full-stack framework. There is no ORM, no templating, no frontend. It is the layer between your business logic and the process it runs in.
| Dependency injection | Plain values, no decorators or reflect-metadata. Unmet dependencies are compile errors. |
| Errors as values | Every fallible call returns a Result. Domain errors are typed and exhaustively matched. |
| Configuration | Environment variables validated once, at boot, into typed values. A bad value exits 78 and says which. |
| Three transports | HTTP (contract-first, over oRPC), Temporal workers, AMQP consumers. |
| Observability | Structured logs correlated per request, OpenTelemetry traces and metrics. |
| Lifecycle | Health probes, graceful drain, resource cleanup on every exit path. |
| Testing | A harness that boots the real graph and swaps one provider at a time. |
An HTTP API is four files: a contract, a router that implements it, a composition root, and an entry point.
contract.ts — what the API promises. A client can take this file alone.
import { authenticated } from "@btravstack/contract";
import { oc } from "@orpc/contract";
import { z } from "zod";
const orderRef = z.object({ id: z.uuidv7() });
export const ordersContract = authenticated({ user: [] })({
place: oc
.input(z.object({ id: z.uuidv7(), quantity: z.number() }))
.output(z.object({ id: z.uuidv7(), quantity: z.number() }))
.errors({
INVALID_QUANTITY: { data: orderRef },
BAD_REQUEST: { data: z.object({ id: z.string() }) },
CONFLICT: { data: orderRef },
}),
});router.ts — one function per procedure, typed by the contract. Every domain failure becomes a status code here, and nowhere else.
import { P } from "unthrown";
export const ordersRouter = api.OrpcRouter(ordersContract)({
inject: {},
unit: { place: PlaceOrder },
sync: () => ({
place: ({ errors, context }, input) =>
context.unit.place
.execute(input.id, input.quantity)
.map((order) => ({ id: order.id, quantity: order.quantity }))
.mapErrCases((matcher) =>
matcher
.with(P.tag("InvalidQuantity"), (e) =>
errors.INVALID_QUANTITY({
message: e.message,
data: { id: e.id },
}),
)
.with(P.tag("InvalidOrderId"), (e) =>
errors.BAD_REQUEST({ message: e.message, data: { id: e.id } }),
)
.with(P.tag("DuplicateOrder"), (e) =>
errors.CONFLICT({ message: e.message, data: { id: e.id } }),
),
),
}),
});Add a fourth error to the contract and this stops compiling until you handle it. That is the whole idea.
module.ts — the composition root. What the application is made of.
import { HttpModule } from "@btravstack/http-server";
import { sessionCodec } from "@btravstack/http-server/session";
export const OrdersApi = HttpModule("OrdersApi")({
router: ordersRouter,
// One unit module per kind a request can open under. `UserModule` is where
// the principal's tenant becomes a `Tenant` and the use cases are composed
// over it — see [Open a per-request scope](/how-to/open-a-per-request-scope).
unit: {
anonymous: RequestModule,
user: UserModule,
service: ServiceModule,
},
// The session cookie's codec, which the `session` scheme this application's
// door declares reads a cookie with.
provides: [sessionCodec()],
imports: [OrderPersistenceModule, observability(), otel()],
});main.ts — the entry point.
import { runMain } from "@btravstack/core";
await runMain(OrdersApi);That is the whole process. PORT and HOST are read inside the graph through a configuration provider — nothing here touches process.env — and the runtime is a service of the module rather than an argument to a factory.
| btravstack | NestJS | AdonisJS | Hand-rolled | |
|---|---|---|---|---|
| Wiring checked | at compile time | at boot | at boot | never |
| Dependency injection | plain values | decorators + metadata | decorators + metadata | by hand |
| Errors | values, typed | exceptions + filters | exceptions + handlers | your choice |
| Graceful shutdown | default | opt-in hooks | opt-in hooks | write it yourself |
| Ecosystem | small, growing | very large | large | none |
| Full-stack | no | no | yes | — |
NestJS has far more packages, integrations and hiring pool, and decorators are more concise to write. If that trade matters more than compile-time certainty, Nest is the better tool — its own comparison page says so in detail.
btravstack is for teams that already chose TypeScript for the type safety and want the framework to honour that choice rather than opt out of it.
OrderApplicationModule needs an OrderRepository and a Logger. This composition provides neither. Nothing runs — the call does not compile, and the error names both missing ports.
Module.scoped(OrderApplicationModule, (ctx) =>
ctx.get(PlaceOrder).execute(tenant, id, 1),
);src/needs-gate.test-d.ts:61:38 - error TS2345: Argument of type
'Module<ResolvedExports<readonly [typeof PlaceOrder, typeof
FindOrder]>, never, Logger | OrderRepository>'
is not assignable to parameter of type
'Module<…> & { readonly "UNSATISFIED DEPENDENCIES \u2014 nothing
provides": Logger | OrderRepository; }'.
Property '"UNSATISFIED DEPENDENCIES — nothing provides"' is missing
in type 'Module<…>' but required in type
'{ readonly "UNSATISFIED DEPENDENCIES \u2014 nothing provides":
Logger | OrderRepository; }'.