Apograph is not a monolith with features bolted on. It is a small, generic host that turns a list of plugins into a running application. The host owns no domain logic — no auth, no users, no content.
Two hosts, one per runtime
// apps/server/src/main.ts
createServer({ plugins });
// apps/admin/src/main.tsx
createAdmin({ plugins });createServer runs each plugin’s init hook in order, imports its NestJS module,
and applies the global /api prefix and validation pipe. createAdmin mounts
the React root, the router and the providers, and registers the routes each
plugin contributes.
Adding a capability means adding a plugin and registering it. You never edit the host.
The server plugin contract
interface ServerPlugin {
name: string;
module: Type | DynamicModule;
onPluginInit?(): void | Promise<void>;
migrations?: { dir: () => string; table: string };
}A plugin is a NestJS module, an optional init hook, and an optional declaration of the migrations it owns. See server plugins.
The admin plugin contract
type AdminPlugin = {
name: string;
routes?: RouteItem[];
layout?: ReactNode;
slots?: SlotContribution[];
};Routes, an optional shell, and contributions into named extension points. See admin plugins.
One database connection
@apograph/database owns a single Drizzle/pg pool, opened in its init hook —
which is why it is first in the plugin list. It is exposed by dependency
injection (@InjectDatabase(), a global module) and as plain
getDatabase() / getPool().
It owns almost no schema of its own. The one exception is outbox_events, the
transactional outbox, which ships with its own migrations.
Every other plugin owns its own tables and its own migrations. There are no
foreign keys across plugin boundaries — a content row’s workspace_id is a
plain uuid, because the workspaces table belongs to identity and content-server
cannot reference it. Isolation is enforced in the application layer instead.
How a request flows
- The admin fires a TanStack Query mutation through a shared axios client with
an
/apibase URL. - A global
AuthGuardvalidates the session — database-backed, revocable, carried in an httpOnly cookie. PermissionsGuardplus@RequirePermissions('users:read')enforces RBAC. State-changing POSTs additionally pass anOriginGuard, which is the CSRF defence.- The use case runs inside a
UnitOfWorktransaction and appends its domain events to the transactional outbox using that same transaction — so an event commits if and only if the mutation does, and never without it. - After commit, the outbox dispatcher delivers each event to its subscribers.
The activity plugin’s subscriber turns the audited kinds into
activity_eventsrows. - The response returns and React Query updates client state.
The drain is triggered post-commit and awaited, so the audit row is normally
there before the response returns, with a five-second poll as the backstop.
Delivery is at-least-once and the audit insert is keyed on the event id with
ON CONFLICT DO NOTHING, so a redelivery never double-records.
Two ways plugins extend each other
Both exist so that one plugin can extend another with no direct dependency.
Admin: named slots
A plugin defines a slot; other plugins contribute into it as data.
// shell/admin defines it
export const SIDEBAR_NAV_SLOT = createSlot<NavItem>('sidebar.nav');
// workspaces/admin contributes
slots: [{ slot: SIDEBAR_NAV_SLOT, items: [{ label: 'Workspaces', to: '/workspaces' }] }]Slots are wired once at boot and read sorted by whoever renders them. See slots.
Server: DI ports
The depended-upon plugin declares a Symbol token and an interface, and
injects it @Optional(). The implementing plugin binds it.
That inversion is what keeps the package graph acyclic: content-server declares
CONTENT_ENTRY_EXTENSION and knows nothing about locales; the localization
plugin binds it and adds row-per-locale scoping to content’s pipeline. See
DI ports.
Packages resolve from source
Workspace packages are consumed with no build step. Their exports point at
./src/index.ts and tsconfig.base.json sets a custom
"@apograph/source" condition, so the admin’s Vite transpiles every package’s
source directly.
Run npx nx sync after changing cross-project dependencies, to update the
TypeScript project references.
This is also why publishing to npm has a pack step that rewrites each
manifest: a consumer outside the workspace cannot resolve from source.
What is deliberately absent
There is no external queue, no separate worker process, no cache server, no search cluster and no websocket layer. A deployment is the API, the static admin bundle and Postgres.
Outbox events leave the process through one door
The transactional outbox is an internal bus: its subscribers are plugins in the same server, and nothing outside the process can subscribe to it directly. The webhooks plugin is the subscriber that turns outbox events into HTTP deliveries — and it does so by queueing a row per endpoint inside the dispatcher’s transaction and POSTing from a worker with nothing open, never from the subscriber itself (ADR-0016). That separation is why one slow receiver cannot stall the activity log or the alarms evaluator.