Tutorial: From Scaffold to Verified CRUD API
This walkthrough builds a small notes API end-to-end with the framework's own tooling at every step: scaffold an app with create, generate a layered domain with the runner, wire the database with generated migrations, test the controller through the real HTTP pipeline with createHttpTestClient, and prove the whole assembly with pnpm modularity verify.
Every step is a command the tooling owns — you hand-write almost nothing except the assertions.
1. Scaffold the app
pnpm create @modularityjs notes-api --preset=fullstack --database=typeorm-sqlite --yes
cd notes-apiThe fullstack preset is Fastify + Zod validation + console logger + TypeORM + JWT auth + the CLI feature (migration commands) + health checks. We override the database to SQLite so the tutorial needs nothing external; keep the preset's default typeorm-postgres when you have Docker around — the scaffolder then also emits a docker-compose.yml and pnpm services:up starts a matching Postgres.
The scaffold compiles and runs immediately:
pnpm dev
# → curl http://localhost:3000/hello/worldEverything the app wires lives in one place — src/bootstrap.ts exports bootstrap() returning createApp({ di: inversify, modules: [...] }). The entrypoints (src/server.ts, src/cli.ts) just import it and call the right app.start(...).
2. Generate a domain
pnpm modularity generate domain noteThis scaffolds the layered domain blueprint under src/modules/note/ — the executable form of the Project Structure recipe — and wires NoteModule into your bootstrap automatically:
src/modules/note/
├── note-types.ts — shared domain types (status unions, ids)
├── note.config.ts — config class; the one sanctioned place for env access
├── note.entity.ts — TypeORM entity (id, name, createdAt, updatedAt)
├── note-input.ts — CreateNoteInput shape
├── note-schemas.ts — createNoteSchema (Zod via zodSchema)
├── note.service.ts — owns persistence; injects DataSource
├── notes.controller.ts — GET/POST /api/v1/notes with @ValidatedBody
├── note.module.ts — providers + HttpControllersPool + DatabaseEntitiesPool
├── __tests__/note.service.spec.ts — service spec with a DataSource double
├── internal/ — the sanctioned home for helpers
└── index.ts — the barrel other modules import throughThe generator detected your plan's capabilities (database, HTTP, validation) and emitted the matching slices — a plan without a database gets an in-memory service instead of an entity.
3. Wire the database
The entity exists; the table doesn't. Never hand-write migrations — generate them from the entity diff:
pnpm modularity database:migration:generate AddNotes
pnpm modularity database:migration:runBoth are CLI passthroughs to your app's own CLI entrypoint (the cli feature wired database-cli's commands into CliCommandsPool). The generator stamps the migration with a provenance checksum — the generated-migration-integrity lint rule rejects hand-edits, so the migration always matches what the ORM expects.
Start the app and exercise the API:
pnpm dev
curl -X POST http://localhost:3000/api/v1/notes \
-H 'content-type: application/json' \
-d '{"name": "ship the tutorial"}'
curl http://localhost:3000/api/v1/notesA blank name comes back as a 422 — @ValidatedBody(createNoteSchema) runs the Zod schema before your handler does.
4. Test the controller through the real pipeline
The generated note.service.spec.ts already unit-tests the service against a DataSource double. Add a controller-level spec that boots the real HTTP driver and dispatches through the full pipeline — routing, validation, exception filters — without binding a port, against a throwaway in-memory SQLite:
// src/modules/note/__tests__/notes.controller.spec.ts
import { DatabaseModule } from '@modularityjs/database';
import { DatabaseTypeormModule } from '@modularityjs/database-typeorm';
import { inversify } from '@modularityjs/di-inversify';
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { HttpValidationModule } from '@modularityjs/http-validation';
import { createApp, ModularityModule } from '@modularityjs/modularity';
import { createHttpTestClient } from '@modularityjs/testing';
import { ValidationModule } from '@modularityjs/validation';
import { ValidationZodModule } from '@modularityjs/validation-zod';
import { afterAll, describe, expect, it } from 'vitest';
import { NoteModule } from '../index.js';
// port: 0 + no app.start() — inject() dispatches through the full HTTP
// pipeline without ever binding a port. The database is an in-memory
// SQLite synchronized from the entity, so no migrations and no backend.
const appPromise = createApp({
di: inversify,
modules: [
ModularityModule,
HttpModule.forRoot({ port: 0 }),
HttpFastifyModule,
ValidationModule,
ValidationZodModule,
HttpValidationModule,
DatabaseModule,
DatabaseTypeormModule.forRoot({
type: 'sqlite',
database: ':memory:',
synchronize: true,
}),
NoteModule,
],
signals: false,
});
afterAll(async () => {
await (await appPromise).shutdown();
});
describe('NotesController', () => {
it('starts empty', async () => {
const client = createHttpTestClient(await appPromise);
const response = await client.get('/api/v1/notes');
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([]);
});
it('creates a note and lists it back', async () => {
const client = createHttpTestClient(await appPromise);
const created = await client.post('/api/v1/notes', { name: 'first' });
expect(created.statusCode).toBe(200);
const list = await client.get('/api/v1/notes');
expect(list.json()).toHaveLength(1);
});
it('rejects an invalid body with 422', async () => {
const client = createHttpTestClient(await appPromise);
const response = await client.post('/api/v1/notes', { name: '' });
expect(response.statusCode).toBe(422);
});
});pnpm testNote what the spec boots: only the modules this controller needs, not the whole bootstrap(). That keeps the test fast, backend-free, and pinned to the module under test — the same pattern the scaffolded hello.controller.spec.ts seeds. synchronize: true is a test-only convenience; real schema changes go through generated migrations (step 3).
5. Verify the assembly
pnpm modularity verifyOne command, one answer, in order of increasing cost:
- environment — the
doctorchecks (Node version, pnpm pin, tsx, entrypoints,.envdrift, registry mapping). - migrations — fails while any stub migration is still present.
- typecheck —
tsc --noEmit. - boot — a
MODULARITY_BOOT_CHECK=1boot: the full DI graph is wired and validated, then the process exits before any lifecycle hook, so no database or backend is needed. - tests —
vitest run.
--json emits a machine-readable report; the scaffolded CI workflow runs pnpm exec modularity verify on every push, so a forgotten driver module or a renamed config key fails the pipeline instead of the first deploy.
Where to go next
- Recipes — per-capability wiring references (auth, cache, transactions, uploads, …)
- Testing —
createTestHarness, overrides, and the capture assertions - Build & Deploy — the scaffolded Dockerfile, migrations in CI, graceful shutdown
- Runner —
generate integration,generate cli-command,add,doctor