The Architecture Mental Model for Node.js Services
Architecture, stripped of diagrams and framework names, is just the set of decisions in a system that are expensive to reverse. Where module boundaries sit, which direction dependencies point, whether two features share a database table - these outlast any individual function or route, and getting them wrong doesn't show up as a bug, it shows up months later as "why does every change to this codebase take three times longer than it should."
Architecture is the durable structure of a system - its boundaries and dependency directions - evaluated by one practical question: how expensive is it to change one part without breaking, or even understanding, the rest.
Insight: Bad architecture doesn't fail loudly like a bug does - it compounds silently as slower delivery, until a team notices every feature now touches five unrelated files.
When to Use: Choosing where a module boundary should go, deciding whether a dependency belongs in your domain code or only your infrastructure, evaluating whether a proposed split (service, layer, module) is solving a real problem, or writing an ADR that has to survive the person who wrote it leaving.
Limitations/Trade-offs: Every architectural choice that reduces coupling adds indirection somewhere else - interfaces, ports, extra hops - and that indirection has a real cost in code you have to read to understand what actually happens.
Related Topics: modular monoliths, hexagonal (ports and adapters) architecture, microservice boundaries, architecture decision records.
Two properties determine almost everything about whether a codebase stays easy to change: coupling and cohesion. Coupling is how much one part of a system knows about, or depends on, another part's internals - two modules are tightly coupled if changing one routinely forces a change in the other, even when the underlying business capability didn't change. Cohesion is how much the things inside one part actually belong together - a module is highly cohesive if everything in it exists to serve one clear responsibility, and low cohesion if it's a grab-bag of unrelated logic that happened to end up in the same file.
The goal architecture is chasing is simple to state and hard to achieve: high cohesion within a boundary, low coupling across boundaries. A boundary is any line you draw and enforce - a folder, a module's public API, a network call between services - past which the rest of the system is only allowed to interact through an explicit, narrow surface rather than reaching into internals directly.
A useful analogy: think of a well-architected system as a building with load-bearing walls in the right places. You can renovate a kitchen without the bedroom falling down, because the wall between them is a real boundary, not a suggestion. A poorly architected system is the equivalent of every room's furniture also functioning as structural support for the room next door - a single change anywhere risks a collapse everywhere.
// Tight coupling: the domain function reaches directly into infrastructureimport { pool } from "../db/pool"; // domain now depends on Postgres existingexport function createOrder(input: OrderInput) { return pool.query("INSERT INTO orders ...", [input]); // can't test without a real DB}
Dependency direction is where this stops being an abstract preference and starts being a concrete, checkable rule. Every import statement is a dependency arrow, and which way those arrows point determines which parts of a system can change independently of which others. If your domain logic imports Express types or a Prisma client directly, then domain logic now depends on those choices - swapping frameworks or ORMs means touching business rules, not just infrastructure code.
Dependency inversion is the specific technique that breaks this: instead of the domain depending on a concrete infrastructure implementation, the domain defines an interface (a port) that describes what it needs, and infrastructure code (an adapter) implements that interface to satisfy it. The dependency arrow now points toward the domain, not away from it - infrastructure knows about the domain's port, but the domain knows nothing about Postgres, Express, or Redis. Hexagonal Architecture in Node is this exact pattern, systematized with a concrete folder layout.
// The domain defines what it needs - a port, not a database clientexport interface OrderRepository { save(order: Order): Promise<void>;}// createOrder depends on the port, never on Postgres, Prisma, or pg directlyexport function createOrder(input: OrderInput, repo: OrderRepository) { const order = { id: crypto.randomUUID(), ...input }; return repo.save(order); // infrastructure fulfills this contract later}
This is why "swap Express for Fastify" or "test without a real database" becomes cheap in a well-bounded system and expensive in a tightly coupled one: the domain code in the inverted version never mentioned the concrete technology in the first place, so replacing it touches only the adapter, never the logic that actually encodes business rules.
Boundaries also interact with team structure in a way that's easy to underweight. A module's boundary isn't just a technical seam - it's usually also an ownership seam. When a module's public API is narrow and enforced (via index.ts exports, ESLint import restrictions, or an actual network boundary), one team can change that module's internals without coordinating with every other team touching the codebase. Weak boundaries force coordination even when the underlying business capabilities don't actually depend on each other - which is often the real, felt cost of "this codebase is hard to work in as we've grown."
The architectural styles covered elsewhere in this section - layered monolith, modular monolith, hexagonal, microservices - aren't really different philosophies competing for the "right" answer. They're the same coupling/cohesion goal pursued with different amounts of enforcement and different operational costs, and picking between them is a trade-off, not a maturity ladder everyone should climb.
A layered monolith (global controllers/, services/, models/ folders) has boundaries by convention only - nothing stops a controller from reaching into another feature's model directly, so coupling creeps in as the codebase grows unless discipline holds. A modular monolith keeps one deployable but enforces boundaries between feature modules, typically via linting rules that block deep imports - it buys most of the coupling benefits of service boundaries without paying for a network. Hexagonal architecture adds a second axis of boundary - between domain logic and any infrastructure at all, regardless of module - which is why it composes naturally inside a modular monolith's modules rather than replacing the idea. Microservices turn the module boundary into a genuine network boundary, enforced by the OS and the wire protocol rather than a linter, which is the strongest possible enforcement - and also the most expensive, trading in-process function calls for distributed failure modes, eventual consistency, and a much larger operational surface.
That progression maps directly onto cost of change, the practical lens for evaluating any of these choices: every step toward stronger boundaries reduces the cost of changing one part in isolation, and increases the cost of changing something that legitimately spans two parts, plus the fixed operational cost of the boundary mechanism itself. A team that hasn't felt real coupling pain yet is usually paying pure overhead for a boundary sub-strategy stronger than it needs; a team drowning in cross-module breakage is usually paying pure overhead by leaving boundaries at convention-only. Microservices When Worth It and ADR: Monolith vs Services both go deep on locating that inflection point for a specific team.
Because these decisions are expensive to reverse and made under real uncertainty, recording the reasoning at decision time - not just the outcome - matters more here than almost anywhere else in a codebase. An Architecture Decision Record exists specifically because "why did we choose this" is exactly the information that evaporates fastest from a team's collective memory, and re-litigating a settled trade-off without knowing the original context wastes the exact effort the original decision was supposed to save.
Style
Strength
Weakness
Best Fit
Layered monolith (convention only)
Simplest to start; zero enforcement overhead
Boundaries erode silently as the codebase grows
Small codebases, solo or single-team, early-stage products
Modular monolith (enforced boundaries)
Most coupling benefits of services, no network cost
Shared process/database still couples deploys and failure domains
Multiple squads, one product, deploy cadence not yet forcing a split
Hexagonal (ports/adapters)
Domain logic testable and framework-agnostic
Extra indirection - interfaces to read even for simple cases
Domain logic worth protecting from infrastructure churn
Microservices
Independent deploys, failure isolation, true team autonomy
Distributed transactions, network failure modes, real ops overhead
Proven scale, platform tooling in place, deploy cadence genuinely blocked
"Architecture means picking a diagram shape upfront." Architecture is an ongoing set of boundary and dependency decisions, most of which get revised as real coupling pain shows up - not a one-time diagram exercise finished before writing code.
"More layers or more services is automatically better structure." Every additional boundary has a real cost - more indirection to read, or more network surface to operate. Boundaries only pay for themselves when they're actually resolving coupling pain the team is feeling.
"Microservices are the mature version of a monolith." They're a different point on the same trade-off curve, not an upgrade - a modular monolith can be significantly better architected, in the coupling/cohesion sense, than a poorly bounded set of microservices.
"If it's split into files and folders, it has boundaries." A folder is only a boundary if something enforces it - without a lint rule, an explicit public API, or a network hop, nothing stops code from reaching across it, and it will, under deadline pressure.
"Dependency inversion is over-engineering for most Node projects." It's overkill for a throwaway script, but for domain logic worth protecting from framework or database churn, the "extra" interface is what makes testing and future migrations cheap instead of a rewrite.
What does "architecture" actually mean, concretely, for a Node.js service?
The set of structural decisions that are expensive to reverse later - where module boundaries sit, which direction dependencies point, and how much one part of the system depends on another part's internals. Everything else is ordinary code that's cheap to change.
What's the practical difference between coupling and cohesion?
Coupling measures how much one part depends on another part's internals - high coupling means changing one routinely forces changes elsewhere. Cohesion measures whether the things inside one part actually belong together. The goal is high cohesion inside a boundary and low coupling across boundaries.
What makes something a real "boundary" versus just a folder?
Enforcement. A folder split is only a boundary if something stops code from reaching across it directly - a lint rule blocking deep imports, an explicit public API (index.ts exports), or an actual network call. Without enforcement, a folder is just an organizational suggestion.
How does dependency direction actually work?
Every import is a dependency arrow. If domain logic imports a concrete infrastructure client (a database driver, a framework type), the arrow points from domain to infrastructure, coupling business rules to that specific technology. Dependency inversion flips this: the domain defines an interface it needs, and infrastructure code implements it - so the arrow points toward the domain instead.
Why does dependency inversion make testing easier?
Because the domain code never references a concrete database or HTTP framework, a test can supply an in-memory implementation of the same interface instead of standing up real infrastructure - the domain logic can't tell the difference, since it only ever depended on the interface's shape.
Is a modular monolith just "microservices without the network"?
Conceptually close - it borrows the enforced-boundary idea from microservices while keeping one deployable and one process, which avoids network failure modes and distributed transactions entirely. It gets most of the coupling benefit without most of the operational cost.
When does a stronger boundary (like a full service split) actually pay for itself?
When the cost of coupling - blocked releases, cross-team coordination overhead, one team's bug taking down an unrelated feature - measurably exceeds the fixed operational cost of the stronger boundary mechanism. Evidence of that pain, not architectural ambition, is the signal to reach for it.
Why do architecture decisions need to be written down (ADRs) more than typical code changes?
Because they're expensive to reverse and made under genuine uncertainty - the reasoning, not just the outcome, is what a future team needs to evaluate whether the original trade-off still holds. That context evaporates from memory faster than almost any other kind of decision.
Does good architecture mean no coupling anywhere?
No - zero coupling isn't achievable or even desirable; some parts of a system genuinely need to interact. The goal is making sure coupling exists within cohesive boundaries where it's cheap, and stays low across boundaries where it's expensive to untangle later.
Is it a mistake to start a new Node service with hexagonal architecture and strict module boundaries from day one?
Not necessarily wrong, but it's a real cost trade-off - strong boundaries add indirection you pay for immediately, in exchange for coupling pain you may not have felt yet. Many teams start simpler and add enforcement as the codebase and team actually grow into needing it.
How do I know if my architecture has silently gotten worse over time?
Watch for symptoms rather than diagrams: a change to one feature routinely requires touching files in unrelated features, onboarding engineers can't predict where new code should live, or two teams keep blocking each other's releases despite working on ostensibly separate features. Each is coupling showing up as delivery friction rather than a bug.