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 (
orders.submit). - Events — past-tense facts emitted by actions (
PlanAccepted). - Policies — gates you can name. "Member 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 (
orders), two with a hyphen if the domain is genuinely composite (external-portal). - Avoid abbreviations that mean nothing in five years.
Step 3 — Write the manifest
import type { FabricModule } from "@fabricorg/platform/modules";
import type { Prisma } from "@example/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],
};using FabricOrg.Platform.Modules;
using Example.Database;
public static class MyVerticalModule
{
public static readonly FabricModule<PrismaTransactionClient> Instance = new()
{
Namespace = "myvertical",
Version = "0.1.0",
ObjectTypes = new[] { "EntityA", "EntityB" },
EventTypes = new[]
{
new EventTypeRegistration { EventType = "EntityACreated", Schema = EntityACreatedSchema, Version = 1 },
new EventTypeRegistration { EventType = "EntityATransitioned", Schema = EntityATransitionedSchema, Version = 1 },
},
Actions = new[] { CreateEntityAAction, TransitionEntityAAction },
Policies = new[] { EntityAPreconditionPolicy },
StateMachines = new[] { 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: "orders.submit",
namespace: "orders",
version: 1,
schema: acceptOfferSchema,
handler: async (ctx, params) => {
const parsed = acceptOfferSchema.parse(params);
// ctx.db is typed as LendingTx
},
policies: ["orders.payment_consent.v1"],
emitsEvents: ["PlanAccepted"],
idempotent: false,
mutatesDomain: true,
stateMachine: {
entityType: "Plan",
targetState: "accepted",
getEntityId: (params) => (params as any).planId,
},
};using FabricOrg.Platform.Actions;
public static class OrderActions
{
public static readonly ActionDefinition<LendingTx> AcceptOffer = new()
{
ActionId = ActionId.Parse("orders.submit"),
Namespace = "orders",
Version = 1,
Schema = AcceptOfferSchema,
Handler = async (ctx, parameters) =>
{
var parsed = AcceptOfferSchema.Parse(parameters);
// ctx.Db is typed as LendingTx
},
Policies = new[] { PolicyId.Parse("orders.payment_consent.v1") },
EmitsEvents = new[] { "PlanAccepted" },
Idempotent = false,
MutatesDomain = true,
StateMachine = new StateMachineBinding
{
EntityType = "Plan",
TargetState = "accepted",
GetEntityId = parameters => ((dynamic)parameters).PlanId,
},
};
}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 "@fabricorg/platform/modules";
import { messagingModule } from "@example/messaging";
import { ordersModule } from "@example/orders";
import { myVerticalModule } from "@example/my-vertical";
registerFabricModules([messagingModule, ordersModule, myVerticalModule]);// Program.cs (or similar)
using FabricOrg.Platform.Modules;
using Example.Messaging;
using Example.Orders;
using Example.MyVertical;
RegisterFabricModules(messagingModule, ordersModule, 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 "@example/my-vertical/actions/handlers/..."). - No vertical vocabulary leaks back into
@fabricorg/platform.
Existing examples: packages/api/modules/{your-application,external-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 (@fabricorg/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.
orders.*is one module, not seventeenorders.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.