Skip to content
The Table Plan
← All posts

Shipping Your First Module

A step-by-step walkthrough of adding a feature module to the starter template — from schema to service to route.

The starter template separates features from infrastructure. Your product logic lives in modules/, the shared plumbing lives in packages/, and a thin Next.js app in apps/ wires them together. This post walks through adding your first module end to end.

What is a Module?

A module is one cohesive product feature — for example, a billing module or a notifications module. It exports a defineModule() manifest with:

The key rule: a module never imports another module. Cross-module communication always goes through an injected event bus interface. This single constraint is what makes "monolith now, microservices later" a boot-time choice rather than a rewrite.

Step 1: Scaffold the Module

Run the generator from the repo root:

pnpm gen module notifications

This creates modules/notifications/ with a defineModule.ts, a services/ directory, and a stub schema file. The generator wires the module into the shared tsconfig so TypeScript picks it up immediately.

What the Generator Creates

Step 2: Write Your Schema

Open modules/notifications/db/schema.ts and define your tables:

import { pgSchema, uuid, text, timestamp } from 'drizzle-orm/pg-core';

export const notificationsSchema = pgSchema('notifications');

export const notifications = notificationsSchema.table('notifications', {
  id: uuid('id').primaryKey().defaultRandom(),
  orgId: uuid('org_id').notNull(),
  message: text('message').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

Notice the schema-per-module convention: every table lives in its own Postgres schema (not just a prefix). This is enforced by validate:data-boundary — if you try to JOIN across module schemas, CI fails.

Step 3: Implement a Service

Services are plain TypeScript functions that accept injected ports (db, logger, cache) and return typed results. No framework coupling — the same service runs in the monolith, the CLI, and a standalone microservice:

export type NotificationsServices = {
  listByOrg(orgId: string): Promise<Notification[]>;
  send(input: SendInput): Promise<void>;
};

Step 4: Wire it at the Composition Root

Open apps/web/app/server/modules.ts and add your module to the list passed to createApp. The root injects the real adapters (Postgres pool, Redis cache, etc.) that your module accepts as ports. Swap adapters in tests to use in-memory fakes — the module code is untouched.

That is the full loop: schema → service → composition root → routes. The rest of the template — auth, multi-tenancy RLS, typed errors, the design system — works for free because your module composes with the shared infrastructure rather than reimplementing it.

What Comes Next?

Once your module is working, look at:

Happy building.