Creating a custom module
A Vercia module is a self-contained domain: its own Postgres schema, Drizzle tables, service class, manifest, event types, and permissions. It integrates with the platform via @vercia/module-sdk and @vercia/kernel.
When to create a module
Create a module when you have:
- A distinct domain with its own tables (not just extra columns on existing ones)
- Permissions that other modules or tenants need to check
- Events that other parts of the system should react to
- A set of entitlements tied to billing plans
For thin CRUD that lives entirely within an existing module's domain, extend that module's service instead.
Generate the scaffold
``bash
pnpm platform module:create --id my-feature --name "My Feature"
`
This creates modules/my-feature/ with the standard structure and wires it into pnpm-workspace.yaml.
Module structure
`
modules/my-feature/
src/
index.ts public exports
manifest.ts data-only manifest (id, permissions, events, …)
my-feature-service.ts
repos/
my-feature-repo.ts
schema/
schema.ts Drizzle table definitions + RLS policies
migrations.ts inline migrations list
tests/
service.test.ts
isolation.test.ts
package.json
tsconfig.json
vitest.config.ts
`
1. Define the manifest
`typescript
// src/manifest.ts
import type { PlatformModuleManifest } from "@vercia/module-sdk";
export const myFeatureManifest: PlatformModuleManifest = {
id: "my-feature",
name: "My Feature",
version: "0.1.0",
platformRange: ">=0.1.0",
dependencies: [],
capabilities: ["content"],
permissions: [
{ key: "my-feature.widget.read", name: "Read widgets", scope: "tenant" },
{ key: "my-feature.widget.create", name: "Create widgets", scope: "tenant" },
{ key: "my-feature.widget.delete", name: "Delete widgets", scope: "tenant" },
],
settings: [],
featureFlags: [],
entitlements: [
{ key: "my-feature.widget_count", name: "Widget limit", unit: "count" },
],
navigation: [
{ label: "Widgets", route: "/admin/widgets", permission: "my-feature.widget.read" },
],
adminResources: [],
blocks: [],
events: {
published: [
{ type: "my-feature.widget.created", version: 1, payloadSchema: {} },
{ type: "my-feature.widget.deleted", version: 1, payloadSchema: {} },
],
consumed: [],
},
jobs: [],
webhooks: [],
healthChecks: [{ key: "my-feature.db", critical: true }],
dataClassification: "standard",
};
`
2. Define the schema
`typescript
// src/schema/schema.ts
import { pgTable, text, timestamp, index } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
export const myFeatureWidgets = pgTable(
"my_feature_widgets",
{
id: text("id").primaryKey(),
tenantId: text("tenant_id").notNull(),
name: text("name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index("my_feature_widgets_tenant_idx").on(t.tenantId)],
);
`
`typescript
// src/schema/migrations.ts
import type { InlineMigration } from "@vercia/database";
export const myFeatureMigrations: InlineMigration[] = [
{
version: "my_feature_0001",
sql:
CREATE TABLE IF NOT EXISTS my_feature_widgets (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE my_feature_widgets ENABLE ROW LEVEL SECURITY;
ALTER TABLE my_feature_widgets FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON my_feature_widgets
USING (tenant_id = current_setting('app.tenant_id', TRUE));
,
},
];
`
Always add RLS. Every table must have ENABLE ROW LEVEL SECURITY + a policy on tenant_id = current_setting('app.tenant_id', TRUE). The platform enforces this; missing it is a security defect.
3. Write the repository
`typescript
// src/repos/my-feature-repo.ts
import type { DbHandle } from "@vercia/database";
import { myFeatureWidgets } from "../schema/schema.js";
import { eq } from "drizzle-orm";
export class MyFeatureRepo {
constructor(private readonly db: DbHandle) {}
async list(tenantId: string) {
return this.db.select().from(myFeatureWidgets).where(eq(myFeatureWidgets.tenantId, tenantId));
}
async create(row: typeof myFeatureWidgets.$inferInsert) {
const [created] = await this.db.insert(myFeatureWidgets).values(row).returning();
return created!;
}
async delete(id: string) {
await this.db.delete(myFeatureWidgets).where(eq(myFeatureWidgets.id, id));
}
}
`
4. Write the service
`typescript
// src/my-feature-service.ts
import type { RequestContext } from "@vercia/kernel";
import type { AuthorizationPort } from "@vercia/authorization";
import type { AuditWriter, OutboxStore } from "@vercia/kernel";
import { errors, makeIntegrationEvent, outboxRecordFromEvent } from "@vercia/kernel";
import { IdGenerator } from "@vercia/kernel";
import type { DbHandle } from "@vercia/database";
import { MyFeatureRepo } from "./repos/my-feature-repo.js";
export class MyFeatureService {
private readonly repo: MyFeatureRepo;
constructor(
db: DbHandle,
private readonly deps: {
authz: AuthorizationPort;
audit: AuditWriter;
outbox: OutboxStore;
},
) {
this.repo = new MyFeatureRepo(db);
}
async listWidgets(ctx: RequestContext) {
await this.deps.authz.requirePermission(ctx, "my-feature.widget.read");
return this.repo.list(ctx.tenantId!);
}
async createWidget(ctx: RequestContext, input: { name: string }) {
await this.deps.authz.requirePermission(ctx, "my-feature.widget.create");
const id = IdGenerator.generate();
const widget = await this.repo.create({ id, tenantId: ctx.tenantId!, name: input.name });
await this.deps.audit.write({
tenantId: ctx.tenantId!,
actor: ctx.actor,
action: "my-feature.widget.created",
resourceType: "widget",
resourceId: id,
});
await this.deps.outbox.append(
outboxRecordFromEvent(
makeIntegrationEvent({
type: "my-feature.widget.created",
version: 1,
tenantId: ctx.tenantId!,
actor: { kind: ctx.actor.kind, id: ctx.actor.userId! },
correlationId: ctx.correlationId,
payload: { widgetId: id, name: input.name },
}),
),
);
return widget;
}
}
`
Key rules:
- Always call authz.requirePermission
before reading or mutating
- Always write an audit entry on every mutation
- Emit domain events via the outbox (not a direct publish call) so events are durable
- Never throw raw errors — use errors.forbidden()
,errors.notFound(), etc. from@vercia/kernel
5. Wire into the API
`typescript
// apps/api/src/my-feature.controller.ts
import { Controller, Get, Post, Body, Req, UseGuards } from "@nestjs/common";
import { AuthGuard } from "./auth.guard.js";
import { TenantContextMiddleware } from "./tenant-context.js";
import type { AuthenticatedRequest } from "./auth.guard.js";
import { MyFeatureService } from "@vercia/module-my-feature";
import { errors } from "@vercia/kernel";
import { withTenantTransaction } from "@vercia/database";
import { z } from "zod";
const createWidgetSchema = z.object({ name: z.string().min(1).max(200) });
@Controller("v1/my-feature")
@UseGuards(AuthGuard)
export class MyFeatureController {
constructor(private readonly service: MyFeatureService) {}
private ctx(req: AuthenticatedRequest) {
const ctx = req.authContext;
if (!ctx) throw errors.unauthenticated();
return ctx;
}
@Get("widgets")
async list(@Req() req: AuthenticatedRequest) {
return withTenantTransaction(this.ctx(req), (ctx) => this.service.listWidgets(ctx));
}
@Post("widgets")
async create(@Body() body: unknown, @Req() req: AuthenticatedRequest) {
const input = createWidgetSchema.parse(body);
return withTenantTransaction(this.ctx(req), (ctx) => this.service.createWidget(ctx, input));
}
}
`
Register the controller in apps/api/src/platform.module.ts:
`typescript
// In the controllers array:
MyFeatureController,
// In the providers array:
{
provide: "MY_FEATURE_SERVICE",
useFactory: (db, authz, audit, outbox) =>
new MyFeatureService(db, { authz, audit, outbox }),
inject: [PLATFORM_TOKENS.db, PLATFORM_TOKENS.authz, PLATFORM_TOKENS.audit, PLATFORM_TOKENS.outbox],
},
`
6. Add to a recipe
`typescript
// recipes/saas/src/index.ts (or your own recipe)
import { myFeatureManifest, myFeatureMigrations } from "@vercia/module-my-feature";
modules: [
// ... existing modules ...
{ id: "my-feature", manifest: myFeatureManifest, migrations: myFeatureMigrations, required: false },
],
`
The API will run myFeatureMigrations at next boot.
7. Write tests
`typescript
// tests/service.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTestDatabase, TestDatabase } from "@vercia/testing";
import { MyFeatureService } from "../src/my-feature-service.js";
import { makeTestContext } from "@vercia/testing";
import { MemoryAuditLog, MemoryOutboxStore } from "@vercia/kernel";
import { FakeAuthorization } from "@vercia/testing";
let db: TestDatabase;
beforeEach(async () => { db = await createTestDatabase(myFeatureMigrations); });
afterEach(async () => { await db.cleanup(); });
it("creates a widget and emits an event", async () => {
const outbox = new MemoryOutboxStore();
const svc = new MyFeatureService(db.handle, {
authz: new FakeAuthorization(),
audit: new MemoryAuditLog(),
outbox,
});
const ctx = makeTestContext({ tenantId: "tenant-1", userId: "user-1" });
const widget = await svc.createWidget(ctx, { name: "Hello" });
expect(widget.name).toBe("Hello");
expect(outbox.pending()).toHaveLength(1);
expect(outbox.pending()[0]!.type).toBe("my-feature.widget.created");
});
`
`typescript
// tests/isolation.test.ts — verify RLS
it("cannot read another tenant's widgets", async () => {
const svc = new MyFeatureService(db.handle, { /* ... */ });
const ctx1 = makeTestContext({ tenantId: "a", userId: "u1" });
const ctx2 = makeTestContext({ tenantId: "b", userId: "u2" });
await svc.createWidget(ctx1, { name: "Secret" });
const visible = await svc.listWidgets(ctx2);
expect(visible).toHaveLength(0);
});
`
Run with:
`bash
pnpm --filter @vercia/module-my-feature test
`
8. React to events from other modules
In apps/worker/src/main.ts, add a handler:
`typescript
handlers.set("identity.user.created", async (event) => {
const { payload } = event as { payload: { userId: string; email: string } };
// create a default widget for every new user
// (use a platform-level db connection with BYPASSRLS for cross-tenant writes)
});
`
Checklist before shipping a module
- [ ] Every table has ENABLE ROW LEVEL SECURITY
+ policy ontenant_id
- [ ] Every public method calls authz.requirePermission
- [ ] Every mutation writes an audit entry
- [ ] Mutations that produce domain events append to the outbox (not direct publish)
- [ ] Module exports manifest
andmigrationsfromsrc/index.ts
- [ ] Manifest lists all permissions, events, entitlements, health checks
- [ ] Service tests cover the happy path and an RLS isolation test
- [ ] pnpm typecheck
green
- [ ] pnpm --filter @vercia/module-<id> test
green
- [ ] Added to a recipe's modules` array