jonathanjuliani
Wrong Node.js architecture: 4 anti-patterns I see in every codebase
Wrong Node.js architecture has a name: Controller Severino, God Service, Hexagonal Theater, and NASA. Real code and when each pattern breaks in production.
Audio in Brazilian Portuguese · YouTube offers automatic captions and translation
You open a Node project after three months away. You need to change one business rule in a route. Four hours later, you still have not found where that rule lives.
You are not slow. The project is wrong.
That kind of mess has a name — four names, actually. Four wrong Node.js architecture patterns I see in almost every codebase I touch. You are probably running one of them right now, or you have before. This post walks through all four with real code, why each one breaks in production, and three principles I use to decide when to split logic — without a textbook recipe or premature abstraction.
What this post argues:
- Why bad architecture only hurts after the project grows
- The gap between what tutorials teach and what a real checkout needs
- Four named anti-patterns: Severino, God Service, Hexagonal Theater, and NASA
- Three practical principles to fix the habit today
- Where each anti-pattern still makes sense — opinion without nuance becomes dogma
When wrong Node.js architecture starts to hurt
Bad architecture is fine while the project is small. Solo dev, MVP, three routes — it works.
It hurts when:
- The team grows and more people touch the code
- The codebase passes roughly twenty files
- You come back after weeks away
- Production breaks and you have five minutes to find the bug
That is when "it works, leave it" turns into an incident at 3 a.m. — and you cannot isolate where payment logic lives.
Tutorials vs production: where the fat controller comes from
Most of these patterns are not laziness. They come from one gap: tutorials teach endpoints. They do not teach where logic belongs.
Pick any popular Node tutorial — Express, Nest, Fastify. It shows how to boot the server, add a route, connect a database, return JSON. Then it ends. You can ship a CRUD. It works.
Then you need a real checkout — validate the cart, check stock, charge Stripe, create the order, send email, update the ERP. Seven concerns, not one.
So you do the only thing you were taught: put everything in the controller. That is anti-pattern number one.
Controller Severino: one endpoint, seven jobs
Severino does everything himself. No delegation. Bakery, laundry, and bank in one afternoon. That is your controller.
@Post('/checkout')
async checkout(@Req() req) {
const body = req.body
if (!body.card) {
throw new BadRequestException()
}
const customer = await this.prisma.customer.findUnique(...)
const stock = await this.inventory.check(...)
const payment = await this.stripe.charge(...)
await this.prisma.order.create(...)
await this.mail.send(...)
return { success: true }
}One function, seven responsibilities:
- Input validation
- Database lookup
- Stock check
- Stripe charge
- Order creation
- HTTP response
How do you test this? You do not. You hope.
Where does "only charge if stock is available" live? In the middle — mixed with HTTP errors, payment integration, and mail.
But what about a simple login endpoint? Fair point — that pattern is fine there. The problem is not Severino code. It is letting every controller become Severino. Move logic out when you have more than two business rules in the handler. Before that, splitting is over-engineering. After that, it is debt.
If you think moving everything to a service fixes it — wait. The next one is worse.
God Service: five dependencies in createUser
The God Service starts with good intent. You read that controllers should only orchestrate and logic belongs in services. Fine. Then you move everything into the same service.
@Injectable()
export class UserService {
constructor(
private prisma: PrismaService,
private mailService: MailService,
private stripeService: StripeService,
private analyticsService: AnalyticsService,
private cacheService: CacheService,
) {}
async createUser(dto: CreateUserDto) {
if (!dto.email.includes('@')) {
throw new Error('Invalid email');
}
const user = await this.prisma.user.create({ data: dto });
await this.stripeService.createCustomer(user);
await this.mailService.sendWelcomeEmail(user.email);
await this.analyticsService.track('USER_CREATED');
await this.cacheService.invalidate('users');
return user;
}
}Five constructor dependencies. createUser does six things: validation, database, Stripe, email, analytics, cache.
This is not a UserService. It is a CreateUserOrchestrationService wearing the wrong name — so anything "user-related" lands in the same file. Three months later: a thousand lines, sixteen dependencies, no owner.
When Stripe is slow, what happens to signup?
It blocks. Users wait eight seconds because Stripe is in maintenance — because customer creation runs inside user creation. Separate concerns became one synchronous chain.
You might have heard that one service per entity is the right model: UserService, OrderService, ProductService. That is a model, not the model. For flows that orchestrate external systems — mail, payments, analytics — entity services are the fastest path to a God Service. My rule: more than four dependencies means orchestrator, not domain service.
The first two patterns are about where logic lives. The next two are worse: lying about where it lives.
Hexagonal Theater: a domain/ folder that imports Prisma
This one is the dev who read the book over the weekend and came back Monday inspired.
The folder structure looks great:
src/
domain/
application/
infra/You open the repo, think "serious project," show it in an interview.
Then you open domain/user.ts:
import { PrismaClient } from '@prisma/client';
import axios from 'axios';
export class User {
// ...
}In theory, domain should not know about databases, HTTP, or external clients — pure business rules. You should swap the database or framework and keep domain stable.
Then Prisma is line one of the domain file. Axios too, just in case.
Is hexagonal architecture good? Yes — when you actually use it. The problem is not hexagonal. It is hexagonal cosplay. Organized folders are not architecture.
Simple symptom: if domain/ imports a framework, ORM, or HTTP client, you have theater — a closed storefront with a pretty facade.
I would rather ship an honest flat src/ monolith than fake hexagonal. The honest monolith does not promise decoupling. Theater promises it and delivers the worst coupling: hidden, because you think you are safe.
NASA architecture: seven interfaces for an MVP
You open an MVP. A POC that should have shipped two months ago.
my-test-project/
src/
domain/
application/
commands/
queries/
core/
shared/
common/
foundation/
abstractions/
contracts/
platform/
infrastructure/
database/
postgres/
IUserRepository.ts
IUserRepositoryAdapter.ts
IUserRepositoryFactory.ts
IUserRepositoryProvider.ts
IUserRepositoryManager.ts
IUserRepositoryRegistry.ts
IUserRepositorySingleton.tsSeven interfaces for a user repository in an MVP with three endpoints.
Thirty minutes to find where users are saved. Inside IUserRepository? Three methods: find, create, update. All the complexity is in abstractions that protect nothing.
"But is it not ready to scale?" Scale to what. You have zero users. You are not ready to scale — you are ready to run out of runway because you spent months on abstraction instead of validating the product.
NASA architecture is what happens when a well-meaning senior builds for a thousand-person company in a two-person project. Every abstraction you add before you need it is debt, not investment. Reality never matches the shape you predicted.
I have worked in NASA projects. You navigate more than you build. The team slows. The product slows. A competitor on an ugly monolith passes you.
Three principles: when to split without over-engineering
There is no one right architecture for every project. There is a right fit for each stage. Three principles I use:
1. Move when it hurts, not when it looks impressive
Logic can start in the controller. That is fine. Move to a service when the controller has more than two business rules. Split the service when it has more than four dependencies. Add a domain layer only when you have rules worth testing in isolation.
Hurts? Move. Does not hurt? Leave it. Code review exists to catch when it starts hurting.
2. Honesty beats structure
I prefer a flat src/ with twenty files over theater folders. I prefer an honest fat controller over a God Service pretending to be three services. Structure should describe what the code does.
A CRUD with a few rules is a CRUD. It does not need to be called Clean Architecture.
3. Abstraction is an answer, not a question
You do not add an interface "in case we change databases someday." You add it when you changed. You do not add a repository "to decouple." You add it when coupling hurt.
Every interface before pain is a bet. You lose most of them.
| Principle | Practical rule |
|---|---|
| Move when it hurts | >2 rules in controller → service; >4 deps in service → split |
| Honesty > structure | Name what the project is, not what you wish it were |
| Abstraction answers | Interfaces only after real pain |
The counterpoint I accept
Strong opinions need limits. Each anti-pattern has ground where it fits:
| Anti-pattern | Where it fits |
|---|---|
| Controller Severino | One or two rules per endpoint (simple login, health check) |
| God Service | Thin CRUD, few integrations, small team and tight deadline |
| Real hexagonal | Rich business rules, real isolated tests, multiple real adapters |
| "NASA" abstractions | After pain shows up — database swap, second provider, compliance |
That is not weakening the thesis — it is naming boundaries. The common mistake is not picking the wrong diagram from a catalog. It is applying enterprise-scale structure at the wrong time — or pretending you already did.
In practice, almost nobody has just one anti-pattern. Theater hexagonal lives with Severino. NASA lives with God Service. The useful question is not which diagram to copy, but when to care — and how much.
Next steps
If the God Service bothered you because of chained await — Stripe, email, analytics in one flow — review how JavaScript actually handles async: Promises in JavaScript: from zero to async/await.
If the confusion is module boundaries and what each file exports, Node.js: require, exports, and module.exports explained shows where separation starts before you invent ten folders.
TL;DR
| Anti-pattern | Symptom | Warning sign | Practical fix |
|---|---|---|---|
| Controller Severino | One route does validation, DB, integration, HTTP | More than 2 business rules in the controller | Extract a service per flow |
| God Service | UserService owns Stripe, mail, analytics, cache | More than 4 constructor dependencies | Explicit orchestrator or queue/event |
| Hexagonal Theater | Pretty folders, domain/ imports Prisma/axios | Framework imports inside domain/ | Honest monolith or real hexagonal |
| NASA | 7 interfaces for 3 repository methods | MVP with big-company abstraction | Abstract only after real pain |
Which of the four shows up in your codebase right now — Severino, God Service, Hexagonal Theater, or NASA? Drop it in the comments. If you have a fifth anti-pattern that belongs on this list, or something here is off, say so — I want the correction.
For the technical detail that does not fit a single post, the newsletter runs alongside the channel — one insight per week in your inbox.
Subscribe to the Engineering Ledger
Get architecture and performance notes in your inbox. Same list as the timed prompt—subscribe here anytime.
Related articles

Implementing Graphs with NodeJS and Javascript
Graphs in JavaScript with adjacency lists: modeling, neighbors, and a solid setup for BFS and DFS. Working Node.js code.
Read story
Implementing Trees with NodeJS and Javascript
Trees in JavaScript: nodes, traversals, and the foundation for BSTs and heaps. Clear, practical Node.js examples throughout.
Read story