Skip to content

Minimal App

Recipes are working wiring examples with the sharp edges annotated. This page and the minimal-app recipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.

A minimum-viable HTTP server with one injectable service, a path parameter, and a JSON body:

typescript
import { Inject, Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import {
  Body,
  Controller,
  Get,
  HttpControllersPool,
  HttpModule,
  Params,
  Post,
} from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';

@Injectable()
class GreetingService {
  greet(name: string): string {
    return `Hello, ${name}!`;
  }
}

@Controller()
class HelloController {
  constructor(
    @Inject(GreetingService) private readonly greetings: GreetingService,
  ) {}

  @Get('/hello/:name')
  hello(@Params() params: { name: string }) {
    return { message: this.greetings.greet(params.name) };
  }

  @Post('/echo')
  echo(@Body() body: { value: string }) {
    return { received: body.value };
  }
}

@Module({
  name: 'app',
  imports: [HttpModule],
  providers: [GreetingService, HelloController],
  pools: [
    { pool: HttpControllersPool, key: 'hello', useClass: HelloController },
  ],
})
class AppModule {}

const app = await createApp({
  di: inversify,
  modules: [
    ModularityModule,
    HttpModule.forRoot({ port: 3000 }),
    HttpFastifyModule,
    AppModule,
  ],
});

await app.start();

Key points the recipe encodes

  • @Module has no controllers field. Valid keys are name | imports | providers | preferences | pools | contracts | lateContracts | overrides. Register a controller via three coordinated pieces: the @Controller() decorator (marks the class), a providers entry (binds it in DI), and an HttpControllersPool contribution (advertises it to the HTTP driver). All three are required — a decorated controller registered as a provider but missing from the pool fails boot with a StateException naming the class (@Controller({ mount: false }) is the explicit opt-out for provider-only classes).
  • A module using pools from another package must import that package's module. AppModule lists imports: [HttpModule] because it contributes to HttpControllersPool (owned by HttpModule).
  • Services are registered the same way as controllers, minus the pool entry. GreetingService appears in providers: [...] and is then injectable via @Inject(GreetingService) in any constructor.
  • The DI driver is passed to createApp, never side-effect-imported. inversify comes from @modularityjs/di-inversify; module code only uses @modularityjs/di decorators.
  • ModularityModule must be in the modules array. createApp does not add it automatically.
  • JSON request bodies are parsed automatically. No extra package needed. For application/x-www-form-urlencoded add @modularityjs/http-fastify-formbody; for multipart/form-data add @modularityjs/http-fastify-upload.

Common response patterns

Handlers normally return a value (object → JSON, primitive → string body, undefined → 204). For redirects, custom status codes, custom headers, or non-standard body shapes:

  • Custom status on the happy path: @StatusCode(201) above the method. Decorator applies before the handler runs; the returned value still becomes the body.
  • Custom header: @SetHeader('cache-control', 'no-store') above the method (or on the class for every route).
  • Anything else — redirects, dynamic status codes, manually-shaped error bodies — inject @Response() response: HttpResponse. Status and headers you set on the response object are merged with whatever you return; calling response.send(...) or response.redirect(...) short-circuits and uses what you sent instead. Both forms work and can be mixed freely within a handler — the adapter picks whichever you used.
typescript
import {
  Body,
  Controller,
  Get,
  Post,
  Params,
  Response,
  StatusCode,
  SetHeader,
  type HttpResponse,
} from '@modularityjs/http';

@Controller()
class ExampleController {
  private readonly items = new Map<string, { id: string; name: string }>();

  // Pure managed mode. Return value becomes the JSON body.
  @Post('/items')
  @StatusCode(201)
  @SetHeader('cache-control', 'no-store')
  create(@Body() body: { name: string }) {
    const item = { id: 'abc', name: body.name };
    this.items.set(item.id, item);
    return item;
  }

  // Imperative redirect — short-circuits via response.redirect().
  @Get('/legacy/:id')
  legacy(@Params() params: { id: string }, @Response() response: HttpResponse) {
    response.redirect(`/items/${params.id}`, 302); // 302 default; pass 301/303/308 as needed
  }

  // Mixed: set status imperatively for the 404 path, return naturally for the
  // happy path. The adapter auto-merges — return value becomes the body even
  // when @Response() is injected, as long as you haven't already called .send().
  @Get('/items/:id')
  show(@Params() params: { id: string }, @Response() response: HttpResponse) {
    const item = this.items.get(params.id);
    if (!item) {
      response.status(404).send({ error: 'not found' });
      return;
    }
    return item;
  }
}

Throwing vs. shaping manually. FrameworkExceptionFilter (from @modularityjs/http-fastify) maps framework exceptions to a fixed body: { code, message, errors? }. Throw NotFoundException, ValidationException, etc. when that shape is fine; use @Response() + response.status(...).send(...) when the caller-facing spec requires a different shape.

Required dependencies

This recipe needs the following runtime dependencies — install with pnpm add @modularityjs/di @modularityjs/di-inversify @modularityjs/http @modularityjs/http-fastify @modularityjs/modularity:

  • @modularityjs/modularity@Module, createApp, lifecycle.
  • @modularityjs/di@Injectable, @Inject, Container. List explicitly: you import from it whenever you write a service, so it should appear in your package.json next to the other framework packages you reference.
  • @modularityjs/di-inversify — the active DI driver.
  • @modularityjs/http — controller decorators + HttpControllersPool.
  • @modularityjs/http-fastify — the active HTTP driver.

fastify is a peer dep of @modularityjs/http-fastify and pnpm installs it automatically (pnpm 10 has auto-install-peers=true by default). Add it to your own package.json only if you want to pin a specific version or import from fastify directly.

And as a devDependency: pnpm add -D @types/node tsx typescript. @types/node is needed for node:* built-in imports (e.g. node:crypto.randomUUID()); tsx to run TypeScript directly via tsx watch src/server.ts.

Apps vs. packages — dependencies vs. peerDependencies. A terminal app (the thing you deploy, private: true) lists every @modularityjs/* it imports in dependencies — these are concrete versions the app resolves at install time. A library or package that exposes a @Module for other apps to consume lists framework packages in peerDependencies (with a devDependencies mirror so its own tests run), letting the consuming app pin one version of each framework package. If you're building a website, write the app and put framework deps in dependencies; you only need the peerDeps pattern when publishing a reusable package.

Required tsconfig

json
{
  "compilerOptions": {
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*"]
}

Don't import 'reflect-metadata' or add it to package.json. @modularityjs/di lists it as a runtime dependency and imports it as a side-effect from packages/di/src/metadata.ts, so the polyfill loads transitively before any decorated class evaluates. The only case for adding it yourself is if you write decorators that call Reflect.metadata / Reflect.getMetadata directly and want the ambient types — devDependency then, never dependency.

Adding logger or config

Both are optional for a minimal boot. Add them when you want them:

  • Logger: install @modularityjs/logger (contract) + @modularityjs/logger-console (driver), add LoggerModule and LoggerConsoleModule to the array. Inject LoggerService anywhere. The minimal boot is silent on success — no startup log line unless you wire in a logger driver, so the only signal the server is up is that a request to its port succeeds.
  • Config: install @modularityjs/config + @modularityjs/config-env, add ConfigModule and ConfigEnvModule. Environment overrides like MODULARITYJS__APP__PORT then flow through — but only for paths declared in a config schema (every entry declares required or a default); an env var targeting an undeclared path fails boot instead of being silently type-guessed.