Modularity is the practice of splitting a Node.js codebase so that each part has a narrow, well-defined job and depends on as little of the rest of the system as possible. It's easy to conflate with "having multiple files," but file count says nothing about modularity - a project with fifty files can be as tangled as one with five if every file freely imports every other one.
This page is the mental model underneath the rest of the section: Modularity Basics shows the layering pattern in working code, and Use Cases & Services, Repository Pattern, and Dependency Injection Patterns go deep on specific pieces of it. Here, the goal is understanding what actually makes a Node.js backend modular: the direction its dependencies point, not how many folders it has.
Modularity is controlling which parts of a system are allowed to depend on which other parts, so that business rules don't depend on delivery mechanisms like HTTP frameworks or databases.
Insight: A codebase where domain logic imports Express directly can't be tested without a server, can't be reused from a CLI or worker, and can't swap frameworks without touching every business rule.
When to Use: Structuring a new service from the start, deciding where a piece of logic belongs, reviewing whether a change touched too many unrelated files, and planning a framework or database migration.
Limitations/Trade-offs: Strict layering adds indirection - an extra interface, an extra file - that's wasted ceremony on a five-route prototype and only pays for itself once a codebase has enough logic worth protecting.
Related Topics: dependency injection, the repository pattern, hexagonal architecture, use cases and application services.
Coupling is how much one part of a system knows about, and depends on, another part; cohesion is how tightly the responsibilities inside one part belong together.
The goal of modularity is low coupling between parts and high cohesion within each part - pieces that change for the same reason live together, and pieces that change for different reasons don't drag each other along.
A route handler and a tax-calculation rule change for entirely different reasons - one changes because an HTTP header format shifted, the other because a jurisdiction's law changed - so bundling them into one function couples two unrelated sources of change.
A simple analogy: a well-modularized backend behaves like a building with clearly labeled doors, where the electrician never needs to walk through the kitchen to reach the breaker panel.
The building's rooms (modules) each serve one purpose, and the paths between them (dependencies) are deliberate, not shortcuts cut through load-bearing walls because it happened to be convenient that day.
In a Node.js backend specifically, the most common load-bearing wall people cut through is the HTTP framework: importing Request/Response types or calling res.json() from deep inside a piece of business logic wires that logic permanently to Express or Fastify, even though the business rule itself has nothing to do with HTTP.
The domain layer holds business rules and has no framework imports at all - it doesn't know whether it's being called from an HTTP route, a message queue worker, or a test file. The application layer (often called "use cases" or "services") orchestrates domain logic to fulfill one specific operation, like "create an order," taking its dependencies as plain parameters rather than reaching for global singletons. The infrastructure layer is where frameworks live - Express routers, Prisma clients, HTTP clients for third-party APIs - translating between the outside world's protocols and the application layer's plain function calls.
A port is the interface the application layer defines for something it needs but doesn't want to know the concrete implementation of - most commonly a repository interface for persistence.
// domain/ports/order-repository.ts - a promise, not an implementationexport interface OrderRepository { save(order: Order): Promise<void>; findById(id: string): Promise<Order | null>;}
An adapter is the concrete class in the infrastructure layer that fulfills a port - a PostgresOrderRepository implementing OrderRepository, for instance. The application layer only ever imports the interface, never the adapter, which is what makes swapping Postgres for DynamoDB a change confined to one new adapter file rather than a rewrite of every use case that touches orders.
Wiring ports to adapters happens in exactly one place: the composition root, typically main.ts, which is the only file in the entire codebase allowed to know about both a use case and its concrete Postgres implementation at the same time. Every other file depends on interfaces; only the composition root depends on classes.
The value of this layering is easiest to see in what it makes possible rather than what it forbids. A use case with no framework imports can be unit-tested by passing in an in-memory fake repository, with no server binding a port and no database running - tests that would otherwise need supertest and a test database run in milliseconds instead. The same use case can also be called from a message queue worker, a scheduled job, or a CLI script without duplicating any business logic, because "how the operation was triggered" and "what the operation does" were never coupled in the first place.
That separation also determines how expensive a framework migration is. A codebase where domain logic never imported Express can move to Fastify by rewriting the infrastructure layer alone; a codebase where business rules are laced with req/res calls has to rewrite the business rules themselves, which is a fundamentally riskier and slower migration because correctness and framework syntax are now tangled together.
Approach
Strength
Weakness
Best Fit
Flat, framework-coupled handlers
Fast to write, no indirection, easy to read for a tiny app
Untestable without a server; framework migration touches business logic
Prototypes, single-purpose scripts, < 5 routes
Layered (domain / application / infrastructure)
Testable without HTTP; framework and DB become swappable
Extra files and interfaces; overkill for trivial CRUD
Services with real business rules and a multi-year lifespan
Feature modules with a shared kernel
Keeps related domain concepts together; scales team ownership
Requires discipline to avoid a bloated "shared" folder
Larger codebases with multiple bounded domains
As a codebase grows past a handful of routes, the natural next question is how to group related use cases, ports, and adapters - by technical layer alone, or by feature module (orders, billing, users) that each contains its own thin layering internally. Most production Node.js services converge on the latter: a shared/ folder for genuinely cross-cutting concerns (error base classes, a logger instance) and feature folders that own their own business rules, so two modules never fight over where a rule like tax calculation belongs.
Enforcement matters as much as the pattern itself - a layering rule that exists only in a wiki page erodes within a few pull requests. Teams that keep this discipline over time typically encode it as an ESLint rule (no-restricted-imports blocking express inside domain/) so a violation fails CI rather than code review memory.
"Modularity just means splitting code into more files." File count is orthogonal to modularity - the property that matters is which files are allowed to import which other files, not how many files exist.
"Interfaces (ports) are unnecessary overhead if I only ever use one database." The value isn't hypothetical database-swapping, it's testability - a port lets a use case be tested with an in-memory fake, with or without ever changing databases.
"Dependency injection requires a framework or container." Passing dependencies as plain function or constructor parameters is dependency injection; a container like Awilix or NestJS's DI system is one implementation of the idea, not a prerequisite for it.
"Layering slows down every project, so it's not worth it early." It's a real cost on a five-route prototype and a real savings on a service with years of business rules ahead of it - the trade-off depends on expected lifespan and logic density, not a universal rule.
"A shared/ folder is where anything reusable belongs." Reusable and cross-cutting aren't the same thing - business logic that two feature modules both need should be owned by one of them and exposed as a service, not dumped into a shared utilities file.
What does "modularity" actually mean for a Node.js backend?
Controlling dependency direction so business logic doesn't depend on delivery mechanisms (HTTP frameworks, databases) - it's a property of the dependency graph, not a count of files or folders.
Why shouldn't domain logic import Express or Fastify types?
Because that couples correctness of a business rule to the syntax of a specific framework - the rule becomes untestable without a server and unmovable without rewriting it during any framework migration.
What's the difference between a "port" and an "adapter"?
A port is an interface the application layer defines describing what it needs, without saying how; an adapter is the concrete class in the infrastructure layer that actually fulfills that interface, like a Postgres-backed repository implementation.
How does dependency direction actually get enforced day to day?
Mostly by convention plus an ESLint rule like no-restricted-imports that blocks framework imports inside the domain layer in CI - without automated enforcement, layering rules tend to erode within a few pull requests.
What is a "composition root" and why is there only one?
It's the single place in the codebase - typically main.ts - allowed to know about both an interface and its concrete implementation at the same time, wiring them together; keeping that knowledge in one place is what lets every other file depend only on interfaces.
Does every Node.js project need this much layering?
No - a five-route prototype pays the cost of interfaces and layers without getting the benefit, since there's no framework migration or complex business logic to protect yet; the trade-off shifts as business-rule density and expected lifespan grow.
How does this layering make testing faster?
A use case that takes its dependencies as parameters can be tested by passing an in-memory fake instead of a real database or a running HTTP server, so tests run in milliseconds and don't require network I/O or port binding.
Should related code be grouped by technical layer or by feature?
Larger codebases typically group by feature (orders, billing) with each feature owning its own thin domain/application/infrastructure layering internally - pure technical-layer grouping tends to scatter one feature's logic across too many top-level folders as a project grows.
Is dependency injection the same thing as a DI container?
No - dependency injection is the general practice of passing dependencies in rather than importing singletons; a container (Awilix, NestJS) automates the wiring for you, but manual constructor or parameter injection is DI too.
What's the risk of a bloated `shared/` folder?
It tends to accumulate business logic that actually belongs to one specific feature, which recouples modules that were supposed to be independent - the fix is usually having one feature module own the logic and expose it as a service to the other.
How expensive is a framework migration in a well-layered codebase versus a coupled one?
In a layered codebase, only the infrastructure layer (routes, adapters) needs rewriting; in a coupled codebase, business rules themselves reference the framework, so the migration has to touch and re-verify correctness-critical code, not just plumbing.