Toast
ContributorDecisions

Future: Platform Architecture

Future: Platform Architecture

Companion to ADR-014: Modular Dependency Injection

This document captures the longer-term vision that the DI migration enables. Each step builds on the previous one and is independently shippable. These are directions, not commitments — each step should get its own ADR when the time comes.

Dependency Graph

Step 1: DI migration (ADR-014)
  └── Step 2: Declarative route definitions (machine-readable route metadata)
        └── Step 3: @toast/api-client (auto-generated from route metadata)
              ├── Step 4: CLI app (remote mode uses api-client, local mode uses services)
              ├── Step 5: Separated frontend (uses api-client + contracts)
              └── Step 6: Extension points (uses services + events + route registration)

Step 2: Declarative Route Definitions

Once services are injectable, the route layer becomes the next source of boilerplate. Today, adding an endpoint requires coordinating across 4 concerns in 3+ locations: OpenAPI route definition, permission middleware, auth context extraction, and response wrapping. A declarative route builder collapses these into a single definition that is both executable and machine-readable.

The problem today — adding a single endpoint requires:

// 1. Route definition (~30 lines of createRoute boilerplate)
const getContentRoute = createRoute({
  method: 'get',
  path: '/{id}',
  middleware: [requirePermission({ content: ['read'] })],
  request: { params: ContentParamsSchema },
  responses: {
    200: { content: { 'application/json': { schema: ContentSchema } } },
    401: { content: { 'application/json': { schema: ErrorSchema } } },
    403: { content: { 'application/json': { schema: ErrorSchema } } },
    404: { content: { 'application/json': { schema: ErrorSchema } } },
  },
});

// 2. Handler binding (in routes.ts)
routes.openapi(getContentRoute, (c) => controller.getContent(c, c.req.param('id')));

// 3. Controller method (extracts auth, calls service, wraps response)
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);
}

The 401/403 error responses are identical across every authenticated endpoint. The getAuthContext(c) call is in every handler. The permission middleware wiring is manual and easy to forget.

What a route builder gives you:

// Single definition — executable, machine-readable, complete
const getContent = defineRoute({
  method: 'get',
  path: '/{id}',
  permissions: { content: ['read'] },
  params: ContentParamsSchema,
  response: ContentSchema,
  handler: ({ params, auth }) => services.content.getContent(params.id, auth.siteId),
  notFound: (result) => result === null,
});

The builder auto-wires:

  • requirePermission middleware from the permissions declaration
  • Standard error responses (401, 403) on every authenticated route
  • Auth context extraction — auth is available in the handler without getAuthContext(c)
  • Response wrapping — return the data, the builder calls c.json()
  • 404 handling from the notFound predicate

Why this matters beyond DX:

  1. Machine-readable route metadata. Every route's permissions, schemas, and path are available as data at startup. This feeds directly into auto-generated API clients, admin UI route guards, CLI command scaffolding, and documentation.

  2. Permission auditing. Today, permissions are scattered across route files as middleware calls. With declarative definitions, you can enumerate every endpoint's required permissions at startup — useful for role management UI, security audits, and generating permission matrices.

  3. Reduced surface for bugs. Forgetting requirePermission on a route is a security bug. When permissions are a required field in the route definition (not optional middleware), the type system catches the omission.

Implementation sketch (~100-150 lines):

// apps/api/src/lib/route-builder.ts
import type { z } from 'zod';

interface RouteDefinition<TParams, TQuery, TBody, TResponse> {
  method: 'get' | 'post' | 'put' | 'patch' | 'delete';
  path: string;
  permissions: Record<string, string[]>;
  params?: z.ZodSchema<TParams>;
  query?: z.ZodSchema<TQuery>;
  body?: z.ZodSchema<TBody>;
  response: z.ZodSchema<TResponse>;
  handler: (ctx: {
    params: TParams;
    query: TQuery;
    body: TBody;
    auth: { user: ToastUser; siteId: string };
  }) => Promise<TResponse | null>;
  notFound?: (result: TResponse | null) => boolean;
}

The builder produces standard OpenAPIHono routes under the hood — Hono is still the runtime, this is just a construction helper. You can always drop down to raw createRoute() for endpoints that don't fit the pattern (webhooks, file uploads, SSE).

Step 3: @toast/api-client — Typed API Client

Before building a CLI or separated frontend, create a shared API client that both can consume. This lives in packages/api-client and is generated from the route metadata that Step 2's declarative definitions expose.

packages/api-client/
├── src/
│   ├── client.ts       # fetch-based client, typed per-endpoint
│   ├── types.ts        # Request/response types (from OpenAPI or contracts)
│   └── index.ts
  • The admin panel switches from raw fetch calls to @toast/api-client
  • The CLI and separated frontend use the same client
  • One place to handle auth tokens, error shapes, retry logic

Step 4: CLI App — @toast/cli

A CLI app that operates on a Toast instance. Two modes of operation:

Remote mode (primary): Talks to a running Toast API server over HTTP using @toast/api-client. No database connection needed.

toast content list --site my-site
toast content publish draft-123
toast site config set timezone "America/New_York"
toast backup export --format json

Local mode (advanced): For self-hosted users who want to run operations directly against the database without a running server. This is where DI pays off — the CLI can call buildAppServices() directly and use the same service layer the API uses, skipping HTTP entirely.

toast local migrate          # run pending migrations
toast local seed             # seed development data
toast local reindex          # rebuild search indexes

Architecture:

apps/cli/
├── src/
│   ├── commands/
│   │   ├── content.ts       # content subcommands
│   │   ├── site.ts          # site config subcommands
│   │   ├── backup.ts        # import/export
│   │   └── local.ts         # local-mode commands (uses services directly)
│   ├── client.ts            # wraps @toast/api-client with CLI auth
│   └── index.ts             # entry point, arg parsing

The CLI doesn't need its own service layer — it either calls the API (remote) or reuses the existing services (local). DI makes the local mode possible because services don't import HTTP globals.

Step 5: Separated Frontend — apps/frontend

The current admin panel is a React SPA served by the API. A separated public-facing frontend decouples the reader experience from the admin panel:

apps/
├── api/            # Hono API server (already exists)
├── admin/          # Admin panel — React SPA (already exists)
└── frontend/       # Public site — SSR/SSG reader-facing app (new)

Why separation matters:

  • The admin panel is a rich SPA (React 19, TanStack Router, Zustand). The public frontend needs different trade-offs: fast initial load, SEO, minimal JS.
  • Different deployment targets — the frontend could be a static site on a CDN, while admin stays on the API server.
  • Different auth models — admin uses session auth, the frontend may be entirely public or use lightweight token auth for member content.

What DI enables:

  • @toast/api-client gives the frontend typed access to the API without coupling to internals
  • @toast/contracts provides shared types (Content, Site, User) that both admin and frontend consume — no type drift
  • If the frontend needs server-side rendering with direct database access (like Ghost's dynamic routing), it can import services from the composition root just like the CLI's local mode

Framework choice is deferred — could be Astro, Next.js, or even Hono with JSX. The important thing is that the data layer is already abstracted behind contracts and services.

Step 6: Extension Points — Plugins and Themes

Once the service layer is cleanly injectable, Toast can expose extension points without requiring forks:

Service hooks: Plugins register callbacks on the event bus to react to content lifecycle events. The EventBus contract already supports this — plugins just need a registration mechanism.

// Hypothetical plugin API
export default function myPlugin(services: AppServices) {
  services.events.on('content.published', async (event) => {
    // Send to external newsletter, update search index, etc.
  });
}

Custom routes: Plugins can contribute Hono route groups that get mounted alongside core routes. The createApp(services) pattern makes this natural — plugins receive the same AppServices bag.

Theme system: The separated frontend (Step 5) enables themes. A theme is a frontend app that consumes @toast/api-client and renders content with its own templates/components. Themes don't touch the API layer.

This is explicitly future work. The DI migration is valuable on its own for testability and maintainability. Extension points are a consequence, not the goal.

On this page