Skip to content

btravstackA backend framework for Node.js and TypeScript

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.

What it is

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.

What you get

Dependency injectionPlain values, no decorators or reflect-metadata. Unmet dependencies are compile errors.
Errors as valuesEvery fallible call returns a Result. Domain errors are typed and exhaustively matched.
ConfigurationEnvironment variables validated once, at boot, into typed values. A bad value exits 78 and says which.
Three transportsHTTP (contract-first, over oRPC), Temporal workers, AMQP consumers.
ObservabilityStructured logs correlated per request, OpenTelemetry traces and metrics.
LifecycleHealth probes, graceful drain, resource cleanup on every exit path.
TestingA harness that boots the real graph and swaps one provider at a time.

What it looks like

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.

ts
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.

ts
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.

ts
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.

ts
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.

How it compares

btravstackNestJSAdonisJSHand-rolled
Wiring checkedat compile timeat bootat bootnever
Dependency injectionplain valuesdecorators + metadatadecorators + metadataby hand
Errorsvalues, typedexceptions + filtersexceptions + handlersyour choice
Graceful shutdowndefaultopt-in hooksopt-in hookswrite it yourself
Ecosystemsmall, growingvery largelargenone
Full-stacknonoyes

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.

Where to start

The container is checked by the compiler

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; }'.

Released under the MIT License.