Building a vertical extension
A design checklist for shipping a new FabricModule, covering the decisions that are expensive to reverse once a vertical is live.
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 { createModuleRegistry } from "@fabricorg/platform/modules";
import { messagingModule } from "@example/messaging";
import { ordersModule } from "@example/orders";
import { myVerticalModule } from "@example/my-vertical";
const registry = createModuleRegistry([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.