Back
Node.jsTypeScriptArchitectureBackendArticle · Jun 1, 2026 · 14 min

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.

code
@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:

  1. Input validation
  2. Database lookup
  3. Stock check
  4. Stripe charge
  5. Order creation
  6. Email
  7. 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.

code
@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:

code
src/
  domain/
  application/
  infra/

You open the repo, think "serious project," show it in an interview.

Then you open domain/user.ts:

code
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.

code
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.ts

Seven 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.

PrinciplePractical rule
Move when it hurts>2 rules in controller → service; >4 deps in service → split
Honesty > structureName what the project is, not what you wish it were
Abstraction answersInterfaces only after real pain

The counterpoint I accept

Strong opinions need limits. Each anti-pattern has ground where it fits:

Anti-patternWhere it fits
Controller SeverinoOne or two rules per endpoint (simple login, health check)
God ServiceThin CRUD, few integrations, small team and tight deadline
Real hexagonalRich business rules, real isolated tests, multiple real adapters
"NASA" abstractionsAfter 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-patternSymptomWarning signPractical fix
Controller SeverinoOne route does validation, DB, integration, HTTPMore than 2 business rules in the controllerExtract a service per flow
God ServiceUserService owns Stripe, mail, analytics, cacheMore than 4 constructor dependenciesExplicit orchestrator or queue/event
Hexagonal TheaterPretty folders, domain/ imports Prisma/axiosFramework imports inside domain/Honest monolith or real hexagonal
NASA7 interfaces for 3 repository methodsMVP with big-company abstractionAbstract 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.

No spam. No third-party APIs. Just me sending updates.

The Engineering Ledger

Bi-weekly transmissions on architecture, performance, and practical engineering. Subscribe from any article—no spam.