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 (orders.submit).
  4. Events — past-tense facts emitted by actions (PlanAccepted).
  5. 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 seventeen orders.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