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:
- Object types — what kinds of entities does this vertical reason about?
- 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)? - Actions — every verb that mutates state. Use snake_case verbs scoped to your namespace (
lending.accept_offer). - Events — past-tense facts emitted by actions (
OfferAccepted). - 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 seventeenlending.subdomainmodules. - "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
- Module contract (how-to) — worked example.
- Where invariants live — picking the right layer.
- Entity invariants — registration-time rules.