MonolithSCALE-SPECIFICCONTESTEDLANGUAGE-SPECIFIC

Shared Libraries

The internal package everyone depends on. Its fan-in is the point and also the problem: every change ripples outward to code you did not open.

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 should code be extracted into a shared internal library, and what does everyone depending on it actually cost?

The requirement

Three modules each have their own way of representing money. A proposal is on the table to extract a shared platform-core package with Money, Result, retry helpers and the HTTP client wrapper, so everything is consistent.

The obvious build

Extract everything the modules have in common. Three implementations of Money is obvious duplication, and consolidating it is exactly what good engineering looks like.

Why it breaks

Some of it is duplication of knowledge and some of it is coincidence. Three Money types that must agree about rounding are one thing; three retry helpers tuned to three different downstream services are three things that happen to share a shape (Duplicate Knowledge).

How it breaks as requirements change
  • Some of it is duplication of knowledge and some of it is coincidence. Three Money types that must agree about rounding are one thing; three retry helpers tuned to three different downstream services are three things that happen to share a shape (Duplicate Knowledge).
  • Consolidation resolves the differences by fiat. Whoever writes the shared version picks a rounding rule, and two of the three modules silently change behaviour — a change nobody tested for, because nobody knew they differed (Characterization Tests).
  • Fan-in becomes the constraint. Once every module depends on it, a change to it is a change to everything, and the library becomes the most conservative code in the system: the place where nothing can be improved without a coordinated upgrade (Fan-in and Fan-out).
  • And it grows. A package named for being shared has no criterion for exclusion, so it accretes anything two modules touch and eventually becomes a common module with a version number (The Common Module).
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
  • The three implementations differ in ways nobody has catalogued — rounding, currency handling, and whether negative amounts are legal.
  • Whatever is extracted becomes a dependency of every module, so its release cadence becomes everyone's release cadence (Stability and Dependency Direction).
  • The team has no experience running an internal package with versioning, so the initial cost is higher than it looks.
Invariants
  • A shared type's behaviour must be identical for every consumer, or it is not shared — it is three behaviours with one name (DRY: Knowledge, Not Lines).
  • A consumer must be able to upgrade without changing its own semantics, which is what makes the dependency safe to accept (Backward Compatibility as a Constraint).

Who owns what, and where the seams fall

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

Responsibilities
  • A shared library owns a concept completely — Money owns arithmetic, rounding, currency and formatting — or it owns nothing and is a collection (Cohesion).
  • Someone owns it: a named person or team responsible for its compatibility, its deprecations and its releases. An unowned shared library is everyone's problem and nobody's work (Code Ownership).
  • Consumers own staying current, which is only reasonable if the library owner keeps upgrades cheap (Deprecation).
  • Nobody should own "the place we put things that two modules use", because that is a criterion that admits everything.
Boundaries
  • A shared library is a published interface, even inside one repository. Every exported symbol is a promise, and the ones exported by accident are promises too (API Stability).
  • The boundary should be drawn around a concept with a stable definition. Money is stable; "the way we call HTTP" is a framework choice that will change (Stable Boundaries).
  • Domain knowledge must not cross into a shared library. The moment it knows what a subscription is, it has coupled every module to one module's domain (Dependency Direction).

What `platform-core` becomes if you let it

The proposal sounds like consolidation and it is actually the creation of a new unit with its own responsibilities. Writing those down before extracting is a five-minute exercise that changes the decision surprisingly often — because the list of reasons it will change is the list of teams that will queue behind it.

The verdict below is the finding for this lesson: a shared library with several unrelated reasons to change is not a library, it is a folder with a version number, and its fan-in makes every one of those reasons expensive (Cohesion).

responsibilitiesplatform-core — Money, Result, retry helpers, the HTTP client wrapperThe proposed shared package, before anyone writes it
Knows
  • How money rounds and which currencies exist
  • How a failure is represented as a value
  • How long to wait between retries and which errors are retryable
  • Which HTTP library we use, and our default timeout and header conventions
Does
  • Arithmetic on amounts
  • Wraps success and failure
  • Retries and backs off
  • Makes HTTP calls with our conventions applied
Depends on
  • The HTTP library
  • A clock, for backoff
  • Nothing else — and that last part is the only healthy line here
Changes when — 6 distinct reasons
  • A currency rule changes
  • We want a different error-value ergonomics
  • A downstream service needs different retry behaviour
  • We change HTTP library
  • We change default timeouts
  • Someone adds the next generically-useful thing

Six reasons to change, from four unrelated sources, in a package every module depends on — so every one of those six changes is a whole-system event. The Money part is a genuine shared concept: it must agree everywhere by definition, and three versions of it is a reconciliation bug. The HTTP wrapper is a framework choice that will change, and putting it here makes changing it a coordinated upgrade. The retry helpers are the clearest error: they look identical and are tuned per downstream service, so consolidating them means either a flag argument or a behaviour change nobody tested for. The correct output of this analysis is one narrow money package, an unshared result if the language does not provide one, and leaving retry and HTTP where they are until a third case proves a shape (The Rule of Three).

The ripple, priced

The characteristic cost of a shared library is not writing it. It is that a change to it is a change to everything that depends on it, and the audit is manual because the compiler cannot tell you which consumers relied on the old behaviour.

Price a change that is small in the library and large in consequence — a rounding rule — under both arrangements, and the trade becomes visible in both directions.

The finance team changes how money rounds on invoices
The change

Rounding moves from round-half-up to banker's rounding for tax-inclusive line items, to match a regulator's guidance.

Three module-local Money implementations
billing/money.tsorders/money.tsreporting/money.ts
testsbilling_money_testorders_money_testreporting_money_test
3 modules · 3 test files

Three edits, made by three teams, each safe in isolation and each shippable independently. The risk is that one team does not hear about the change, so the three drift and an invoice total disagrees with a report total by a cent — which is discovered by an auditor, not by a test.

One shared money package
packages/moneyplus an audit of every consumer
testsmoney_testand the full consumer suites in CI
2 modules · 2 test files

One edit, applied everywhere at once, and consistency is guaranteed by construction — which is the correct outcome for a rule that must agree by definition. The cost is the audit: rounding appears in tax, in refunds, in proration and in reporting, and the compiler flags none of them because the signature did not change.

what it cost The shared version bought guaranteed consistency and paid for it with a change that has no compiler-checkable blast radius. Every consumer's expected values shift, so every consumer's test fixtures must be reviewed by someone who knows whether the new number is right — and that review cannot be delegated to the library owner, who does not know what a proration should be. It also created a queue: while this change is in flight, no other change to the package can ship, so three teams are blocked on one regulatory update. Both of those costs are worth paying here, because rounding is genuinely one piece of knowledge. Neither would be worth paying for the retry helpers (Duplicate Knowledge).

Four ways to handle code that two modules need

Extraction is not the only option and it is not the default one. The realistic alternatives each win in a different region, and the region is defined by two things: whether the knowledge must agree, and how fast it is still moving.

The migration column is the one that decides most real cases. Copying is trivially reversible, one broad package is nearly irreversible, and a decision this durable deserves more evidence than "these look similar" (Reversible and Irreversible Decisions).

Money, Result, retry helpers, HTTP wrapper — four possible homes
OptionSimplicityFlexibilityTestabilityMigration costOperationalNote
Leave it duplicatedEach module changes independently at the speed its team needs, and nothing is coupled. Correct while the code is still moving or while the copies genuinely differ. Fails for knowledge that must agree: three rounding rules is a defect with three owners.
One narrow package per conceptA money package, and nothing else in it. Fan-in is real but proportional, the concept has an owner, and a consumer that needs money does not acquire an HTTP client. The best answer for the Money case and the most work to set up.
One broad platform-core packageCheapest to create and the shape that accretes. Every module depends on everything in it, so every change is a whole-system event and the package acquires unrelated reasons to change. Very hard to unwind once fan-in is established.
One module owns it; others call its interfaceWorks when the concept clearly belongs to a domain — billing owning invoice numbering, say. Wrong for genuinely cross-cutting concepts like money, because it makes every module depend on billing and inverts the dependency direction (Dependency Direction).

caveat The scores assume the duplicated implementations currently agree, which is almost never checked and is frequently false — three Money types usually differ in at least one edge case, and consolidating without characterizing them first silently changes behaviour in two of the three. The columns also cannot express the decisive question, which is not a property of the code at all: whether the duplicated sites encode the same knowledge, meaning a change must apply to all of them together, or merely the same shape. Get that wrong and every column here is scoring the wrong decision (DRY: Knowledge, Not Lines).

How to build it

Most important first.

  • Extract only where the duplicated thing is the same knowledge — where a change must apply to all copies simultaneously. If a change would apply to one, it is not shared (DRY: Knowledge, Not Lines).
  • Extract after the third occurrence rather than the second, because two cases cannot distinguish a coincidence from a pattern (The Rule of Three).
  • Prefer several narrow libraries to one broad one. A module that needs Money should not acquire the HTTP client, and one package per concept keeps upgrade blast radius proportional (Do We Need a Package for This?).
  • Reconcile the differences explicitly before consolidating: write down how each implementation currently behaves, decide which behaviour wins, and treat the losers as migrations rather than as bugs (Designing the Migration).
  • Version it and mean it, with a deprecation path for every removal. Inside one repository this can be as light as a compatibility test suite, but "we all upgrade together" must be a decision rather than an assumption (Semantic Versioning).
  • Keep it dependency-free. A shared library that pulls in a framework has just made that framework a dependency of everything (Transitive Dependencies).

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
  • A bug fix in the shared library: one edit, and every module gets it — genuinely cheap, and the reason to have one at all.
  • A behaviour change: one edit plus an audit of every consumer, because the compiler will not tell you which of them depended on the old behaviour. That audit is the real cost and it is proportional to fan-in (Change Amplification).
  • Adding a consumer: free. Removing the library later: expensive and rarely attempted, which is why extraction should be treated as a fairly durable decision (Reversible and Irreversible Decisions).
  • Leaving it duplicated instead: each change costs N edits, but they are independent, individually safe, and each one can be made by the team that needs it without asking anyone. For a fast-moving area that is frequently the better trade (The Cost of Change).
What the recommended approach costs
  • Consistency across modules, bought with a coordination point that every module now queues behind.
  • One place to fix a bug, at the price of one place that cannot be changed quickly.
  • Fewer lines overall, and a dependency graph where everything points at one node — which is exactly the shape that makes large refactors hard (Afferent and Efferent Coupling).

What can go wrong

Failure modes
  • It becomes a bottleneck: every team needs a change, one team owns it, and the queue is the delivery constraint (Fan-in and Fan-out).
  • It accretes. Six months in it contains a date formatter, a feature-flag client and three domain helpers, and nobody can state what it is for (The Utility Dumping Ground).
  • A consumer needs behaviour the shared version does not provide, so a flag is added — and the library now encodes two behaviours with a boolean, which is where the divergence should have stayed (Boolean Flag Explosion).
  • The mitigation fails on its own terms: strict versioning is introduced, and modules pin old versions to avoid upgrade work, so the codebase now runs four versions of Money simultaneously — the duplication is back, with a release process on top (What Technical Debt Actually Is).
Dependencies, and their direction
  • Every module depends on it and it depends on nothing — that inversion is what makes a shared library safe, and violating it makes the library a cycle waiting to happen (Dependency Cycles).
  • Its transitive dependencies become everyone's. A logging library chosen inside it is chosen for the whole system, usually without a decision (Transitive Dependencies).
  • This is the internal-package sense of the term. The dynamic-linking sense — one binary artifact loaded by many processes at runtime — is a different mechanism with its own compatibility rules (Dynamic Linking), and its ABI-stability problem (ABI Stability) is the same fan-in argument enforced by a loader instead of a compiler.
Misreads
  • "Duplication is always worse than a shared library." A shared abstraction over things that merely look alike is more expensive than the duplication, because it is invisible and it silently couples unrelated decisions (DRY: Knowledge, Not Lines).
  • "It is internal, so we do not need versioning." Fan-in creates the same compatibility obligations as an external package. The only thing internality removes is the need for a registry (API Stability).
  • "Put it in the shared library so everyone benefits." That phrasing is how libraries accrete. The question is not who benefits but whether the thing is one concept with one owner (The Common Module).
  • "Shared library means dynamic linking." Here it means an internal package consumed at build time. The runtime-loaded shared object is a different mechanism, with binary compatibility as its central constraint (Dynamic Linking).
Smells this explains
  • shotgun-surgery
  • utility-dumping-ground

Testing it, and how it ages

What to test, and at which boundary
  • Test the library as a public contract with its own suite, because the consumers will not test its behaviour and will simply depend on it (Contract Tests).
  • Before consolidating, characterize each existing implementation so the differences are visible and the choice of winner is deliberate (Characterization Tests).
  • Run consumer test suites against the new version of the library in CI. In a monorepo this is cheap and it is what makes a shared library safe to change (Where a Test Must Be Real).
  • Assert the library imports nothing from any module, which is the check that keeps domain knowledge from leaking in (Stable Dependencies).
How this design ages
  • A healthy shared library gets narrower over time as concepts that did not belong are moved out. Growth in scope is the warning sign, not the success metric.
  • Fan-in rises monotonically, so the library becomes progressively harder to change. Plan for it to become effectively frozen and design the interface accordingly (Stability and Dependency Direction).
  • The usual end state for the misconceived parts is a fork: one module copies the code out to change it freely. That is often the correct repair rather than a failure, and pretending otherwise keeps a bad abstraction alive (Premature Abstraction).
  • Splitting one broad library into several narrow ones is the most common healthy refactor here, and it gets harder the longer it is deferred (Module Granularity).

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.

  • SCALE-SPECIFICAt one team a shared library is just a folder with a nicer name — everyone can change it, nobody queues, and versioning is overhead. At five teams it is a coordination point with an owner, a release process and a deprecation policy, and the same code needs an order of magnitude more process around it. The extraction that is obviously right at one scale is the bottleneck at another.
  • CONTESTEDThe strongest case against extraction: shared code creates coupling between teams that have no other relationship, and the coupling is worse than the duplication because it is invisible in both directions — a consumer cannot see who else depends on the behaviour it is about to change. Proponents of copying argue that three tuned implementations are cheaper in total than one contended one, and for fast-moving, thinly-shared code they are usually right. Where they are wrong is knowledge that must agree by definition: three rounding rules for money is not diversity, it is a reconciliation bug waiting for an auditor.
  • LANGUAGE-SPECIFICHow cheap a narrow library is depends on the ecosystem. Where packages are near-free and workspace tooling resolves them locally, several small libraries are practical. Where each package needs its own build, publish and version story, the fixed cost pushes teams toward one broad library — which is exactly the shape that accretes, so the tooling cost turns into a design outcome.

Where the depth lives

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

Domains that do not exist yet
  • Programming Languages & Runtime Internals — the same phrase names a different mechanism there: a binary loaded at runtime by many processes, where the fan-in problem reappears as binary compatibility and symbol resolution rather than as source-level API stability.
  • Testing & Reliability Engineering — running every consumer's suite against a candidate version of a shared package is the only practical substitute for the compiler when a change alters behaviour rather than signatures.