Lesson 1 of 5
Project Structure
"Make the change easy, then make the easy change." — Kent Beck
Part V ended by drawing a boundary: business logic must not see req or res. This Part is about everything on the far side of it, and it starts with where the files go.
That sounds like the least consequential decision in a codebase and is one of the more consequential ones, for a reason Beck's line names. Structure exists to make changes local. A well-organised project is not one that looks tidy in a file tree; it is one where a typical change — add a field, add an endpoint, change a rule — touches a small number of adjacent files. A badly organised one is where the same change touches six directories, none of which are near each other, and every pull request conflicts with every other.
There are two dominant layouts, they optimise for different things, and the choice matters more as a team grows. Beyond that there are three properties worth enforcing rather than hoping for: dependencies pointing one way, no import cycles, and a single place where everything is wired together.
None of this is Node-specific, and all of it is more urgent in Node than in most ecosystems — because the module system will happily let a cycle resolve to a half-initialised object rather than telling you, which Part I ch4 measured.
You add a field to an order. How many directories does that change touch?
In the conventional layer-by-type layout — routes/, controllers/, services/, repositories/, validators/, tests/ — the answer measured below is six. Every one of those directories holds an orders file, and a change to orders touches all of them, in six different places in the tree.
In a feature-by-folder layout, where orders/ contains its routes, service, repository, schema and tests, the answer is one.
That is not an aesthetic difference. Six directories means six files open, a pull request that is hard to review as a unit, and merge conflicts with anybody touching a different feature — because you are both editing services/. It also means a new engineer cannot find "everything about orders" without knowing the convention. The layer-by-type layout optimises for finding all the services, which is a thing almost nobody needs to do; the feature layout optimises for changing one feature, which is what work actually consists of.
Two layouts, and what each optimises
The measurement first, because the argument is usually made on taste.
Three things that measurement settles, and one it does not.
Both layouts hold the same number of files. The difference is adjacency, not size. Nobody is eliminating a concern by moving it.
Feature folders localise change, which is the operation teams perform constantly. A pull request becomes one directory, reviewable as a unit, and two people working on different features do not touch the same directories.
Layer-by-type localises kind. It is genuinely better if your common task is "audit every repository" or "apply a change to all controllers" — which happens, but rarely, and usually as a one-off that a search handles.
What the measurement does not settle is small projects, where either works and the feature layout's ceremony is not yet earned. A service with three endpoints does not need directories at all. This is the Metz warning from Part V ch3 applied to layout: the structure should arrive when the pain does.
A workshop can be organised by tool type — all the screwdrivers in one drawer, all the saws in another — or by job, with a box per task containing everything that job needs. The tool-type arrangement looks tidier and is genuinely better if your question is "how many screwdrivers do we own".
But nobody's work is "use a screwdriver". Work is "fit the door", and fitting a door means opening six drawers, remembering which ones, and putting everything back in six places. The job-box arrangement duplicates a little — two boxes may each hold a screwdriver — and that duplication is the price of the whole job being in one place. Which is exactly the trade in a codebase: the feature layout has a repository.js in every feature folder rather than one repositories/ directory, and the win is that a change is local.
The dependency rule
Layout says where files are. The dependency rule says which of them may know about which, and it is the property that keeps a codebase from becoming a single tangled unit.
The rule is one line of code and buys three properties.
The domain is testable in isolation. It imports nothing that needs a connection, a port or a fixture, so its tests are functions calling functions.
Adapters are replaceable. Swapping Postgres for something else touches the adapter and nothing else, because nothing inward knows the adapter exists. This is the payoff people are usually promised and rarely get, and it is a direct consequence of the arrow direction.
Dependencies are inverted where they need to be. application/ports is the trick: the application declares the interface it needs — "something that can save an order" — and the adapter implements it. The arrow from adapters/db to application/ports points inward, so the application depends on its own abstraction rather than on a driver.
This is the file-layout expression of the factory pattern from Part V. There, dependencies were injected as arguments; here, the type of thing being injected is declared by the layer that needs it rather than by the layer that provides it.
The dependency rule has been rediscovered under several names. Cockburn's hexagonal architecture (2005) called the inward-facing interfaces ports and the implementations adapters. Jeffrey Palermo's onion architecture (2008) drew it as concentric rings. Robert Martin's clean architecture (2012) named it the dependency rule outright. All three say the same thing: source dependencies point inward, and the innermost layer knows nothing of the outer ones.
It reached JavaScript late and unevenly. For most of Node's history the dominant layout was the one Express's own generator produced — routes/, views/, public/ — which is layer-by-type with no dependency rule at all, and it was fine for the size of application people were building in 2011. The pressure to formalise came with TypeScript and with services large enough that "which module may import which" became a real question, and NestJS's popularity is substantially explained by shipping an opinion about it.
What is worth noticing is that none of this is enforceable by the language. JavaScript has no module visibility, no package-private, no way to say "this may only be imported by that". The rule is a convention, and convention plus a large codebase plus time equals violation — which is why §5 is about a check rather than a guideline.
Cycles, and the barrel that creates them
Import cycles are the failure mode Node handles worst, and one common convention manufactures them.
Three consequences worth being precise about.
A cycle does not throw. Part I measured it: CommonJS returns a partially-populated exports object, so a function is undefined at call time rather than at import time. ES modules are stricter — a temporal dead zone error — but only for values, and function declarations are hoisted, so many cycles work until one does not.
Barrel files convert a tree into a hub. Every module importing from index gains an edge to a node that has edges to everything, so two modules that would never have referenced each other become mutually reachable. They are convenient for a package's public surface and a liability inside it.
The failure is order-dependent. Which module loads first decides which one sees a partial object, and that order can change when an unrelated file is renamed — which is why these bugs appear to come from nowhere.
The practical rules: import directly rather than through a barrel inside a project, keep barrels for the boundary of a published package, and run a cycle check in CI. The check above is thirty lines and madge --circular is a one-line alternative.
Three questions about a project's shape.
How many directories does a typical change touch? If the answer is more than two, the layout is fighting the work.
Does anything inward import something outward? A domain file importing a database client means the domain can no longer be tested or reused without one.
Is there exactly one place where things are wired together? If construction is scattered, there is no single answer to "what does this service consist of".
The composition root
Dependency injection needs one place where the injecting happens, and its properties are worth stating.
Four properties, and the fourth is the one people miss.
Construction happens once, at startup. Nothing constructs its own dependencies at call time, so there is no hidden state and no surprise about when a connection is opened.
The wiring is readable. One file answers "what does this service consist of", in dependency order, which is the same question §4 of Part V ch3 asked of routes.
Tests replace exactly what they need. Overriding the database and the clock leaves the mailer real. That is finer-grained than module mocking and requires no framework.
The composition root is the only place allowed to know everything. It sits outside the dependency rule — it imports from every layer by design, because somebody has to. Recognising it as a deliberate exception is what stops people concluding the rule is unworkable.
The most common structural failure is the service that imports the database directly. It is the shortest path when writing the code, and it makes that module untestable without a live connection, unreusable from a worker with different credentials, and impossible to reason about — because the connection is created by a side effect at import time, so importing the module for any reason opens a socket. Test suites then acquire a real database, and their runtime goes from seconds to minutes.
The second is the barrel file cycle. A project adds index.js re-exports for tidier imports, two modules end up importing from it, and one of them receives a half-initialised object. The symptom is a function that is undefined at call time in one environment and fine in another, because module load order differs — and it commonly appears after a rename that changed nothing semantic.
The third is structure applied too early. A service with four endpoints acquires domain, application and adapter layers, ports for a single database, and a composition root wiring six things — and every change now touches four files instead of one. The layered structure is correct for a large service and is overhead for a small one, and the honest signal to introduce it is a change that hurts, not a plan that it might.
Enforcing it
A convention that is not checked becomes a convention that is violated, and all three properties above are checkable in a few lines.
Three notes on making a check like this survive contact with a team.
Allow the exception explicitly. The composition root imports outward by design. A rule with no escape hatch gets disabled entirely the first time it is inconvenient, so name the exception rather than weakening the rule.
Report every problem at once. The same argument as configuration validation in Part V ch5 — one problem per run is one failed build per problem.
Introduce it with a baseline. An existing codebase will have violations. Record the current set, fail on new ones, and shrink the baseline over time. A check that fails on day one gets deleted on day one.
In practice, eslint-plugin-import and dependency-cruiser do all of this with configuration rather than code, and madge --circular covers cycles alone. Writing it yourself is a good way to understand what they check; using them is the right call afterwards.
Common Mistakes
Layer-by-type in a project that changes by feature. A one-field change touches six directories, measured.
Applying layered structure to a small service. Four endpoints do not need ports and adapters; the structure should arrive with the pain.
A domain module importing a database client. It is then untestable without a connection and unreusable from anything else.
No inward-facing port. The application depending on a driver rather than on its own interface makes the driver unswappable.
Barrel files inside a project. They turn a tree into a hub and put any two modules one step from a cycle.
Assuming a cycle will error. CommonJS gives you a half-filled
exportsobject silently; the failure is order-dependent.Constructing dependencies at import time. Importing a module for any reason then opens a connection.
Scattering construction. With no composition root there is no single answer to what the service consists of.
A rule with no exception for the composition root. It gets disabled entirely the first time it is inconvenient.
Introducing a structural check without a baseline. It fails on day one on legacy violations and is deleted on day one.
Interview Corner
routes/, services/ and repositories/ each containing an orders file, I would have an orders/ directory containing its routes, service, repository, schema and tests. The reason is that work is almost always "change this feature", and in the layer-by-type layout that means opening six directories scattered across the tree, whereas the feature layout makes it one directory — so the pull request is reviewable as a unit and two people working on different features do not touch the same folders. Layer-by-type is genuinely better if the common task is "look at every repository", but that is a rare, usually one-off job that a search handles. The caveat is size: a service with three or four endpoints does not need any of this, and I would let the structure arrive when a change starts to hurt rather than imposing it up front.undefined at call time in production but fine locally. What would you check?exports object, so a function that will exist a moment later reads as undefined at the point of use. Which module ends up half-initialised depends on load order, so it can differ between environments or change after an unrelated file is renamed, which is why it looks like it came from nowhere. The most common source in a modern codebase is a barrel file: an index.js that re-exports everything, imported by the same modules it re-exports. That adds an edge from every module to the barrel and from the barrel to every module, so any two modules touching it are one step from a cycle. I would run a cycle detector — madge --circular or dependency-cruiser — to confirm and find it, then fix it by importing directly rather than through the barrel, and add the check to CI so it cannot come back. Barrels are reasonable at the published boundary of a package and a liability inside one.dependency-cruiser or import lint rules in CI, along with a cycle check, and I would allow the composition root as an explicit exception since it imports from everywhere by design. Two things matter for adoption. The check has to report every violation at once, not one per run. And on an existing codebase it needs a baseline — record the current violations, fail only on new ones, and shrink the baseline over time — because a check that fails on day one gets deleted on day one, and then the boundary has no enforcement at all.undefined export caused by a cycle.Hands-on
Four exercises. Exercise 4 combines the first three into the CI check — the form in which a convention becomes a property.
Summary
- Structure exists to make changes local, and the measurement is how many directories a typical change touches — six for layer-by-type, one for feature folders.
- Layer-by-type localises kind; feature folders localise change. Work is almost always changing a feature.
- Small services need neither. The structure should arrive with the pain, not before it.
- Dependencies point inward: adapters know the application, the application knows the domain, nothing imports outward.
- The application declares its own ports, so it depends on its abstraction rather than on a driver — which is what makes adapters replaceable.
- Import cycles do not throw. CommonJS hands over a half-filled
exportsobject, and which module gets it depends on load order. - Barrel files turn a tree into a hub and put any two modules that use them one step from a cycle.
- One composition root, constructing everything at startup, is the only place allowed to import from every layer.
- Tests override exactly what they need at the composition root — no mocking framework, and the rest stays real.
- Enforce the rules in CI with a baseline. A check that fails on day one is deleted on day one.
Structure decides where code lives. The next chapter is about the values that flow through it: configuration and secrets in earnest — how a service learns its database password without that password being in the repository, in a log line, in an error message, or in a container image layer. It is the least glamorous chapter in this Part and the one most likely to matter on a bad day.
The layer-by-type layout persists because it is what frameworks generate, not because anybody chose it. Express's own generator produced routes/ and views/, Rails made the pattern famous, and a generation of tutorials copied it — so the default arrangement of most Node projects is the one that maximises the number of directories a change touches. That is a good illustration of how much of a codebase's shape is inherited rather than decided, and it is worth occasionally asking which of your conventions anybody actually chose.
What I find genuinely underrated is the ports idea. Everybody has heard "depend on abstractions", and it usually gets implemented as an interface sitting next to the implementation, which achieves nothing — the arrow still points outward. The version that works is that the consumer declares the interface: the application says "I need something that can save an order", and the database adapter implements the application's interface rather than the application importing the driver's. That one relocation is the whole difference between a domain you can test without a database and one you cannot, and it is invisible in a file listing.
The part I would push back on is enthusiasm. Layered architecture is correct for a service with a hundred endpoints and actively harmful for one with four, where it turns a one-file change into a four-file change and buys nothing, because there is no second adapter and never will be. The cost of structure is paid immediately and its benefit arrives later, if at all — which makes it exactly the kind of decision people get wrong in both directions. The honest signal is a change that hurt, and the honest response to "we might need it" is to wait.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.