Skip to content

Driver Pattern ​

Every module with I/O (mail, storage, cache, session, queue, search, db, logging) does its work through a driver. The module's methods stay the same whichever driver is behind them; swapping the backend changes one line where the module is built.

What a driver is ​

A driver is an object that implements the module's driver interface. For storage that is StorageDriver:

ts
import type { FileMeta } from '@loewen-digital/fullstack/storage'

interface StorageDriver {
  get(key: string): Promise<Uint8Array | null>
  put(key: string, data: Uint8Array | string | ReadableStream, meta?: FileMeta): Promise<void>
  delete(key: string): Promise<void>
  exists(key: string): Promise<boolean>
  list(prefix?: string): Promise<string[]>
  getUrl(key: string): Promise<string>
}

The instance the module returns (StorageInstance) adds what is driver-independent, such as getText, and forwards the rest.

Choosing a driver ​

Two kinds of driver, two ways to get an instance:

  • Named drivers need nothing but their name. createX({ driver: 'memory' }) builds them.
  • Drivers with options (credentials, a client, a path) are built with their own factory and handed to createXInstance(driver). Naming one in createX throws and points to that factory.
ts
import {
  createStorage,
  createStorageInstance,
  createLocalDriver,
  createS3Driver,
  createR2Driver,
} from '@loewen-digital/fullstack/storage'

const inMemory = createStorage({ driver: 'memory' }) // tests
const onDisk = createStorageInstance(createLocalDriver({ root: './uploads', baseUrl: '/uploads' }))
const onS3 = createStorageInstance(
  createS3Driver({ bucket: 'my-bucket', region: 'eu-central-1', accessKeyId: '...', secretAccessKey: '...' }),
)
const onR2 = createStorageInstance(
  createR2Driver({ accountId: '...', bucket: 'my-bucket', accessKeyId: '...', secretAccessKey: '...' }),
)

onDisk.put('avatar.png', bytes) and onS3.put('avatar.png', bytes) are the same call.

Drivers per module ​

ModuleNamed (createX({ driver }))With options (createXDriver(options) into createXInstance)
mailconsolecreateSmtpDriver (needs nodemailer), createResendDriver, createPostmarkDriver
storagememorycreateLocalDriver, createS3Driver, createR2Driver
cachememorycreateRedisDriver (a Redis client), createKvDriver (a KV namespace)
sessionmemory, cookie (needs secret)createRedisDriver into createSessionManager
queuememorycreateRedisDriver, createCloudflareDriver (a Queue binding)
searchsqlite-ftscreateMeilisearchDriver, createTypesenseDriver; a custom driver into createSearch({ driver })
dbsqlite (drizzle-orm and better-sqlite3, optional peers you install)none yet; postgres, mysql and d1 are declared and throw
loggingconsoleTransport() is the defaultfileTransport, externalTransport, passed as transports

Swapping drivers per environment ​

Decide by what the environment provides. The memory driver in development and tests, Redis where REDIS_URL is set:

ts
import { createCache, createCacheInstance, createRedisDriver } from '@loewen-digital/fullstack/cache'

declare const redis: Parameters<typeof createRedisDriver>[0]['client'] // ioredis or node-redis v4+

export const cache = process.env.REDIS_URL
  ? createCacheInstance(createRedisDriver({ client: redis, prefix: 'app:' }))
  : createCache({ driver: 'memory' })

Writing a custom driver ​

Implement the interface and pass the object to createXInstance. A driver can wrap another one; this one namespaces every key, so two tenants share one store without seeing each other's entries:

ts
import { createCacheInstance, createMemoryDriver, type CacheDriver } from '@loewen-digital/fullstack/cache'

function withPrefix(inner: CacheDriver, prefix: string): CacheDriver {
  return {
    get: <T>(key: string) => inner.get<T>(`${prefix}${key}`),
    set: (key, value, ttl) => inner.set(`${prefix}${key}`, value, ttl),
    has: (key) => inner.has(`${prefix}${key}`),
    delete: (key) => inner.delete(`${prefix}${key}`),
    flush: () => inner.flush(),
  }
}

const tenantCache = createCacheInstance(withPrefix(createMemoryDriver(), 'tenant-a:'))

Nothing has to be registered: a driver is a value, and the module only sees the interface.

What a driver pulls in ​

The drivers are small and reach their service through fetch: Resend, Postmark, S3, R2, Meilisearch and Typesense need no SDK. The Redis, KV and Cloudflare Queue drivers take the client or binding you already have and import nothing. Three drivers load a package, and only when they are built: SMTP imports nodemailer on first send, the sqlite db driver loads drizzle-orm and better-sqlite3 and the sqlite-fts search driver better-sqlite3 when their factory runs. Every one of these packages is installed by you, not with @loewen-digital/fullstack.