Skip to content

Anti-Patterns

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

LLMs often pattern-match from NestJS, Angular, or TypeORM and produce code that compiles but is semantically wrong here. Specifically watch for:

  • @Module({ controllers: [HelloController] })no such field. Valid @Module keys are name | imports | providers | preferences | pools | contracts | lateContracts | overrides. Register a controller via providers: [HelloController] and pools: [{ pool: HttpControllersPool, key: 'hello', useClass: HelloController }], plus imports: [HttpModule]. All three are required.
  • import 'reflect-metadata'; at your app's entry point — unnecessary. The framework imports it as a side effect inside @modularityjs/di and @modularityjs/http before any of your decorated classes evaluate.
  • "reflect-metadata" in your app's dependencies or devDependencies — comes transitively through @modularityjs/di (declared as a runtime dep there). Add it yourself only if you write decorators that call Reflect.metadata / Reflect.getMetadata directly and want the ambient types — devDependency in that case, never dependency.
  • import { Container } from 'inversify'; — never. Always @modularityjs/di.
  • import '@modularityjs/di-inversify'; (side-effect form) — pass it: import { inversify } from '@modularityjs/di-inversify'; then createApp({ di: inversify, ... }).
  • if (!container.isBound(X)) { ... } — don't use as a null-safety check. Declare the dependency in your module's imports or providers. Boot-time validation will catch missing contracts.
  • ❌ Putting a controller in only providers: [...] and forgetting the HttpControllersPool entry — it binds in DI but the HTTP driver doesn't see it, route registration is empty, requests 404. (Symptom: fastify.printRoutes() is empty.)
  • @Module({ services: [...], routes: [...], middlewares: [...] }) — none of these exist. Use providers and pools per the schema above.
  • ❌ Building an AppModule that imports a driver (HttpFastifyModule) when you only need the contract — couples your app to a specific driver. Import HttpModule, list the driver in the top-level modules: [...] array.

Two more, decorator-specific:

  • ❌ Method decorators that don't fire (@Get('/') records nothing, routes empty). Symptom: getMethodMetadataList(YourController) returns []. Cause: the file is outside tsconfig.include so emitDecoratorMetadata is skipped. Fix: keep entry under src/ (or whatever your include glob matches), or extend the include.
  • ❌ Decorating a class with @Controller() but never adding it to a module's pools: [{ pool: HttpControllersPool, ... }] — same 404 symptom as above. The decorator alone is necessary but not sufficient.

Auth + DB wiring traps (each cost an LLM agent multiple manifest lookups during validation):

  • ❌ Guarding @Auth() manually with if (!identity) return { error: ... } — that's a 200 with an error body, not a 401. Use @RequireAuth() from @modularityjs/http-auth as a class- or method-level decorator; the adapter returns a proper 401 before your handler runs. For "must be authenticated AND have permission X", use @RequirePermission('X') from @modularityjs/http-authz — it implies @RequireAuth().
  • ❌ Loading HttpAuthModule and expecting Bearer auth to work — the bridge ships no default authenticators. You must either load a bridge package (HttpAuthJwtModule, HttpAuthApiKeyModule, HttpAuthOidcModule, HttpAuthSessionModule, HttpAuthLocalModule) or contribute your own HttpAuthenticator to HttpAuthenticatorsPool. Without that, request.auth is always undefined no matter what AuthResolversPool contains.
  • ❌ Adding HttpCsrfModule to modules: [...] as a "hardening" step and expecting existing routes to keep working — HttpCsrfConfig.globalProtection defaults to true, so every non-GET/HEAD/OPTIONS controller route gets CsrfGuard attached at afterLoad and starts 403-ing without a valid token (an anonymous POST with no session fails outright). This is the intended default; plan for it. @CsrfExempt() opts a single route out — use it for Bearer-token APIs and HMAC-verified webhooks, not to silence the guard. See the CSRF part of the session wiring section.
  • pools: [{ pool: DatabaseEntitiesPool, key: 'todo', useClass: Todo }]DatabaseEntitiesPool is a value pool. The driver iterates the pool's values and feeds them to TypeORM's DataSource.entities. Using useClass puts a DI factory in the slot instead of the entity class; boot succeeds, but TypeORM never sees the entity, dataSource.getRepository(Todo) returns a repository against a table TypeORM never created, and migrations are generated against an empty schema. Always useValue: Todo.

Wrong layer for the wrap — three things that all "wrap something with next()" but at different scopes:

  • ❌ Using a middleware (@ApplyMiddleware) to transform a handler's return value — middleware runs before the handler exists; it has no access to what the handler will return. Use an interceptor (@UseInterceptor) — its await next() IS the handler's return.
  • ❌ Using an interceptor to trace every DatabaseConnection.query() call — interceptors only wrap the HTTP handler, not the service calls made inside it. Use a plugin (@Plugin({ target: DatabaseConnection, method: 'query' })) — plugins wrap the actual method on the actual class, fire for HTTP and CLI and scheduled jobs.
  • ❌ Using a plugin to set an HTTP response header — plugins are at the DI layer; they see method args/return, not the HTTP request/response. Use middleware or an interceptor.

The shortcut: middleware's next() is the rest of the request pipeline; interceptor's next() is the handler; plugin's next() is the single method it targets. If your wrap needs to fire for non-HTTP code, it has to be a plugin.