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 order API; the samples below are lifted from it.
The port both adapters implement
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:
const makePersistenceModule = () =>
Module("Persistence")({
imports: [ConfigModule],
provides: [
Provider(Pool)([AppConfig], {
acquire: openPool,
release: (pool) => pool.close(),
}),
Provider(OrderRepository)([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:
const InMemoryPersistenceModule = Module("InMemoryPersistence")({
provides: [
Provider(OrderRepository)({
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:
const makeAppModule = <E, N>(persistence: Module<OrderRepository, E, N>) =>
Module("App")({
imports: [persistence],
provides: [
Provider(GetOrder)([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
// 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("o-1"),
);
// Tests: nothing resourceful, Needs is never, Module.build accepts it.
const built = await Module.build(makeAppModule(InMemoryPersistenceModule));The wrong pairing does not compile:
await Module.build(makeAppModule(makePersistenceModule())); // UNSATISFIED DEPENDENCIESScope is still in Needs, so the call's arity gate rejects it before anything runs. 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
it("returns the order", async () => {
const result = await Module.build(
makeAppModule(InMemoryPersistenceModule),
).flatMap((ctx) => ctx.get(GetOrder).execute("o-1"));
expect(result).toBeOkWith({ id: "o-1", 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
- Keep a port private — why
Poolnever leaks out of the production module. - Manage a resource's lifetime — what
acquire/releaseguarantee. - Entry points —
Module.buildandModule.scoped, precisely.