The Direction of the Arrows: Dependencies Are Your Real Architecture

Draw your app's architecture on a whiteboard and it always looks clean. Neat boxes, sensible names, tidy layers stacked top to bottom. Everyone nods. Then you open the code and it doesn't match the drawing at all — a UI component reaches into a data-fetching file, a "shared" helper imports a specific feature, two features import each other through a back door nobody remembers adding.
The whiteboard is the architecture you wish you had. The import statements are the architecture you actually have. And the gap between them is where most large React apps quietly rot.
I've spent the last two articles on where code lives — the shared/ folder and barrel files. This one is about the thing underneath both: not where a module sits, but which way it points. Who is allowed to import whom. Because that — the direction of the arrows — is the real shape of your app.
Your folders lie, your imports don't
Here's the uncomfortable part. Folder structure is a suggestion. You can put a file in shared/ and it means nothing about how coupled it is — what matters is what it imports and who imports it. Two apps with identical folder trees can have completely different architectures, because architecture isn't the tree. It's the graph.
Every import statement is a directed edge: "this module depends on that one." Collect all of them and you get your dependency graph — the one true map of your app. And that map answers the questions folders can't:
- If I change this file, what breaks? (everything that points at it)
- Can I delete this feature cleanly? (only if nothing outside points into it)
- Why is this "small" component impossible to reuse? (because it points at half the app)
- Why did one route pull in the entire bundle? (follow the arrows — you'll find a bad one)
You already met that last one in the barrel article. That wasn't really a barrel problem. It was an arrow problem — an import pointing somewhere it shouldn't, and a barrel making it easy.
Folders describe intent. Imports describe reality. When they disagree, reality wins.
Arrows have a correct direction
The single most useful architectural rule I know fits in one sentence: dependencies should point in one direction — from specific to general, from volatile to stable, never back.
Think of your app in rough layers, top to bottom:
app ← wires everything together features ← "edit policy", "create claim" — business use-cases entities ← domain models: Policy, Claim, User shared ← generic, app-agnostic: Button, formatDate, http client
The rule is simple: arrows only ever point down. A feature may import an entity. An entity may import from shared. shared imports nothing from above it — not an entity, definitely not a feature.
Why down and not up? Because the lower you go, the more stable and general the code is. shared/Button doesn't know your app sells insurance. entities/Policy knows about policies but not about the specific "renew a lapsed policy" screen. The edit-policy feature knows the most and changes the most. If a stable module (Button) points up at a volatile one (edit-policy), then every time that feature changes, your generic button is at risk — and it's no longer generic. You just welded the most reusable thing in your app to the least reusable one.
The moment shared imports a feature, the word "shared" becomes a lie. This is the mechanical reason the shared/ folder turns into a junk drawer: not because people are messy, but because they let arrows point the wrong way, and an upward arrow is exactly how a generic folder gets chained to a specific feature.
The two arrows that kill you
There are two directions of arrow that do almost all the damage. Learn to see them and you've caught 90% of architectural decay.
The upward arrow — general depending on specific. A shared utility importing a feature. An entity importing a screen. A design-system component importing app-specific business logic. Every one of these takes something that was supposed to be reusable and staples it to something that isn't.
// shared/lib/format.ts — a "generic" formatter import { POLICY_STATUS_LABELS } from '@/features/edit-policy/constants'; // ^^^^^^^^^^^^^^^^^^^^^^ upward arrow export function formatStatus(code: string) { return POLICY_STATUS_LABELS[code] ?? code; }
This looks harmless. It compiles, it works, the PR gets approved. But shared/lib/format now depends on edit-policy. Import that formatter anywhere — a totally unrelated screen — and you've dragged edit-policy in with it. "Shared" now means "shares a dependency on one specific feature." The fix is to push the specific thing up to where it belongs and keep the shared thing ignorant:
// shared/lib/format.ts — knows nothing about policies export function formatStatus(code: string, labels: Record<string, string>) { return labels[code] ?? code; } // features/edit-policy/... — the feature owns its own labels formatStatus(policy.status, POLICY_STATUS_LABELS);
The generic module stayed generic. The specific knowledge stayed in the feature. The arrow points down again.
The sideways arrow — feature importing feature. edit-policy reaches directly into billing-dashboard. Now you can't touch one without understanding the other, you can't delete either cleanly, and if billing-dashboard also imports edit-policy back, you've got a cycle — two modules that can only be understood as one tangled unit. Cross-feature imports are how a codebase where every feature was supposed to be independent becomes one where nothing is.
The fix isn't "never let features talk." It's how they talk. Pull the shared concept down into an entity both can depend on, or lift the coordination up into the app layer that's allowed to know about both. Features don't import each other sideways; they meet in a layer above or below. Arrows down, never across.
A feature's public API is the only door in
Here's what makes the "arrows down" rule enforceable instead of a vibe: each feature exposes one public entry point, and nobody imports past it.
features/edit-policy/ index.ts ← the ONLY thing outsiders may import ui/EditPolicyForm.tsx model/useEditPolicy.ts model/validation.ts ← private lib/mapPolicyDto.ts ← private
// features/edit-policy/index.ts — deliberate public surface export { EditPolicyForm } from './ui/EditPolicyForm'; export { useEditPolicy } from './model/useEditPolicy'; // validation, DTO mapping, internal helpers stay private
Anything outside the feature imports from @/features/edit-policy and nothing deeper. The insides — validation rules, DTO mapping, private hooks — are free to change, because from the outside they don't exist. This is the good use of a barrel I argued for last time: not to shorten imports, but to draw a wall with exactly one door in it.
Reach past that door — import { validatePolicy } from '@/features/edit-policy/model/validation' — and you've done two bad things at once: coupled to an internal that was never promised to stay, and created an arrow that no longer respects the feature's boundary. The public API is what turns "arrows point down" from a hope into a checkable rule. There's a door. You use the door.
Make the graph impossible to get wrong
You will not hold this in your head. Nobody does. On a real team, someone adds an upward import at 6pm on a Friday, it works, it ships, and the arrow is now part of your architecture forever. Good intentions don't scan the dependency graph. Tooling does.
I let a linter own the rule. eslint-plugin-import with no-restricted-imports, or a boundaries plugin (eslint-plugin-boundaries, or the import rules in Biome), encoding the layer directions as actual errors:
// eslint — shared may not import upward, features may not import each other 'no-restricted-imports': ['error', { patterns: [ { group: ['@/features/*', '@/entities/*'], message: 'shared/ must not import from features or entities — arrows point down only.' }, ], }],
Now the wrong arrow doesn't get a code review debate. It gets a red squiggle before the commit. The architecture stops being a document people forget and becomes a constraint the tooling enforces. That's the whole game: an architectural rule that isn't mechanically enforced is a rule that's already being broken somewhere you haven't looked.
And once the arrows are constrained, a dependency-graph visualiser (madge, dependency-cruiser, or your bundler's analyzer) stops being a curiosity and becomes a health check. Run madge --circular and it lists your cycles — every one a place where two modules secretly became one. A clean graph with arrows all pointing down is not an aesthetic preference. It's the difference between a change that touches one file and a change that touches forty.
Why this is the article under the others
The shared/ folder turns into a junk drawer because people let it depend upward. Barrel files blow up your bundle because they make a bad arrow cheap to draw. Features become impossible to delete because they point sideways at each other. Every one of those is the same disease wearing a different symptom: an arrow going the wrong way.
So when I evaluate an architecture now, I don't start with the folder tree — that's the story the code tells about itself. I start with the graph: pull the imports, look at the direction of the arrows, and find the ones pointing up or sideways. Those arrows are the whole diagnosis. Fix them and the folders mostly sort themselves out. Ignore them and you can reorganize folders every quarter and still be stuck in the same tangle, because you were rearranging the boxes while the arrows — the thing that actually mattered — stayed exactly where they were.
Next I want to turn from where the arrows point to how big the boxes should be — composition versus decomposition, and how to hand a component its dependencies instead of letting it reach out and grab them. If you want a fast, slightly alarming read on your own app tonight, run madge --circular src and count the cycles. Tell me the number — I'm curious whether you're prouder or more horrified than you expected.