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:
requirePermissionmiddleware from thepermissionsdeclaration- Standard error responses (401, 403) on every authenticated route
- Auth context extraction —
authis available in the handler withoutgetAuthContext(c) - Response wrapping — return the data, the builder calls
c.json() - 404 handling from the
notFoundpredicate
Why this matters beyond DX:
-
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.
-
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.
-
Reduced surface for bugs. Forgetting
requirePermissionon 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
fetchcalls 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 jsonLocal 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 indexesArchitecture:
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 parsingThe 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-clientgives the frontend typed access to the API without coupling to internals@toast/contractsprovides 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.