FabricFabricPlatform
Platform referenceDevelopment patterns

Building a vertical extension

A design checklist for shipping a new FabricModule.

This page is a design checklist, paired with the how-to in module contract. Read both together.

Step 1 — Define the ontology

Before writing code, write down:

  1. Object types — what kinds of entities does this vertical reason about?
  2. Lifecycle states — for each stateful object, what are its states and which state-class does each belong to (initial, active, action_required, waiting, terminal, exception)?
  3. Actions — every verb that mutates state. Use snake_case verbs scoped to your namespace (lending.accept_offer).
  4. Events — past-tense facts emitted by actions (OfferAccepted).
  5. Policies — gates you can name. "Borrower must have signed consent." "Submission must be inside business hours." Each gets a <id>.v1.

A useful smell test: if you can't draw the state machine, you can't ship the actions yet.

Step 2 — Pick the namespace

The namespace is permanent. It prefixes every action ID, IDs in audit logs, and event subject types in some cases. Choose carefully:

  • Lowercase letters, digits, hyphens.
  • One word if possible (lending), two with a hyphen if the domain is genuinely composite (borrower-portal).
  • Avoid abbreviations that mean nothing in five years.

Step 3 — Write the manifest

import type { FabricModule } from "@fabric/platform/modules";
import type { Prisma } from "@repo/database";

export const myVerticalModule: FabricModule<Prisma.TransactionClient> = {
  namespace: "myvertical",
  version: "0.1.0",
  objectTypes: ["EntityA", "EntityB"],
  eventTypes: [
    { eventType: "EntityACreated", schema: entityACreatedSchema, version: 1 },
    { eventType: "EntityATransitioned", schema: entityATransitionedSchema, version: 1 },
  ],
  actions: [createEntityAAction, transitionEntityAAction],
  policies: [entityAPreconditionPolicy],
  stateMachines: [entityAStateMachine],
};

Validation runs at registration time — see extending entities for the full list of checks.

Step 4 — Bind handlers to a concrete DB client

The platform parameterizes everything by TDb. Verticals bind it to whatever the host app uses — Prisma.TransactionClient, DrizzleClient, etc. The vertical is not portable across DB clients (the platform is). To run a vertical on a different ORM, fork the vertical.

type LendingTx = Prisma.TransactionClient;

export const acceptOfferAction: ActionDefinition<LendingTx> = {
  actionId: "lending.accept_offer",
  namespace: "lending",
  version: 1,
  schema: acceptOfferSchema,
  handler: async (ctx, params) => {
    const parsed = acceptOfferSchema.parse(params);
    // ctx.db is typed as LendingTx
  },
  policies: ["lending.credit_pull_consent.v1"],
  emitsEvents: ["OfferAccepted"],
  idempotent: false,
  mutatesDomain: true,
  stateMachine: {
    entityType: "Offer",
    targetState: "accepted",
    getEntityId: (params) => (params as any).offerId,
  },
};

Step 5 — Wire into the app

The host app composes modules at startup. There is no auto-discovery.

// apps/saas/lib/register-modules.ts (or similar)
import { registerFabricModules } from "@fabric/platform/modules";
import { messagingModule } from "@repo/messaging";
import { lendingModule } from "@repo/lending";
import { myVerticalModule } from "@repo/my-vertical";

registerFabricModules([messagingModule, lendingModule, myVerticalModule]);

The same call must happen in every process that runs handlers — the API, the worker, the MCP server. A common pattern is a single register-modules.ts imported at startup by each app.

Step 6 — Test the boundaries

Add module-specific boundary tests that fail CI on regressions:

  • No direct ORM mutations from API procedures (db.X.create | update | delete | upsert).
  • No direct handler imports from API procedures (import { someHandler } from "@repo/my-vertical/actions/handlers/...").
  • No vertical vocabulary leaks back into @fabric/platform.

Existing examples: packages/api/modules/{your-application,borrower-portal}/boundary.test.ts.

Step 7 — Ship the module's own unit tests

Every action handler should ship with handler-level unit tests. The platform's testing fixtures (@fabric/platform/testing) provide an in-memory adapter registry, a fake ActionContext, and deterministic IDs so handlers can be tested without a worker.

Boundary tests are the cross-cutting safety net; handler tests are the per-feature safety net. You want both.

Anti-patterns

  • Auto-registering on import. Side-effecting an import is unobservable from the call site. Force the host app to opt in.
  • One module per file. A namespace is a domain. lending.* is one module, not seventeen lending.subdomain modules.
  • "Just one direct write." There is no scenario in which a direct domain write is correct in production code. Find the action you are missing and add it.
  • Bypassing policies for "internal" actions. If it's actorType: system, it still has policies that may apply. The runtime evaluates them either way.

See also

On this page