Minimal App
Recipes are working wiring examples with the sharp edges annotated. This page and the
minimal-apprecipe 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:
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
@Modulehas nocontrollersfield. Valid keys arename | imports | providers | preferences | pools | contracts | lateContracts | overrides. Register a controller via three coordinated pieces: the@Controller()decorator (marks the class), aprovidersentry (binds it in DI), and anHttpControllersPoolcontribution (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 aStateExceptionnaming 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.
AppModulelistsimports: [HttpModule]because it contributes toHttpControllersPool(owned byHttpModule). - Services are registered the same way as controllers, minus the pool entry.
GreetingServiceappears inproviders: [...]and is then injectable via@Inject(GreetingService)in any constructor. - The DI driver is passed to
createApp, never side-effect-imported.inversifycomes from@modularityjs/di-inversify; module code only uses@modularityjs/didecorators. ModularityModulemust be in the modules array.createAppdoes not add it automatically.- JSON request bodies are parsed automatically. No extra package needed. For
application/x-www-form-urlencodedadd@modularityjs/http-fastify-formbody; formultipart/form-dataadd@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; callingresponse.send(...)orresponse.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.
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 yourpackage.jsonnext 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
{
"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), addLoggerModuleandLoggerConsoleModuleto the array. InjectLoggerServiceanywhere. 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, addConfigModuleandConfigEnvModule. Environment overrides likeMODULARITYJS__APP__PORTthen flow through — but only for paths declared in a config schema (every entry declaresrequiredor adefault); an env var targeting an undeclared path fails boot instead of being silently type-guessed.