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:
- Services — the business logic, called directly in the monolith or over the bus in a microservice.
- Routes — optional HTTP handlers the composition root mounts.
- Subscriptions — optional event handlers the bus calls.
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
modules/notifications/src/defineModule.ts— the manifestmodules/notifications/src/services/notifications.services.ts— business logic stubmodules/notifications/db/schema.ts— Drizzle schema (empty to start)
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:
- Events: publish a domain event so other modules (or a webhook handler) can react without coupling.
- Jobs: schedule background work through the job-queue port instead of ad-hoc
setTimeoutcalls. - Tests: write unit tests for the services, an integration test against a real Postgres container, and an e2e test that exercises the full API route.
Happy building.