Skip to content

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

bash
pnpm create @modularityjs notes-api --preset=fullstack --database=typeorm-sqlite --yes
cd notes-api

The 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:

bash
pnpm dev
# → curl http://localhost:3000/hello/world

Everything 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

bash
pnpm modularity generate domain note

This 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 through

The 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:

bash
pnpm modularity database:migration:generate AddNotes
pnpm modularity database:migration:run

Both 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:

bash
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/notes

A 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:

typescript
// 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);
  });
});
bash
pnpm test

Note 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

bash
pnpm modularity verify

One command, one answer, in order of increasing cost:

  1. environment — the doctor checks (Node version, pnpm pin, tsx, entrypoints, .env drift, registry mapping).
  2. migrations — fails while any stub migration is still present.
  3. typechecktsc --noEmit.
  4. boot — a MODULARITY_BOOT_CHECK=1 boot: the full DI graph is wired and validated, then the process exits before any lifecycle hook, so no database or backend is needed.
  5. testsvitest 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, …)
  • TestingcreateTestHarness, overrides, and the capture assertions
  • Build & Deploy — the scaffolded Dockerfile, migrations in CI, graceful shutdown
  • Runnergenerate integration, generate cli-command, add, doctor