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; a mapErrCases triage is exhaustive. |
| 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.
Six names below are yours, not this framework's, and the page shows them rather than hiding them, because "where does PlaceOrder come from?" is the first question a reader has:
| Name | What it is |
|---|---|
PlaceOrder | your own di Port — the use case, with its own modeled errors. Nothing here declares it |
OrderPersistenceModule | your adapters: the repository behind that port |
api | your one defineHttp({ authenticators }) binding — see Protect a procedure |
RequestModule / UserModule / ServiceModule | your per-request modules, one per scheme a request can arrive under — see Open a per-request scope |
The tutorial builds every one of them from an empty directory, in order, with nothing declared off-page.
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)({
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/orpc-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, across modules: visibility, scopes, transitive needs | at boot | at boot | each call's own signature |
| Dependency injection | plain values | decorators + metadata | decorators + metadata | by hand |
| Errors | values, typed | exceptions + filters | exceptions + handlers | your choice |
| Graceful shutdown | on SIGTERM by default, with a Kubernetes pre-drain delay | opt-in, enableShutdownHooks() | on SIGTERM, through providers' shutdown() | write it yourself |
| Ecosystem | small, growing | very large | large | none |
| Full-stack | no | no | yes | — |
Every framework in that table manages its lifecycle, and so do Spring Boot (graceful shutdown on by default), the .NET Generic Host and Effect's runMain. The difference is narrower. The compiler checks the composition (module visibility, scopes and transitive needs, not only a constructor's arguments), and what it cannot see is a defect before any factory runs. The pre-drain delay, the drain's deadlines and its unit accounting are defaults rather than deployment work. The AdonisJS row is from its v6 lifecycle and service providers pages.
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.
entity models domain entities with Zod schemas and typed invariants. di composes type-safe ports, providers and modules without a framework runtime. Each has its own getting-started guide.
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; }'.