Skip to content

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-supabase without 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.

See also

MIT licensed.