Skip to content

Swap an adapter for tests ​

How-to. Compose one application module against two adapters and let the compiler pick the entry point. For why a module's exports are what make adapters interchangeable, see Modules and privacy.

Goal: one application module, two persistence adapters — a production one backed by a real pool, an in-memory one for tests — swappable at the composition root without touching the application.

This is the seam di is built around. The full version, compiled and tested, is Hexagonal (di alone); the samples below are lifted from it.

The port both adapters implement ​

ts
class OrderRepository extends Port("OrderRepository")<{
  readonly findById: (id: string) => AsyncResult<Order, OrderNotFound>;
}> {}

The application module depends on this port and nothing else — so any module that exports it will do.

The production adapter ​

Resourceful: the pool is acquired once and must be released, so this module's Needs carries Scope:

ts
const makePersistenceModule = () =>
  Module("Persistence")({
    imports: [ConfigModule],
    provides: [
      Provider(Pool)({
        inject: { config: AppConfig },
        acquire: openPool,
        release: (pool) => pool.close(),
      }),
      Provider(OrderRepository)({
        inject: { pool: Pool },
        sync: ({ pool }) => ({
          findById: (id) => {
            const row = pool.findById(id);
            return (
              row === undefined ? Err(new OrderNotFound({ id })) : Ok(row)
            ).toAsync();
          },
        }),
      }),
    ],
    exports: [OrderRepository], // Pool stays internal
  });

The test adapter ​

Nothing to acquire, nothing to release — Needs is never:

ts
const InMemoryPersistenceModule = Module("InMemoryPersistence")({
  provides: [
    Provider(OrderRepository)({
      inject: {},
      value: { findById: (id) => Ok({ id, total: 99 }).toAsync() },
    }),
  ],
  exports: [OrderRepository],
});

The seam: an application module generic in its adapter ​

Make the application module a function of the persistence module, generic in that module's own error and requirement channels:

ts
const makeAppModule = <E, N>(persistence: Module<OrderRepository, E, N>) =>
  Module("App")({
    imports: [persistence],
    provides: [
      Provider(GetOrder)({
        inject: { orders: OrderRepository },
        class: GetOrderInteractor,
      }),
    ],
    exports: [GetOrder],
  });

Module<OrderRepository, E, N> says: any module whose exports include OrderRepository, whatever it may fail with, whatever it still needs. Both channels flow through into the resulting application module — which is what makes the next step work.

Composition roots: the types pick the entry point ​

ts
// Production: the graph needs Scope (Pool is resourceful), so only
// Module.scoped — which opens a scope and guarantees its close — accepts it.
const result = await Module.scoped(
  makeAppModule(makePersistenceModule()),
  (ctx) => ctx.get(GetOrder).execute("0199a1e0-0000-7000-8000-000000000001"),
);

// Tests: nothing resourceful, Needs is never, Module.build accepts it.
const built = await Module.build(makeAppModule(InMemoryPersistenceModule));

The wrong pairing does not compile:

ts
// @ts-expect-error — UNSATISFIED DEPENDENCIES: `Scope` is still in `Needs`.
await Module.build(makeAppModule(makePersistenceModule()));

Scope is still in Needs, so the call's dependency gate rejects it before anything runs, and the message ends on the missing piece: required in type '{ readonly "UNSATISFIED DEPENDENCIES — nothing provides": Scope; }'. A test that quietly wires the production adapter into a scope-less build breaks at compile time, not in CI at midnight. Passing the in-memory module to Module.scoped is fine — Scope is simply absent from its Needs, and a scope that releases nothing is harmless.

In a test file ​

ts
it("returns the order", async () => {
  const result = await Module.build(
    makeAppModule(InMemoryPersistenceModule),
  ).flatMap((ctx) =>
    ctx.get(GetOrder).execute("0199a1e0-0000-7000-8000-000000000001"),
  );
  expect(result).toBeOkWith({
    id: "0199a1e0-0000-7000-8000-000000000001",
    total: 99,
  });
});

toBeOkWith is @unthrown/vitest's matcher — one deep assertion, the style this repo's own suites use.

The same seam under the kernel ​

An application booted by start swaps adapters the same way, one level up: compose a root that imports the stub persistence module in place of the real one and hand it to @btravstack/testing's boot, which starts it and stops it again when the test ends, whatever the body does. examples/order-api's test-fixtures.ts does exactly this — a persistenceOf(repository) module providing OrderRepository as a value, and an apiWith(repository) root that is HttpModule over the real application modules plus that stub. They never learn which one they got. See Test an application.

See also ​

Released under the MIT License.