The Broad Way

[ Sharp Mind · Sharp Blade · Sharp Spirit ]

root@construct:~
/nestjs-at-scale-42-modules-and-counting
$_
<-- back to /logs
2025-12-08//LOG

NestJS at Scale: 42 Modules and Counting

People keep asking me how TOPO Contabil runs 42 NestJS modules without turning into spaghetti. The honest answer is: discipline, patterns, and knowing when to break your own rules. Let me walk through how we got here and what we learned. TOPO is an enterprise accounting platform. Brazilian tax law is insanely complex, so our domain is massive. We have modules for fiscal documents, tax calculations, ledger management, financial statements, integrations with government systems (SPED, EFD, NFe), client management, multi-tenant isolation, audit trails, and about 30 more things. Each one is a full NestJS module with its own services, controllers, repositories, DTOs, and use cases. MODULE ORGANIZATION We follow Clean Architecture strictly. Each module has this structure: The domain layer has entities and value objects. The application layer has use cases, one per file, one public method per class. The infrastructure layer has repositories, external service adapters, and framework-specific code. The presentation layer has controllers and DTOs. We ended up with 252 use cases across the system. Every single one follows the same pattern: receive a command or query DTO, validate, execute business logic through the domain, persist through the repository interface, return a result. No exceptions to this pattern. The key insight: BORING IS GOOD. When a new developer joins, they can open any module and immediately know where everything is. The structure is identical everywhere. This feels like ceremony when you have 3 modules. At 42, it's survival. DEPENDENCY INJECTION AT SCALE NestJS DI is powerful but it can become a nightmare. Our rules: Modules only expose services through their public API. No reaching into another module's internals. Ever. We define explicit module interfaces using abstract classes. Module A depends on the abstraction, not Module B's concrete implementation. This means we can swap implementations, mock for testing, and most importantly, we can trace every cross-module dependency. We built a custom decorator called ModuleDependency that documents and validates cross-module relationships at startup. If Module A tries to inject something from Module B without declaring the dependency, the app crashes on boot. Fail fast, fail loud. Circular dependencies were our biggest enemy early on. The fix was architectural: we extracted shared concepts into dedicated modules. Instead of Fiscal depending on Tax and Tax depending on Fiscal, both depend on a TaxRules module that owns the shared logic. THE 16-STATE WORKFLOW MACHINE Fiscal documents in Brazil go through a complex lifecycle. Drafted, validated, signed, transmitted, authorized, rejected, cancelled, corrected, and about 8 more states I won't bore you with. Each transition has preconditions, side effects, and audit requirements. We built a state machine that's configured declaratively. You define states, transitions, guards, and effects. The engine handles execution, rollback on failure, event emission, and audit logging. It's about 800 lines of code and it's the most important piece of infrastructure in the system. Every state transition is a database transaction. Guards run before the transition and can abort it. Effects run after and are idempotent so they can be retried. The whole thing is event-sourced for audit purposes because Brazilian tax authorities can ask you to prove the exact sequence of operations that led to a document's current state. WHAT DOESN'T WORK The ceremony is real. Creating a new use case means creating 4-5 files minimum: the use case class, the input DTO, the output DTO, the controller method, and often a repository method. For simple CRUD this feels absurd. We've talked about code generation but honestly, with Claude Code, I just describe what I need and it scaffolds the whole thing in seconds. The AI is the code generator. Performance is tricky. NestJS middleware, pipes, guards, and interceptors run on every request. With 42 modules loaded, the dependency graph is large. Cold starts in serverless would be painful. We run on dedicated infrastructure so this is fine, but it's a real constraint. Testing at this scale requires strategy. We have unit tests for use cases (fast, isolated), integration tests for repositories (need database), and E2E tests for critical flows (slow, brittle). The ratio is about 70/20/10. Running all tests takes 4 minutes. Running just unit tests takes 18 seconds. WHAT I'D DO DIFFERENTLY I'd start with the state machine earlier. We bolted it on at module 15 and had to migrate a bunch of inline state logic. Pain. I'd enforce module boundaries from day one with automated checks, not just conventions. We added the ModuleDependency validator at module 25. By then we had already untangled three circular dependencies manually. I'd use more value objects. We were lazy early on and passed primitives around. A CNPJ (Brazilian tax ID) as a string is a bug waiting to happen. A CNPJ value object that validates on construction is safety. WOULD I USE NESTJS AGAIN? Yes. It's opinionated enough to keep 42 modules consistent but flexible enough to let us build custom infrastructure where we need it. The DI system is the best in the Node ecosystem. TypeScript support is first-class. The module system maps naturally to domain boundaries. It's not the fastest framework. It's not the simplest. But at this scale, maintainability beats everything else. And NestJS with Clean Architecture is the most maintainable Node.js setup I've found. 42 modules. 252 use cases. Zero regrets. Well, maybe a few. But zero regrets about the framework choice.
The Broad Way | Kinho.dev