Toast
ContributorDecisions

ADR-014: Modular Dependency Injection

ADR-014: Modular Dependency Injection

Status

Proposed

Context

Toast's codebase has grown to a point where the wiring between core systems (database, auth, config, events, HTTP) is creating friction:

  1. Module-level singletons with init/get patterngetDb(), getAuth(), eventBus are all module-level singletons. Services and repositories import them directly. This makes the dependency graph implicit and testing requires careful ordering.

  2. Services hardcode their repository importscontent.service.ts does import { contentService } from '../../services/index.js' at module scope. There's no way to swap implementations without module mocking.

  3. Auth is a ~600-line monolithapps/api/src/lib/auth.ts mixes Better Auth configuration, database hooks for multi-tenancy, email driver usage, event emission, session management, and cookie config. If Better Auth changes its type inference (as happened in 1.5.3), types break across the entire app.

  4. Config singleton forces import-time evaluationbuildConfig() is parameterized (accepts an optional env), but the module exports a singleton (export const config = buildConfig()) that runs immediately on import. Tests that need different config values must use vi.stubEnv() before any imports rather than passing config directly.

  5. Import-order dependency between DB and auth — Auth calls getDb() at construction time, requiring initDb() to have been called first. This was the subject of Issue #621. The current workaround (initDb() in index.ts before lazy getAuth()) works but the coupling is implicit — nothing in the type system enforces the ordering.

  6. No seams for testing — Because everything is imported at module level, unit tests rely on vi.mock() for module-level mocking rather than constructor/parameter injection.

The goal is not to build a framework-grade DI container, but to formalize the wiring pattern to make the codebase scalable, testable, and resilient to library changes.

The testing tax today

Currently, ~35 test files use ~86 vi.mock() invocations. A typical service test requires:

  • vi.hoisted() block defining 10-16 mock functions
  • 2-3 vi.mock() calls targeting exact module paths (../repositories/index.js, ../events/index.js, ../lib/slug.js)
  • Careful ordering of mock chain returns (repository tests mock the Drizzle builder chain in reverse)

Route tests are worse: 4-6 vi.mock() calls per file, mocking repositories, auth, events, and utilities. Adding a single new dependency to a service requires updating every test file that mocks that service.

What we're NOT doing

  • Not building a generic DI container — No decorators, no reflection, no runtime type scanning, no service registry. Plain variables in a composition root.
  • Not making core systems swappable for end users — That's what drivers are for.
  • Not adopting NestJS/Adonis patterns — Too slow, too coupled, kills standalone package usability.
  • Not class-ifying everything — Toast uses functions, and that's fine.
  • Not abstracting the ORM — Drizzle is the database boundary. Repositories own all Drizzle usage and import schema from @toast/db-schema. If Drizzle were replaced, repositories would be rewritten — nothing above them should change. The Database contract manages connection lifecycle, not query abstraction. The schema split (@toast/db-schema in shared/) is about correct placement, not abstraction.

Decision

Introduce a typed composition root with contract-based modules. Each core system becomes a module that:

  1. Exports a contract (TypeScript interface) from @toast/contracts
  2. Has a factory function that creates the concrete implementation
  3. Receives its dependencies as parameters (not global imports)
  4. Gets wired together in a single composition root at startup

This is essentially formalizing what index.ts already does (init db, then start server) into a structured, typed pattern.

How this solves Issue #621

Today, the import-order dependency between DB and auth is implicit:

index.ts: initDb({ url: config.database.url })  ← must happen first

app.ts: imports routes → routes import controllers → controllers import services

auth/routes.ts: getAuth().handler(req)  ← lazy construction calls getDb()

Nothing in the type system enforces that initDb() runs before getAuth(). If a new import path triggers auth construction before database init, the app crashes at runtime.

With the composition root, the dependency is explicit and compile-time enforced:

const database = createDatabaseModule(config); // db created first
const auth = createAuthModule({ config, database, events, emailDriver }); // receives db

Auth cannot be constructed without a database reference because the factory function's type signature requires it. The ordering bug becomes impossible.

Directory Structure

The monorepo uses a multi-tier directory structure with a clear litmus test for placement:

apps/        → Deploys somewhere (api, admin, frontend)
shared/      → Changes at feature cadence, Toast-specific, doesn't deploy alone
packages/    → Framework-level, could be extracted to a separate repo and used by a different application
drivers/     → Infrastructure integrations, closer to framework than application code

The extraction test: "Could this package be useful in a completely different application?" If yes → packages/. If it changes every time you add a feature → shared/.

  • packages/dbsplit required. The connection utilities and multi-tenant helpers are reusable ORM setup (packages/). But the schema definitions (content, users, sites) are Toast's domain — they change every time you add a feature (shared/). This ADR splits @toast/db into two packages:
    • packages/db — Drizzle connection setup, multi-tenant query helpers, migration tooling. Framework-level, no knowledge of Toast's domain.
    • shared/db-schema (@toast/db-schema) — Table definitions, relations, Zod schemas derived from tables. Changes at the feature cadence alongside contracts.
  • packages/drivers (email, storage adapters) — yes, generic integrations
  • packages/ui (Base UI components) — yes, reusable component library
  • packages/config (shared tsconfig/eslint) — yes, generic tooling config
  • shared/contracts (Toast's API shapes, user types, content schemas) — no, these are Toast's domain types

contracts and db-schema both change at the feature cadence, not the framework cadence. Every new API resource adds types to contracts and tables to db-schema. Moving them to shared/ signals this distinction and keeps packages/ reserved for framework-level code that has no knowledge of Toast's domain.

drivers/ stays as a separate workspace root. Drivers are infrastructure integrations — they implement typed interfaces from @toast/drivers and are independently versioned npm packages. They're closer to framework code than application code, but they have their own workspace entry because official driver packages (drivers/email-mailgun/, drivers/storage-s3/) live alongside the driver interfaces (packages/drivers/).

Architecture Overview

shared/
├── contracts/                     # EXISTING: @toast/contracts (moved from packages/)
│   └── src/
│       ├── index.ts               # Existing: users, content, site, etc.
│       ├── auth.ts                # EXISTING: ToastUser, ToastSession + NEW: AuthProvider
│       ├── database.ts            # NEW: Database contract (connection lifecycle)
│       ├── events.ts              # MOVE: EventBus interface (from apps/api/src/events/types.ts)
│       ├── config.ts              # NEW: AppConfig contract
│       └── logger.ts              # NEW: Logger contract
├── db-schema/                     # NEW: @toast/db-schema (split from packages/db)
│   └── src/
│       ├── index.ts               # Re-exports all tables, relations, Zod schemas
│       ├── content.ts             # content table definition + relations
│       ├── users.ts               # users table definition + relations
│       ├── sites.ts               # sites table definition + relations
│       └── ...                    # Other domain table definitions

apps/api/
├── src/
│   ├── container.ts               # NEW: Composition root (infra + services + repos)
│   ├── index.ts                   # Simplified: buildAppServices() → createApp() → serve()
│   ├── app.ts                     # Changed: createApp(appServices) instead of global imports
│   ├── modules/                   # NEW: Infrastructure module factories
│   │   ├── database.module.ts     # Creates db from config
│   │   ├── auth.module.ts         # Creates auth from db + config + events + email driver
│   │   ├── events.module.ts       # Creates event bus
│   │   └── logger.module.ts       # Creates logger from config
│   ├── routes/
│   │   └── content/
│   │       ├── routes.ts          # Changed: createContentRoutes(services)
│   │       └── content.controller.ts  # Changed: createContentController(services)
│   ├── services/                  # MIGRATED: factory functions returning service objects
│   └── repositories/              # MIGRATED: factory functions receiving db

Contracts

Each core system gets an interface in @toast/contracts. These are the seams.

Auth — the critical contract

ToastUser and ToastSession already exist in @toast/contracts and are already used by the session middleware and Hono types. The new work is the AuthProvider interface that wraps Better Auth behind a minimal boundary:

// shared/contracts/src/auth.ts

// EXISTING — already defined and used throughout the app
export interface ToastUser {
  /* ... */
}
export interface ToastSession {
  /* ... */
}

// NEW — abstracts the auth library
export interface AuthProvider {
  /** Handle auth HTTP requests (mount at /api/auth/*) */
  handler: (request: Request) => Promise<Response>;
  /** Extract session from request headers, or null if unauthenticated */
  getSession(headers: Headers): Promise<{ user: ToastUser; session: ToastSession } | null>;
}

Today, apps/api/src/lib/auth.ts re-exports ToastUser and ToastSession from @toast/contracts — the canonical types are already in the right place. The AuthProvider interface formalizes the boundary so that Better Auth's internals don't leak past auth.module.ts.

If Better Auth dies or breaks types: change auth.module.ts. Everything else sees ToastUser from @toast/contracts, which we control.

Database — connection lifecycle, not ORM abstraction

// shared/contracts/src/database.ts
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';

/**
 * Database contract — connection lifecycle management.
 *
 * This does NOT abstract the ORM. Repositories import Drizzle types
 * from @toast/db and schema objects from @toast/db-schema. The boundary is:
 * - Database contract: owns connection init, close, health check
 * - Repositories: own all Drizzle query logic
 * - If Drizzle is replaced, repositories change. Nothing above them does.
 */
export interface Database {
  /** The Drizzle ORM instance — passed to repository factories */
  readonly db: PostgresJsDatabase;
  /** Health check for readiness probes */
  checkConnection(): Promise<boolean>;
  /** Graceful shutdown */
  close(): Promise<void>;
}

Logger and EventBus

// shared/contracts/src/logger.ts

/** Logger contract — matches Pino's API surface */
export interface Logger {
  info(obj: Record<string, unknown>, msg: string): void;
  error(obj: Record<string, unknown>, msg: string): void;
  warn(obj: Record<string, unknown>, msg: string): void;
  debug(obj: Record<string, unknown>, msg: string): void;
  child(bindings: Record<string, unknown>): Logger;
}

The EventBus interface already exists in apps/api/src/events/types.ts. It moves to @toast/contracts unchanged.

Auth Module: Receiving the Email Driver

Today, auth.ts calls getEmailDriver() from @toast/drivers at module scope for magic link sending. This is another implicit singleton dependency. The auth module factory receives the email driver as an explicit parameter:

// apps/api/src/modules/auth.module.ts
import type { AuthProvider, ToastUser, ToastSession } from '@toast/contracts';
import type { EmailDriver } from '@toast/drivers';

export function createAuthModule(deps: {
  config: AppConfig;
  database: Database;
  events: EventBus;
  emailDriver: EmailDriver;
}): AuthProvider {
  // All Better Auth config, hooks, plugins — contained in this one file
  const auth = betterAuth({
    database: drizzleAdapter(deps.database.db, { ... }),
    // session hooks use deps.database.db directly
    // event emission uses deps.events
    // magic link uses deps.emailDriver
    ...
  });

  return {
    handler: (req) => auth.handler(req),
    async getSession(headers) {
      const result = await auth.api.getSession({ headers });
      if (!result) return null;
      // One cast at the boundary — Better Auth's inferred type → our contract
      return {
        user: result.user as ToastUser,
        session: result.session as ToastSession,
      };
    },
  };
}

This establishes the pattern for internal driver consumption: modules receive drivers as parameters, not by calling loader singletons. The driver loader (getEmailDriver()) is still used — but only in the composition root where it's called once and the result is passed to consumers.

Composition Root: Startup Wiring with Plain Variables

No container, no service registry — just a function that builds everything in dependency order and returns a typed bag of services. The composition root is the only file that knows about all the pieces. Everything else receives exactly what it needs.

// apps/api/src/container.ts
import { getEmailDriver } from '@toast/drivers';
import { buildConfig } from './config/index.js';
import { createAuthModule } from './modules/auth.module.js';
import { createDatabaseModule } from './modules/database.module.js';
import { createEventsModule } from './modules/events.module.js';
import { createLoggerModule } from './modules/logger.module.js';
// ... repository and service imports

/**
 * Application services — the aggregate that route factories receive.
 */
export interface AppServices {
  content: ContentService;
  site: SiteService;
  users: UsersService;
  auditLog: AuditLogService;
  uploads: UploadsService;
  version: VersionService;
  collaboration: CollaborationService;
  auth: AuthProvider;
  logger: Logger;
}

/**
 * Infrastructure — returned separately for lifecycle management (shutdown, health).
 */
export interface Infrastructure {
  database: Database;
  config: AppConfig;
}

export async function buildAppServices(): Promise<{
  services: AppServices;
  infra: Infrastructure;
}> {
  // --- Infrastructure (explicit dependency order) ---

  const config = buildConfig();
  const logger = createLoggerModule(config);
  const database = createDatabaseModule(config);
  const events = createEventsModule();
  const emailDriver = await getEmailDriver();
  const auth = createAuthModule({ config, database, events, emailDriver });

  // --- Repositories (receive db, not getDb()) ---

  const db = database.db;
  const contentRepo = createContentRepository(db);
  const siteRepo = createSiteRepository(db);
  const usersRepo = createUsersRepository(db);
  const auditLogRepo = createAuditLogRepository(db);
  const collaborationRepo = createCollaborationRepository(db);

  // --- Services (receive repos + infra, passed to route factories) ---

  const content = createContentService({ contentRepo, siteRepo, events });
  const site = createSiteService({ siteRepo, events });
  const users = createUsersService({ usersRepo });
  const auditLog = createAuditLogService({ auditLogRepo, usersRepo, logger });
  const uploads = createUploadsService({ config, logger });
  const version = createVersionService({ contentRepo, db });
  const collaboration = createCollaborationService({ collaborationRepo, config });

  return {
    services: { content, site, users, auditLog, uploads, version, collaboration, auth, logger },
    infra: { database, config },
  };
}

Why plain variables instead of a container: The composition root already enforces dependency order through variable declarations — auth can't reference database before it's assigned. A typed container (Map<string, unknown> with typed accessors) adds indirection without solving a problem that const declarations don't already solve. If a future need arises (plugin resolution by key, dynamic service registration), a container can be introduced then.

App Creation and Lifecycle

createApp receives the pre-wired services. No container on context, no singleton imports in route files.

// apps/api/src/app.ts
import type { AppServices } from './container.js';

export function createApp(services: AppServices) {
  const app = new OpenAPIHono<ApiEnv>();

  // Global middleware — same as today
  app.onError(createErrorHandler());
  app.use('*', requestId());
  app.use('*', requestLogger());
  app.use('*', cors({ ... }));

  // Auth handler — Better Auth routes (login, signup, magic link, etc.)
  app.all('/api/auth/*', (c) => services.auth.handler(c.req.raw));

  // Session middleware receives auth provider instead of calling getAuth()
  app.use('/api/*', session(services.auth));

  // Route registration — each group receives only the services it needs
  app.route('/healthz', createHealthRoutes(services));
  app.route('/api/content', createContentRoutes(services));
  app.route('/api/settings', createSettingsRoutes(services));
  app.route('/api/users', createUsersRoutes(services));
  app.route('/api/uploads', createUploadRoutes(services));
  app.route('/api/audit', createAuditRoutes(services));
  // ...

  return app;
}

Entry point with graceful shutdown

// apps/api/src/index.ts
import { buildAppServices } from './container.js';
import { createApp } from './app.js';

const { services, infra } = await buildAppServices();
const app = createApp(services);

const server = serve({ fetch: app.fetch, port: infra.config.environment.port });

// Graceful shutdown — close database connections on termination
function shutdown() {
  server.close();
  infra.database.close();
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

The Infrastructure return type gives the entry point access to lifecycle methods (database.close()) without exposing infrastructure to route handlers. Route handlers only see AppServices.

Session middleware

The session middleware receives AuthProvider instead of calling getAuth():

// apps/api/src/middleware/session.ts
import type { AuthProvider } from '@toast/contracts';

export function session(auth: AuthProvider): MiddlewareHandler {
  return async (c, next) => {
    const result = await auth.getSession(c.req.raw.headers);

    if (result) {
      c.set('user', result.user); // ToastUser from @toast/contracts
      c.set('session', result.session); // ToastSession from @toast/contracts
      c.set('siteId', result.user.siteId ?? null);
    } else {
      c.set('user', null);
      c.set('session', null);
      c.set('siteId', null);
    }

    await next();
  };
}

Migrating Services and Repositories

Services switch from top-level exported functions to factory functions returning an object. Each function that currently lives at module scope becomes a method on the returned object, closing over injected dependencies.

Service — before:

// content.service.ts — 11 exported functions, module-level imports
import { contentRepository, siteRepository } from '../repositories/index.js';
import { eventBus } from '../events/index.js';
import { generateSlug } from '../lib/slug.js';

export async function createContent(input, siteId, authorId) {
  const site = await siteRepository.findById(siteId);
  const slug = generateSlug(input.title);
  const row = await contentRepository.create({ ...input, siteId, authorId, slug });
  eventBus.emit(createContentCreatedEvent({ ... }));
  return { success: true, data: formatContent(row) };
}

export async function getContent(id, siteId) {
  return contentRepository.findById(siteId, id);
}

// ... 9 more exported functions

Service — after:

// content.service.ts — factory function, deps as params
import type { EventBus } from '@toast/contracts';
import type { ContentRepository } from '../repositories/content.repository.js';
import type { SiteRepository } from '../repositories/site.repository.js';

export function createContentService(deps: {
  contentRepo: ContentRepository;
  siteRepo: SiteRepository;
  events: EventBus;
}) {
  return {
    async createContent(input, siteId, authorId) {
      const site = await deps.siteRepo.findById(siteId);
      const slug = generateSlug(input.title);
      const row = await deps.contentRepo.create({ ...input, siteId, authorId, slug });
      deps.events.emit(createContentCreatedEvent({ ... }));
      return { success: true, data: formatContent(row) };
    },

    async getContent(id, siteId) {
      return deps.contentRepo.findById(siteId, id);
    },

    // ... remaining methods
  };
}

/** The service type — inferred from the factory, used by controllers and tests */
export type ContentService = ReturnType<typeof createContentService>;

Repository — before:

// content.repository.ts
import { getDb } from '@toast/db';
import { content } from '@toast/db'; // today: schema and connection in same package
import { eq, and } from 'drizzle-orm';

export function findById(siteId: string, id: string) {
  const db = getDb();
  return db
    .select()
    .from(content)
    .where(and(eq(content.siteId, siteId), eq(content.id, id)));
}

Repository — after:

// content.repository.ts
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { content } from '@toast/db-schema';
import { eq, and } from 'drizzle-orm';

export function createContentRepository(db: PostgresJsDatabase) {
  return {
    findById(siteId: string, id: string) {
      return db
        .select()
        .from(content)
        .where(and(eq(content.siteId, siteId), eq(content.id, id)));
    },
    // ... remaining methods
  };
}

export type ContentRepository = ReturnType<typeof createContentRepository>;

Why repositories import from @toast/db-schema, not via contracts: Schema objects (content, users) and query operators (eq, and) are imported directly — not abstracted behind contracts. Contracts abstract cross-layer boundaries (auth library → services, db connection → modules) where swapping an implementation is realistic. Schema imports are internal to the repository layer — repositories are the Drizzle layer. Wrapping every table definition behind a contract would be abstracting the ORM, which we explicitly reject above. If Drizzle changes, repositories change. Nothing above them does — that's the boundary that matters. The @toast/db-schema split simply places these domain definitions in shared/ where they belong (they change at the feature cadence), while keeping Drizzle connection utilities in packages/db.

Barrel files (services/index.ts, repositories/index.ts) currently use namespace re-exports (export * as contentService from './content.service.js'). During migration, barrel files are updated to re-export the factory functions and types. Consumers that import via barrel files (import { contentService } from '../../services/index.js') will be updated as part of the route factory migration.

Route and Controller Factories

Routes become factory functions that create an OpenAPIHono instance with a pre-wired controller:

// apps/api/src/routes/content/routes.ts
import type { AppServices } from '../../container.js';
import { createContentController } from './content.controller.js';

export function createContentRoutes(services: AppServices) {
  const controller = createContentController(services.content, services.version);
  const routes = new OpenAPIHono<ApiEnv>();

  routes.openapi(listContentRoute, (c) => controller.listContent(c, c.req.valid('query')));
  routes.openapi(getContentRoute, (c) => controller.getContent(c, c.req.param('id')));
  routes.openapi(createContentRoute, (c) => controller.createContent(c, c.req.valid('json')));
  // ...

  return routes;
}
// apps/api/src/routes/content/content.controller.ts
import type { ContentService } from '../../services/content.service.js';
import type { VersionService } from '../../services/version.service.js';

export function createContentController(
  contentService: ContentService,
  versionService: VersionService
) {
  return {
    async listContent(c: Context, query: ContentListQuery) {
      const { siteId } = getAuthContext(c);
      const result = await contentService.listContent(siteId, query);
      return c.json(result, 200);
    },

    async createContent(c: Context, body: CreateContent) {
      const { user, siteId } = getAuthContext(c);
      const result = await contentService.createContent(body, siteId, user.id);
      if (!result.success) return c.json({ error: result.error }, 409);
      return c.json(result.data, 201);
    },

    async getContent(c: Context, id: string) {
      const { siteId } = getAuthContext(c);
      const item = await contentService.getContent(id, siteId);
      if (!item) return c.json({ error: 'Content not found' }, 404);
      return c.json(item, 200);
    },

    // ... remaining handlers
  };
}

Testing

Service tests — the biggest payoff. No vi.mock(), no vi.hoisted(), no path coupling:

// content.service.test.ts
import { createContentService } from './content.service.js';
import { InMemoryEventBus } from '../events/event-bus.js';

test('creates content and emits event', async () => {
  const events = new InMemoryEventBus();
  const contentRepo = {
    create: vi.fn().mockResolvedValue({ id: '1', title: 'Test', siteId: 's1' }),
    findById: vi.fn(),
  };
  const siteRepo = {
    findById: vi.fn().mockResolvedValue({ id: 's1', title: 'My Site' }),
  };

  const service = createContentService({ contentRepo, siteRepo, events });
  await service.createContent({ title: 'Test' }, 's1', 'user-1');

  expect(contentRepo.create).toHaveBeenCalledWith(expect.objectContaining({ title: 'Test' }));
  // No vi.mock() needed. Adding a new dependency to the service doesn't break this test.
});

Controller tests — mock at the service interface:

// content.controller.test.ts
import { createContentController } from './content.controller.js';

test('returns 404 when content not found', async () => {
  const contentService = { getContent: vi.fn().mockResolvedValue(null) };
  const versionService = { listVersions: vi.fn() };
  const controller = createContentController(contentService, versionService);

  const c = createMockContext({ siteId: 's1', user: mockUser });
  const response = await controller.getContent(c, 'nonexistent-id');

  expect(response.status).toBe(404);
});

What DI doesn't fix: Repository tests still need to either mock the Drizzle query builder or use a real database. DI helps by making db a parameter (so you could pass a test database instance), but the query chain mocking problem remains for unit tests. Integration tests against a real database are the better solution for repositories.

Consequences

Positive

  • Clear directory semantics: The multi-tier structure (apps/ → deploys, shared/ → Toast-specific shared code, packages/ → extractable framework code, drivers/ → infrastructure integrations) makes placement decisions trivial via the extraction test.
  • Library isolation: Better Auth is behind AuthProvider interface. Its types don't leak past auth.module.ts. Type breakages in upstream updates are fixed in one file.
  • Import-order bugs eliminated: The composition root enforces dependency order at the type level. The initDb()getAuth() timing issue (Issue #621) becomes impossible because auth receives database as a parameter.
  • Testability: Service tests drop from ~3 vi.mock() calls + ~16 hoisted mocks to zero. Controller tests mock at the service interface, not the module graph.
  • Explicit dependency graph: The composition root shows exactly what depends on what. No hidden getX() calls buried in module scope.
  • Driver injection pattern: Auth receiving the email driver as a parameter establishes the pattern for all internal driver consumption — modules receive drivers, not loader singletons.
  • Graceful shutdown: The Infrastructure return type gives the entry point access to database.close() without exposing internals to route handlers.
  • Incremental migration: Each feature stack can be migrated independently. Old and new patterns coexist.
  • Type safety: ToastUser and ToastSession are defined in @toast/contracts — we control the shape. Service types are inferred from factories. No any leaks.
  • Negligible runtime cost: Service factories create closures at startup. No per-request overhead.

Negative

  • More files: Module factories and contracts add ~10 new files. But each file is smaller and has one job.
  • Service rewrite scope: Converting services from top-level functions to factory-returned objects is mechanical but touches every line. content.service.ts alone is ~630 lines.
  • Composition root verbosity: container.ts explicitly wires ~5 repos and ~7 services. This is intentional (explicit > magic) but grows linearly with features.
  • Learning curve: New contributors need to understand "services are created by factories in the composition root." But the pattern is simple — it's "pass your dependencies as arguments."

Risks

  • Over-abstracting too early: Only create contracts for systems that actually need seams (auth, db, events, logger). Don't create CookieProvider or HashingService unless there's a real need.
  • Auth contract fidelity: The ToastUser interface must be kept in sync with what Better Auth actually returns. TypeScript catches mismatches at the boundary cast in auth.module.ts, but only if tests exercise the auth flow. Integration tests for auth are essential.
  • Migration fatigue: ~7 feature stacks × ~5 files each = ~35 files to migrate. A vertical slice proof-of-concept on the content stack should validate that the pattern is worth the effort before committing to the full migration.

Future Directions

The DI foundation directly enables several future capabilities, each building on the previous. These are documented separately in Future: Platform Architecture:

  1. Declarative route definitions — Route builder that auto-wires permission middleware, error responses, and auth extraction. Machine-readable route metadata.
  2. @toast/api-client — Typed API client generated from route metadata, used by CLI and frontend.
  3. CLI app — Remote mode (HTTP via @toast/api-client) and local mode (direct service calls via composition root).
  4. Separated frontend — Public-facing app decoupled from admin panel.
  5. Extension points — Plugins registering on event bus, contributing routes.

Each step is independently shippable. The DI migration is valuable on its own for testability and maintainability; these future capabilities are consequences, not the goal.

References

On this page