DependenciesGENERALCONTESTEDSCALE-SPECIFIC

Dependency Direction

Stable, high-level policy should not depend unnecessarily on volatile, low-level detail. The word "unnecessarily" is doing almost all the work.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

When module A and module B have to talk to each other, which one should know the other exists?

The requirement

Pricing has to read the current promotional campaign, which lives in a Postgres table. The obvious code has the pricing rules importing the campaign repository, and the team lead says the arrow points the wrong way.

The obvious build

Pricing needs campaign data, so pricing imports the campaign repository and calls it. This is the shortest path from requirement to working code, it reads top to bottom, and a new engineer can follow it without knowing anything about ports or adapters. That is a genuine virtue and it is why almost every codebase starts here.

Why it breaks

The schema team renames a column. The change is announced as a database change, but it lands in the pricing rules, because that is where the row is read — and the pricing tests now need a database to run.

How it breaks as requirements change
  • The schema team renames a column. The change is announced as a database change, but it lands in the pricing rules, because that is where the row is read — and the pricing tests now need a database to run.
  • A price recomputation for an old order needs the campaign set *as it was*, not as it is. With pricing calling the repository directly there is no seam to hand it a historical campaign set; the only option is to fake the database.
  • Somebody adds a second caller — an admin price preview — and it needs pricing without a transaction. The repository call is in the middle of the rules, so the transaction question is now a pricing question.
  • The pricing tests get slow, so they get run less, so the module that changes every second sprint is the least-protected one in the system. That is the actual cost, and it arrives quietly.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • One Postgres database, one deployable, five engineers. Nobody is going to swap the database, and everyone knows it.
  • The pricing rules are the part of the system that changes most often — roughly every second sprint.
  • The schema is owned by another team and has changed twice this year without warning.
  • TypeScript, so an interface costs one file and no runtime overhead; the same argument is priced differently in a language where it costs a virtual dispatch or a header.
Invariants
  • A price computed by the rules must be reproducible from its inputs alone — the same order and the same campaign set must produce the same number, forever, including for an order from last year.
  • Nothing outside pricing may compute a price, whichever direction the dependency ends up pointing.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Pricing owns the rule: given an order and a set of applicable campaigns, what does it cost.
  • The campaign repository owns retrieval: given a query, what rows exist in Postgres right now.
  • Something above both — an application service, a use case, a handler — owns the sequencing: fetch the campaigns, then price the order.
  • Nobody owns "pricing knows how campaigns are stored". That sentence should not be true of any module.
Boundaries
  • The seam falls between *deciding* and *fetching*. Those two change for different reasons and at different rates, which is the whole justification for a boundary (Cohesion).
  • The boundary is not "domain versus infrastructure" as a matter of taste. It is drawn here because the schema changes on someone else's schedule and the rules change on ours, and a boundary between two different change schedules is one that pays (Stable Boundaries).
  • Direction is a property of the source code — who imports whom, who compiles without whom. It is not a property of the runtime call graph, which still runs top to bottom in both designs (Interface Versus Implementation).

The arrow is about compilation, not about calls

Both designs below make exactly the same runtime call sequence: something fetches campaigns from Postgres, then something computes a price. Nothing about the order of operations changes. What changes is which file mentions which other file — which module you can compile, load, read and test without dragging the other one in.

That is why "dependency direction" sounds abstract and is not. It is answerable by grepping the import statements, and the answer predicts what a change will cost.

  • Runtime order is identical in both. Anyone arguing about direction on performance grounds is arguing about something else.
  • In the naive version the rules module cannot be loaded without a database driver on the path, which is what makes its tests slow.
  • The service is allowed to know about both sides. Sequencing is a real responsibility and it belongs somewhere (Designing by Responsibility).
Same calls, two directions
fetch campaignsprice(order, campaigns)SQLthe import that made the rules untestablethe arrow being removedPriceOrder (sequencing)Naive: rules import repoPricing rules (stable)CampaignRepository (volatile)Postgres schema (someone else's)
UserLLMAgentToolDataDecisionHumanGuardrail

"Unnecessarily" is the entire principle

The unqualified version — high-level must not depend on low-level — is false, and taken seriously it produces a codebase where every call goes through a port. The useful version has two qualifiers: the dependency must be *unnecessary*, meaning something is gained by removing it, and the depended-on thing must be *volatile*, meaning it actually changes, is slow, or has side effects.

Drop either qualifier and the principle stops discriminating. A rule that fires on every dependency is not a heuristic, it is a policy, and this domain does not have policies about structure.

The schema team renames campaign.discount_pct to campaign.rate_basis_points
The change

A column the pricing path reads is renamed and its units change. No business rule changes; it is purely a storage change made by a different team.

Pricing rules import the campaign repository and read rows directly
PricingRulesCampaignRepositoryPricingFixturesAdminPricePreview
testspricing_test (needs a database)repository_testadmin_preview_test
4 modules · 3 test files

The edit is small; the review is not. A reviewer looking at a diff in PricingRules cannot tell at a glance whether a rule moved, because the rule and the row-reading are in the same file. Pricing's tests need a schema, so the fixture files move too.

Rules take campaigns as an argument; the repository maps rows to campaigns
CampaignRepository
testsrepository_test
1 module · 1 test file

One module, one test file. PricingRules is untouched and does not recompile differently, so the review question "did a rule change?" is answered by the file list.

what it cost The mapping is now an extra place that must be kept in sync: adding a genuinely new campaign field means editing the row type, the mapper and the rule, where the naive design needed one edit. On an additive-change-heavy codebase that tax can exceed the saving, and teams that add fields weekly and rename columns yearly are measurably better off with the naive design.

Two ways to fix a bad arrow, and the cheap one goes first

PARADIGM-SPECIFICThe "pass the data down" move is cheap in a language where a function can take a list and return a value, and it is how the functional-core style handles this problem by default. In an OO codebase where the unit of reuse is a class with collaborators, the same move fights the idiom, and the inverted port is often the shape the team and its framework can actually maintain — so the ranking of these two options genuinely flips with the paradigm rather than one being universally better.

When the arrow points the wrong way there are two moves available, and they are not equally priced. The first is to *remove* the dependency by moving sequencing up: the caller fetches, then hands the data down. The second is to *invert* it by defining an interface on the stable side and having the volatile side implement it.

Removal is almost always cheaper, and it is almost always skipped, because inversion is the one with a name and an acronym. Try passing the data first. Reach for an interface when the stable side genuinely has to *trigger* the volatile one — send this email, write this row — rather than merely consume its output (Dependency Inversion).

Removing the dependency versus inverting it
Inverted: the rules now own a port
// pricing/rules.ts
export interface CampaignSource {
  activeFor(orderId: string): Promise<Campaign[]>
}

export class PricingRules {
  constructor(private campaigns: CampaignSource) {}

  async price(order: Order): Promise<Money> {
    const cs = await this.campaigns.activeFor(order.id)
    return apply(order, cs)
  }
}
// price() is now async, needs a double in every test,
// and cannot be called for a historical campaign set.
Removed: the rules take what they need
// pricing/rules.ts   — imports nothing but its own types
export function price(order: Order, campaigns: Campaign[]): Money {
  return apply(order, campaigns)
}

// app/priceOrder.ts  — sequencing lives here
export async function priceOrder(id: string) {
  const order = await orders.load(id)
  const campaigns = await campaignRepo.activeFor(id)
  return price(order, campaigns)   // sync, pure, trivially testable
}

The inverted version still has pricing depending on *when* campaigns are loaded, which is why it is async and why every test needs a stub. Removing the dependency makes the rule a pure function of its inputs, so historical recomputation, admin preview and batch repricing all become the same call with a different argument. Inversion redirects a dependency; removal deletes it, and a deleted dependency has no maintenance cost.

How to build it

Most important first.

  • Establish which side is more stable. Not "more important" and not "higher in the diagram" — which one you expect to edit less often, and whose schedule you control (Stable Dependencies).
  • Point the arrow from volatile to stable. The thing that changes often is allowed to know about the thing that changes rarely; the reverse means every change to the volatile side reaches into the stable one.
  • When that is already the case, stop. A great many dependencies are already pointing the right way and need nothing done to them, and the discipline of noticing that is what separates this from ritual (When Design Does Not Pay).
  • When it is not, invert it — but only if the dependency is actually volatile (Volatile Dependencies). That is the subject of Dependency Inversion, and it is a separate move with its own cost.
  • Move sequencing up rather than pushing knowledge down. "Fetch, then decide" at the caller is usually a smaller change than any interface, and it removes the dependency instead of redirecting it.

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Next schema change under the naive design: the pricing module is edited, its tests are edited, the fixtures are edited, and the review has to establish that a rule did not change while a column was renamed. Roughly a day, and the risk is that a rule change hides inside a plumbing change.
  • Next schema change after the split: the repository is edited and the repository tests are edited. Pricing does not compile differently and does not need review. Roughly an hour.
  • Next *rule* change is where the split really pays: pricing is a pure function, so the change is one file plus fast tests with no database, and the historical-recompute case is exercised by passing a different campaign set.
  • What got more expensive: adding a field that pricing needs now requires touching three places — the row, the mapping, and the rule — where before it was one. That is the standing tax, and it is paid on every additive change forever.
What the recommended approach costs
  • Fetch-then-decide over-fetches. The service loads every applicable campaign even when the first rule would have short-circuited, and in a hot path that is a real and measurable cost (Cost-Aware Interfaces).
  • The stack is deeper. A reader tracing "where does the campaign come from" now goes through the service instead of reading one line, and for a reader passing through once that is pure cost (Local Reasoning).
  • The rule only helps when the volatility judgement is right. Deciding that the database is volatile in a company that has run the same Postgres for nine years is a judgement that has been wrong for nine years.

What can go wrong

Failure modes
  • The arrow is turned around but the *knowledge* is not moved: pricing now takes a CampaignRepository interface whose methods are findByCampaignTableId and return database rows. The import points the right way and the coupling is unchanged (Leaky Abstractions).
  • Direction is applied as a blanket rule, so every module gets an interface and the codebase acquires two hundred one-implementation abstractions (How SOLID Gets Misused).
  • Sequencing moves up so far that the application service becomes the god object it was supposed to prevent, holding every fetch for every use case (God Object).
  • The team argues about direction for a dependency on the standard library's date formatter, which is not volatile, does not have side effects worth isolating, and never needed a seam.
Dependencies, and their direction
  • After the change, pricing depends on nothing but its own inputs — no repository, no clock, no configuration. It becomes a pure function of order and campaigns (Purity and Testing).
  • The application service now depends on both pricing and the repository, and that is correct: sequencing is exactly the responsibility that is allowed to know about both sides.
  • The repository depends on Postgres and on the campaign row shape, and on nothing in pricing. That direction never wanted to be inverted.
Misreads
  • "High-level code must never depend on low-level code." Not what the principle says, and taken literally it forbids calling a hash function. The claim is about *unnecessary* dependence on *volatile* detail; a dependency on something stable and side-effect-free is fine and inverting it is waste (Dependency Inversion, Critically).
  • "So the domain layer imports nothing." A domain module importing a decimal library, a UUID library, or its own language's collections is not a dependency-direction problem. Purity about imports is a different and much weaker goal than purity about volatility.
  • "Direction means layers." Layers are one way of imposing a direction and a fairly blunt one; a vertical slice with no layers at all can have impeccable dependency direction (Vertical Slices, Package by Layer).
  • "We inverted it, so it is decoupled now." An interface that exposes the other side's vocabulary has moved the coupling into a file, not removed it. The question is whether a change on one side forces a change on the other, and the import graph does not answer that on its own (Kinds of Coupling).
Smells this explains
  • shotgun-surgery
  • feature-envy

Testing it, and how it ages

What to test, and at which boundary
  • Pricing gets pure unit tests with campaigns constructed in the test, no database, no fixtures, and they should run in single-digit milliseconds. If they do not, the split is not finished (What a Unit Is).
  • The repository gets an integration test against a real Postgres, because what it is being tested for is exactly the thing an in-memory fake cannot tell you (Where a Test Must Be Real).
  • One test at the application service that fetch-then-price wires together, so the seam itself has coverage and does not become the untested gap between two well-tested halves.
  • Property test the invariant directly: the same order and campaign set always produce the same number (Property-Based Testing).
How this design ages
  • The pure pricing core keeps absorbing rules — tiers, currency, rounding — with no change to its dependencies, which is the payoff compounding.
  • When a second source of campaigns appears (a feature-flag service, say), the application service grows a second fetch and pricing does not change at all. That is the anticipated change this design was bought for.
  • It stops being right if pricing needs to *decide what to fetch* — if a rule says "if the order is over 500, also look for volume campaigns". Then fetch-then-decide no longer works and you need either a callback into retrieval or a two-phase evaluation, and the honest answer is that the second is usually worth building and the first usually is not (Effect Boundaries).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat a change to X forces a change to everything that knows about X is a property of source dependencies as such, so it holds in any language with separate compilation units, modules or files.
  • CONTESTEDThe strongest opposing case: most systems never swap their database, never gain a second implementation, and pay the indirection tax forever for an option they never exercise — so "just call the repository" is the cheaper design measured over the actual life of most software. That argument is correct about the swap-the-database justification, which is usually bogus. It is weaker against the testability and change-schedule argument, which does not depend on any implementation ever being replaced.
  • SCALE-SPECIFICWith one team owning both sides, an unwanted direction costs a conversation. When the schema is owned by another team on another release cadence, the same direction costs a cross-team negotiation per change, which is why the same code is fine at five engineers and untenable at fifty.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — "these tests need a database" is the earliest and loudest signal that an arrow points the wrong way, and the confidence question behind it is theirs.
  • Programming Languages & Runtime Internals — what a module boundary actually costs at compile and link time differs enormously between a header-based language, a JIT-compiled one and a module-system language, and that cost is an input to this decision.