Writing an adapter
Adapters in Nwire follow one pattern: contract package ships interface + in-memory default; adapter packages ship driver-specific implementations opt-in. Storage, mail, queue, bus, logger, container all follow it.
The pattern
@nwire/storage — contract + InMemoryStorage default (zero infra)
@nwire/storage-s3 — S3 / MinIO driver (consumers add this for S3)
@nwire/storage-fs — local filesystem driver
@nwire/storage-azure — (a future contributor adds this without touching contracts)The contract package is what your domain code imports. Adapter packages plug in at the app boot layer, never inside a module.
Why
- The contract is stable; drivers change. Splitting the package lets drivers evolve without forcing every consumer to rebuild.
- In-memory defaults mean tests run with zero infra by default; no Docker, no mocks.
- New drivers are additive — anyone can publish
@nwire/storage-supabasewithout coordinating a release of the contract.
The four parts of an adapter package
1. Contract (interface)
ts
// packages/nwire-storage/src/storage-contract.ts
export interface Storage {
put(key: string, body: Buffer | Uint8Array, opts?: PutOptions): Promise<{ etag: string }>
get(key: string): Promise<Uint8Array | null>
delete(key: string): Promise<void>
signedUrl(key: string, ttl?: number): Promise<string>
}
export const Storage = token<Storage>("Storage")2. In-memory default
ts
// packages/nwire-storage/src/in-memory-storage.ts
export class InMemoryStorage implements Storage {
private blobs = new Map<string, Uint8Array>()
async put(k, b) { this.blobs.set(k, b); return { etag: hash(b) } }
async get(k) { return this.blobs.get(k) ?? null }
async delete(k) { this.blobs.delete(k) }
async signedUrl(k) { return `memory://${k}` }
}3. Plugin that wires the contract
ts
// packages/nwire-storage/src/storage-plugin.ts
export const storagePlugin = definePlugin("storage", ({ provide }, opts: { driver?: Storage }) => {
provide(Storage, () => opts.driver ?? new InMemoryStorage())
})4. Adapter package — driver only
ts
// packages/nwire-storage-s3/src/s3-storage.ts
import { S3Client, PutObjectCommand /*...*/ } from "@aws-sdk/client-s3"
import type { Storage } from "@nwire/storage"
export class S3Storage implements Storage {
constructor(private client: S3Client, private bucket: string) {}
async put(key, body) { /* ... */ }
async get(key) { /* ... */ }
async delete(key) { /* ... */ }
async signedUrl(key) { /* ... */ }
}Consumer composition:
ts
import { storagePlugin } from "@nwire/storage"
import { S3Storage } from "@nwire/storage-s3"
import { S3Client } from "@aws-sdk/client-s3"
const app = await createApp({
plugins: [storagePlugin({ driver: new S3Storage(new S3Client({ region: "us-east-1" }), "my-bucket") })],
})Testing pattern
The contract package ships a test suite that any driver can run against itself:
ts
// packages/nwire-storage/src/storage-contract.test-suite.ts
export function storageContractSuite(name: string, factory: () => Storage) {
describe(name, () => {
it("put + get round-trips", async () => { /* ... */ })
it("delete removes the blob", async () => { /* ... */ })
it("signedUrl returns a string", async () => { /* ... */ })
})
}
// In packages/nwire-storage-s3/src/s3-storage.test.ts:
storageContractSuite("S3Storage (minio)", () => new S3Storage(minio, "test-bucket"))Every driver has to pass the same suite. Drift is impossible.