Engineering

The Architecture Trap: Why NestJS Makes It Too Easy To Stop Thinking

How Framework Convenience Becomes Architectural Debt

10 min read Max
#NestJs #Architecture #Typescript #Software-Design

This article is part of a series


The NestJS Architecture Series

1/2

How to use NestJS as infrastructure rather than architecture. This series explores why service-heavy codebases inevitably collapse, and demonstrates the patterns that keep business logic testable, boundaries explicit, and changes predictable.

Software frameworks succeed when they remove friction. NestJS removes a lot of it. HTTP handling becomes trivial. Dependency injection is automatic. Modules, providers, controllers, pipes, interceptors—everything is neatly packaged into a system that looks opinionated, structured, even architectural.

And that is the problem.

NestJS gives you a convenient environment, but that convenience can easily be mistaken for design. Many developers assume that because the framework encourages a certain file and class structure, it must also provide an architectural model. It doesn’t. NestJS is infrastructure. It solves transport, routing, lifecycle, dependency resolution, and integration plumbing. What it does not do is tell you how to structure a domain, where business logic should live, or how boundaries should be drawn.

The effect is predictable: you get services that absorb everything. Every rule, every decision, every transformation drifts into an @Injectable() singleton simply because it’s easy. And once a service becomes a dumping ground, its neighbours start depending on it, not because of design, but because dependency injection makes reaching for it effortless. The moment a service is injected everywhere, it becomes a global namespace with a different coat of paint.

None of this is malicious. It is simply the path of least resistance. Architecture requires deliberate constraints; NestJS requires none.


The TypeScript Mindset

To understand why this is such a trap, we need to briefly address the foundation beneath all this: TypeScript.

I’ve written about this in detail in TypeScript: The Trouble With Pretending

, and the essence of it matters here: TypeScript is JavaScript with compile-time types. Nothing more. Nothing less. Classes don’t provide runtime encapsulation. Inheritance doesn’t exist at runtime. All the private/protected machinery vanishes the moment the code is compiled.

This is not a limitation. It’s the nature of the language.

Ignoring this leads to code that mimics languages that do have those constructs. That’s how you end up writing Java with worse tooling and pretending that decorators and DI produce a meaningful object system. Languages shape architecture, and architecture must follow the strengths of the language. You don’t write functional architecture in Java 1.5 just because you technically could. You don’t build class hierarchies in C even though GTK+ famously did. Those who understand the spirit of a language treat it as a guide, not an inconvenience.

TypeScript’s strength is that it adopts JavaScript’s model—objects, functions, closures, factories—and layers types on top to express intent. Good TypeScript embraces this. It avoids building imaginary class hierarchies that provide no runtime semantics. It avoids treating decorators as architectural primitives. It expresses the domain in values and compositions, not mandatory OOP.

This becomes extremely important when paired with NestJS, because NestJS—as a framework—leans so heavily into the class-decorator aesthetic that it tempts developers into thinking that this aesthetic is the architecture.

It isn’t.

Once you stop pretending TypeScript is Java, the idea that “all business logic lives in services” collapses immediately.


Where Domain Code Belongs

A service in NestJS is fundamentally simple. It is a singleton object instantiated by the DI container. That’s it. There is nothing inherently architectural about that. The only reason it appears architectural is because the framework encourages you to use services for everything—but that’s a matter of ergonomics, not design.

A healthy architecture treats services as coordination points: places where data is loaded, errors are handled, workflows are directed, and repositories are called. They are not the home of business rules. They are not the model of the domain. They are not the source of truth for invariants.

Once you acknowledge this, it becomes obvious where business logic should live: in plain TypeScript modules that have nothing to do with NestJS. These modules define the behaviour and transformations that describe your domain. They don’t know what a controller is. They don’t know what a provider is. They don’t know that TypeORM exists. They deal purely with meaning.

It is in these domain modules that TypeScript’s nature shines. Objects can represent aggregates or state holders. Functions can express rules, validations, decisions, compositions. Data can be shaped intentionally. Testing becomes trivial, dependency surfaces small, boundaries clear.

The entire point is to remove the domain from the gravitational pull of the framework.


The Repository Boundary and the Role of Mapping

One of the most damaging patterns in NestJS projects is treating TypeORM entities as domain models. TypeORM encourages this because its decorators blur the line between persistence schema and in-memory representation. But the moment you let entities leak out of repositories, your domain becomes shaped by constraints of the database rather than the needs of the system.

The more durable approach is straightforward: repositories return domain objects. Not ORM entities. Not whatever the database schema uses internally. The repository is responsible for mapping from database shapes to domain shapes. This mapping is not overhead—it is the boundary that protects the domain from infrastructure.

Once again, TypeScript’s nature helps here. Domain objects don’t have to be classes. They don’t need decorators. They don’t require inheritance chains. They can be simple values validated at creation time, ideally through Zod.

Which brings us to the question of validation.


Why Zod Works Better at the Edge

NestJS ships with class-validator and class-transformer, which create the illusion that since everything in NestJS is class-based, your validation should be too. But these tools rely on attaching decorators to classes and expecting those decorators to act as a runtime contract. It’s verbose, rigid, and fundamentally mismatched with JavaScript’s object model.

Zod, on the other hand, fits perfectly into the JavaScript/TypeScript world. It defines schemas as values, not metadata. These schemas can validate any object—API inputs, domain objects, outbound responses, or internal transformations. They don’t require NestJS. They don’t require decorators. They don’t break when you refactor.

At the edges of the system—API or database boundaries—runtime correctness matters. Zod expresses that clearly. Internally, where we control all data paths, TypeScript’s types are sufficient.

This aligns with the broader philosophy: the domain is not shaped by the framework.


Dependency Injection and Accidental Global State

NestJS’s dependency injection system is powerful, but its power is also dangerous. Injecting a service is so easy that it becomes reflexive. If one service needs something from another, you inject it. If a third needs both, you inject both. Before long, the dependency graph becomes a mesh of sideways access.

The moment every service can talk to every other service, you’ve lost control of data flow. Boundaries dissolve. Dependency direction disappears. And once dependency direction disappears, you no longer have architecture—you have global state with better syntax.

This is why discipline about what a service may and may not access is so important. It’s the same reason to separate domain logic from services entirely: once the domain lives outside NestJS, the temptation to wire services together for the sake of reusing logic decreases dramatically.


Framework Integration Points: The Exception That Proves the Rule

Not everything in NestJS is a trap. Some integrations—Bull queues, GraphQL resolvers, WebSocket gateways, scheduled tasks—are infrastructure concerns that benefit from tight framework coupling. But they’re only safe when treated as entry points, not as places where logic lives.

A Bull processor marked with @Processor('billing') and @Process('calculate-overdue') is doing the same job as a controller: receiving external input and routing it into your system. The fact that the input comes from a queue instead of HTTP doesn’t change the architectural role.

The processor should deserialize job data, log what’s happening, delegate to a service, and handle queue-level errors like retries and dead letter queues. What it should not do is contain business logic, load data and make decisions, or be injected into other parts of the system.

The same principle applies to GraphQL resolvers, cron jobs, and WebSocket handlers. They’re infrastructure boundaries. They translate external events into internal operations. The moment they start being the operations, the architecture collapses in the same way service-heavy systems do—just with different decorators.

Framework integration is not the problem. Treating framework integration points as architecture is.


Sideways Data Flow and the Shape of a Mess

The problem with service-heavy architectures becomes visible when you look at the dependency graph. In a typical NestJS project that leans on services for everything, dependencies start to look like this:

Dependency-Graph: +----------------+ | UserService | +----------------+ ^ ^ ^ | | | | | | +----------------+ | +----------------+ | BillingService |--+--| EmailService | +----------------+ +----------------+ ^ ^ ^ | | | | +----------+-------+ | | +----------------+ +----------------+ | ReportService | | AuditService | +----------------+ +----------------+

Services reach sideways into each other. Billing needs user data, so it injects UserService. Reporting needs to reuse some billing calculation, so it injects BillingService. Email needs to know about users and billing, so it injects both. Audit wants everything.

On paper each individual injection makes sense. In aggregate it creates a mesh. Data can enter from almost anywhere and exit almost anywhere else. There is no clear direction. You don’t know which service owns which piece of logic because the logic is scattered and shared via DI.

Once this kind of sideways movement appears, new behaviour is almost always implemented by finding an existing service that is “close enough” to the problem and wiring into it. Refactoring becomes dangerous because you might break three other call sites you did not know existed.


Architecture After NestJS

Architecture is not the folder layout generated by a CLI. It’s not the presence of decorators. It’s not the appearance of structure. Architecture is the set of constraints we impose on the system to preserve clarity, direction, autonomy, and intent.

NestJS can coexist with good architecture, but only if NestJS is put firmly in its place. It handles HTTP, routing, DI, modules, pipes, guards, interceptors, lifecycle management, and data access wiring. It does infrastructure well.

But the domain belongs elsewhere. It belongs in code that understands the language it’s written in. Code that doesn’t depend on decorators. Code that doesn’t vanish at runtime. Code that can be tested without a DI container. Code that expresses the problem rather than the framework.

The moment you stop treating NestJS as the architecture, a different kind of clarity emerges. Services shrink. Domain code consolidates. Boundaries become explicit. Data flow becomes comprehensible. The system becomes malleable.

Nothing dramatic changes in terms of tooling. The project still uses NestJS. The controllers still exist. The services still exist. But the meaning of the system no longer lives inside them.

And once meaning is free from the framework, the system finally begins to behave like software rather than scaffolding.


What Comes Next

This article has argued why the default NestJS patterns lead to trouble. It has described the principles: domain logic outside the framework, services as orchestrators, repositories as boundaries, Zod at the edges, and deliberate constraints on dependency direction.

But principles without code are just philosophy.

The follow-up piece—NestJS Architecture in Practice: Code Patterns That Actually Work—shows what this looks like in practice. The patterns. The file structure. The testing approach. The decisions about when to add complexity and when to stay simple.

If the ideas here resonate but feel abstract, that’s the piece to read next. It’s where theory becomes concrete, and where you can see whether these constraints actually make systems better—or just different.

The question isn’t whether NestJS is good or bad. The question is whether you’re using it as infrastructure or mistaking it for architecture. Once you see the difference, the code writes itself.