Skip to content

diWiring, checked at compile time

Ports as the vocabulary your application defines, providers bound at one edge, and modules that declare their imports and exports — with unmet dependencies, leaked internals and resource leaks caught by the compiler, and Result instead of throws.

di

At a glance

ts
import { Module, Port, Provider, type ServiceOf } from "@btravstack/di";
import { Err, Ok, type AsyncResult } from "unthrown";

// 1. Ports: named by the domain, never by whatever will implement them.
class OrderRepository extends Port("OrderRepository")<{
  readonly findById: (id: string) => AsyncResult<Order, OrderNotFound>;
}> {}
class GetOrder extends Port("GetOrder")<{
  readonly execute: (id: string) => AsyncResult<Order, OrderNotFound>;
}> {}

// 2. Application: depends on the port, never on an adapter.
class GetOrderInteractor {
  private readonly orders: ServiceOf<OrderRepository>;
  constructor(orders: ServiceOf<OrderRepository>) {
    this.orders = orders;
  }
  execute(id: string): AsyncResult<Order, OrderNotFound> {
    return this.orders.findById(id);
  }
}

// 3. Adapter: bound at one edge. A resourceful one puts `Scope` in `Needs`.
const Persistence = Module("Persistence")({
  provides: [
    Provider(Database)([AppConfig], {
      acquire: (config) => openPool(config.dbUrl),
      release: (pool) => pool.close(),
    }),
    Provider(OrderRepository)([Database], {
      sync: (db) => ({ findById: (id) => db.query(id) }),
    }),
  ],
  exports: [OrderRepository], // Database stays internal to this module.
});

// 4. Composition root: `Scope` in `Needs` forces `Module.scoped`, which opens
//    a scope and guarantees it is closed — success, failure, or partial
//    failure — before this call resolves.
const App = Module("App")({
  imports: [Persistence],
  provides: [
    Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }),
  ],
  exports: [GetOrder],
});

const result = await Module.scoped(App, (ctx) =>
  ctx.get(GetOrder).execute("o-1"),
);

Swap Persistence for a resource-free in-memory module and Module.build (no scope, no teardown) compiles too — but passing the resourceful module to Module.build does not: Needs still contains Scope, so the call is rejected before anything runs.

Released under the MIT License.