Comparison
Why ModularityJS exists, and how it compares to other TypeScript backend frameworks.
This page is honest about tradeoffs. Every choice has a cost. If the constraints below don't apply to your project, a different framework may be a better fit — we say so explicitly at the end.
TL;DR
| Capability | ModularityJS | NestJS | Fastify / Express + DIY | AdonisJS |
|---|---|---|---|---|
| Global swappable contracts | First-class | Workaround[^1] | DIY | Partial (IoC) |
| Boot-time contract validation | Framework-wide | Per-module | No | No |
| Multiple impls coexisting (pools) | First-class | DIY | DIY | DIY |
| Vendor module = drop-in (no rewiring) | Yes | Rarely | No | Sometimes |
| HTTP-optional kernel (CLI/worker/HTTP) | Yes | HTTP-first[^2] | DIY | HTTP-first |
| Forward/reverse lifecycle | 5 phases | 2 hooks | None | Provider hooks |
| DI container is replaceable | Yes | No (custom IoC) | — | No |
| Opinionated batteries (ORM, CLI gen) | No | Yes | No | Yes |
[^1]: { isGlobal: true } modules + useClass/useFactory overrides, but no app-wide registry — feature modules still re-import.
[^2]: Worker / CLI is possible via standalone applications, but the framework and the @nestjs/* ecosystem assume HTTP.
We built this on NestJS first
This isn't a thought experiment. An earlier effort spent close to 300 commits across 50+ nestjs-* packages trying to layer a Magento-2-style preference / pool / plugin system on top of NestJS. The pain points below describe what we hit, in order of structural depth.
The two-line summary: NestJS's module-scoped DI and metadata-coupled introspection are excellent for application code and a sustained obstacle for framework-level features. Eventually it was faster to drop NestJS than to keep routing around it. ModularityJS keeps the parts that worked — decorators, lifecycle hooks, contract/driver discipline — and replaces the parts that didn't.
The pain points
1. Module-scoped providers vs. swappable contracts
In NestJS, providers live in modules. To consume a provider from module A in module B, A must export it and B must import A. There is no app-wide registry that says "the cache implementation is X" — there is "this module sees this implementation."
This is fine for application code. It becomes a real problem for infrastructure contracts (cache, lock, queue, storage, mailer, auth, ...), because the natural shape is "the app has one of these, and every feature uses it." The common workaround is @Global() modules — but global modules don't fix the swap problem. They globalize visibility, not overridability: replacing the cache for tests or a different environment still means touching every site that registered or imported it.
In our NestJS prototype this manifested as 40+ modules marked @Global() to make the system tractable — a symptom that the scoping semantics were running against the architectural goal. ModularityJS doesn't need a global flag because everything is global by default, with deterministic last-wins overrides at the app boundary:
// ModularityJS — last-wins preference at the app boundary
const app = await createApp({
di: inversify,
modules: [
CacheModule, // contract
CacheRedisModule.forRoot({}), // production driver
],
});
// In tests, swap by ordering modules differently:
modules: [
CacheModule,
CacheRedisModule.forRoot({}),
CacheMemoryModule, // last-wins — overrides Redis
],No re-import in feature modules. No conditional providers. No @Global() proliferation.
2. Pools vs. NestJS introspection
Many production needs are not "pick one implementation" — they are "all of these should run":
- All authentication resolvers should be tried in order (JWT, API key, session, ...)
- All health indicators should be checked
- All log transports should receive the entry
- All notification channels should fire
The instinct on NestJS is to lean on DiscoveryService and ModuleRef to enumerate contributors. We tried this. It doesn't hold up: DiscoveryService enumerates Nest-metadata-aware providers and trips on aliased bindings (useExisting), and ModuleRef.get() resolves instances without exposing how they were bound. Finding "every implementation of contract X across the module graph" becomes a parallel registry sitting next to the DI container.
The NestJS effort eventually migrated package after package away from DiscoveryService — data-exchange, notifier, websocket, logger, auth — onto a hand-rolled keyed pool system layered on top of Nest. ModularityJS makes pools a first-class module primitive: declarative contributions, deterministic ordering, type-safe injection via @InjectAll(pool). The Pool vs. Preference decision is part of the API surface, not an ad-hoc pattern.
3. Plugins / method interception
Magento-style plugins intercept calls on domain services, not HTTP requests. NestJS interceptors are request-scoped — they work for HTTP/gRPC/microservice transports but don't naturally model "wrap every instance of OrderService.submit with a before/around/after handler."
Building this on NestJS meant reaching into the discovery layer, wrapping resolved instances, and managing ordering and recursion guards by hand. The clearest signal in our history is a commit that removed the ModuleRef dependency from the plugin system because it couldn't be made reliable — the flagship feature couldn't lean on NestJS's official mechanisms. ModularityJS exposes this as @modularityjs/plugins, an AOP primitive built directly against the abstract Container, not retrofitted onto a discovery API.
4. Vendor modules without "now wire it up yourself"
In most TypeScript frameworks, installing a community package is the start of integration, not the end. @nestjs/passport is a typical example — you install, then you write a JwtStrategy, register PassportModule, write a guard, and decorate every route. The vendor ships the building blocks; you assemble the app-level wiring.
ModularityJS borrows another idea from Magento 2: modules ship with their wiring. Pool contributions, lifecycle hooks, configuration schema — all declared inside the module. Consumers add the module to modules: [...] and it is fully integrated:
// @modularityjs/auth-jwt registers itself into AuthResolversPool.
// AuthService.authenticate(req) walks the pool — no per-route wiring.
modules: [
HttpModule,
HttpFastifyModule,
AuthModule,
AuthJwtModule.forRoot({ secret: '...' }), // contributes to AuthResolversPool
HttpAuthModule, // HTTP extension that wires Auth into the request lifecycle
];This is what makes third-party modules a real ecosystem proposition rather than starter-kit templates. A vendor publishing @acme/auth-foo knows their pool contributions, configuration, and lifecycle land in any consumer app the same way.
5. Boot-time contract validation vs. runtime "Cannot resolve dependencies"
NestJS catches missing providers within a module at boot — but it does not know that "the application as a whole must have a cache driver." If no module in the app provides CACHE_MANAGER, NestJS doesn't fail at boot; it fails when a consumer tries to inject it.
ModularityJS treats contract fulfillment as a framework-level invariant. Every abstract contract declared by a loaded module must have a provider by the time createApp returns — or createApp throws synchronously with the unfulfilled contract list. If your app boots, every @Inject(Contract) resolves. "We forgot to install the driver" is a startup error, not a production incident.
This is also where the NestJS effort accumulated the most boilerplate. The validation we needed — contract ownership, preference boundaries, pool membership, plugin targets — wasn't expressible inside Nest, so it lived in a parallel setupModuleSystem() that grew to about 840 lines. The equivalent in ModularityJS is the ModuleLoader + container-wirer orchestration at roughly 500 lines, because it doesn't fight a second container model.
6. HTTP-coupled vs. HTTP-as-a-package
In NestJS the kernel is HTTP-centric. Worker / CLI deployments are supported via standalone applications, but the ecosystem and the documentation are HTTP-first.
ModularityJS's kernel knows nothing about HTTP. HTTP is the @modularityjs/http contract and one of its drivers (@modularityjs/http-fastify). The same kernel runs CLI-only, worker-only, HTTP-only, or all three composed — by listing different modules.
// Pure CLI application — no HTTP server bound.
await createApp({
di: inversify,
modules: [
CliModule.forRoot({ name: 'myapp' }),
CliCommanderModule,
DatabaseModule, DatabaseTypeormModule.forRoot({...}),
OutboxCliModule,
],
});Framework-by-framework
vs. NestJS
NestJS is the most popular DI-driven TypeScript backend framework, and it shaped a lot of expectations.
What NestJS does well: huge community, broad ecosystem, mature documentation, generators, GraphQL/REST/microservices coverage, polish. For an application-shaped codebase — controllers, services, repositories, one or two infrastructure choices that don't change — NestJS is a strong pick.
Where it stops fitting: the moment your architecture leans on swappable contracts, pluggable extension points, or third-party modules that drop in with wiring, you start building a parallel container next to NestJS's. We did. The pain points above are what that parallel container looks like.
Honest tradeoff: NestJS has years of community, training, and tooling. ModularityJS is younger and smaller. If you want a framework you can hire for, NestJS is the safer pick today.
vs. Express / Fastify + DIY DI
If you are gluing together Fastify with tsyringe or awilix, you've made every choice ModularityJS makes for you — but ad-hoc and per-codebase.
Where ModularityJS differs:
- The pool / preference / lifecycle pattern is standardised; vendor modules can target it.
- The DI provider is explicit (
di: inversify); your container is swappable. - Contract fulfillment is enforced at boot, not by code review.
Honest tradeoff: a DIY stack is the right call when your needs are narrow and stable. ModularityJS pays off as the number of swappable infrastructure choices grows — multiple environments, multiple consumers, multiple deployments.
vs. AdonisJS
AdonisJS has an IoC container and a service provider system, with strong batteries-included opinions (ORM, Auth, Mailer, Edge templates).
Where ModularityJS differs:
- AdonisJS opinionates the ORM, the templating engine, the validator. ModularityJS contracts each of those and ships multiple drivers.
- AdonisJS's container resolves by string IDs; ModularityJS uses classes-as-tokens with strict typing.
- ModularityJS enforces boot-time fulfillment; AdonisJS surfaces missing bindings at runtime.
Honest tradeoff: Adonis is a great "all in" experience — including a Laravel-style CLI generator. ModularityJS is a more neutral substrate.
vs. Magento 2 (the inspiration)
ModularityJS borrows the idea of preferences, the idea of virtual types (Named Bindings), the idea of vendor modules that self-wire, the idea of plugins as method interception, and the idea of deterministic module ordering. It does not borrow:
- XML configuration (
di.xml,module.xml) — ModularityJS uses TypeScript decorators and@Module({...})metadata. - Two-pass code generation (
bin/magento setup:di:compile) — there is no generation step. - The PHP runtime model and the weight that comes with it — ModularityJS is a small set of focused packages, not a monolith.
The lineage is conceptual, not literal. Magento 2 proved that contracts + preferences + pools + plugins work as the foundation of a third-party ecosystem. ModularityJS asks: what does that idea look like in modern TypeScript, with zero framework coupling?
What ModularityJS does NOT do
- We don't ship an ORM. We have a
databasecontract with TypeORM and Prisma drivers — pick one or write another. - We don't ship a CLI generator (no
nest generate). Use@modularityjs/createto scaffold an app, but day-to-day file generation is unopinionated. - We don't ship a UI / admin panel.
- We don't ship a microservice transport layer in the box (events / queue contracts cover the common cases).
- We don't have years of community Q&A behind us. We have docs and source — read them, ask questions, but expect to read source occasionally.
When ModularityJS is the wrong choice
- You need an opinionated, batteries-included framework with a large community and lots of tutorials → NestJS or AdonisJS.
- You're shipping a small endpoint, no swappable infrastructure, no plans to grow → Fastify with a couple of libs is less ceremony.
- You're already deep in another DI / module system and the team is productive → don't switch for switching's sake.
- You need a turnkey GraphQL / gRPC microservice toolkit out of the box → NestJS's ecosystem is years ahead here.
ModularityJS pays off when you have multiple infrastructure contracts to swap, multiple deployment topologies sharing a codebase, or a vendor ecosystem you want to enable.