Do We Need a Package for This?
Four questions decide it: how much it does for you, how hard it would be to replace, what it drags in, and who maintains it.
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.
Should this be a dependency, code we write, or code we copy — and how do I answer that in under ten minutes?
A feature needs retries with exponential backoff and jitter. There is a well-known package that does it. There is also a nineteen-line version of it on the whiteboard.
It is on the registry, it has stars, it does what we need. Adding it takes a minute and writing it takes an afternoon, so adding it is obviously cheaper.
It compares the wrong two numbers. The comparison is not "a minute versus an afternoon" but "a minute plus perpetual upgrade obligation versus an afternoon plus nineteen lines you can read".
- It compares the wrong two numbers. The comparison is not "a minute versus an afternoon" but "a minute plus perpetual upgrade obligation versus an afternoon plus nineteen lines you can read".
- It ignores replaceability, which is the number that actually hurts. A package that ends up in forty files is expensive to leave regardless of how good it was.
- It ignores what arrives with it — the graph, the licence, the review process, the scanner findings (Transitive Dependencies).
- And it ignores who is behind it. Two packages with identical features and different maintenance stories are different decisions, and stars measure neither.
- The mirror-image failure is just as real: teams that refuse packages end up hand-rolling date arithmetic, retries with no jitter, and their own crypto — three afternoons that become three years of bugs (Build, Library, SaaS or Managed Service).
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.
- The team is eight engineers with no dedicated platform group; anything written here is maintained by whoever is on the rota.
- The retry policy will be tuned repeatedly as we learn which downstreams are flaky — this is code that will be edited, not written once.
- The organisation already requires a licence review and a security scan for new packages, so adding one has a fixed process cost measured in days, not minutes.
- Whatever is chosen, a retried operation must remain safe to repeat. That is a property of our code and no library provides it (Idempotency by Design).
- The decision must be recorded well enough that in a year someone can tell whether it still holds (Decision Records).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Whoever proposes the package owns answering the four questions in the pull request description, in about three sentences.
- Whoever reviews owns the counterfactual — what we would write instead, and honestly how long it would take.
- If the answer is "we write it", the team owns it like any other module: it gets a home, an owner, tests and a name (The Common Module is what happens when it does not).
- The decision boundary is between *mechanism* and *policy*. Mechanism — parsing, encoding, transport, cryptography, date arithmetic — is where libraries reliably win, because the specification is external and getting it right is expensive.
- Policy — when to retry, how to price, what counts as valid for us — is where they reliably lose, because the library encodes someone else's decisions and you will spend your time configuring your way out of them (What a Framework Charges).
- A package that mixes the two is the hardest case, and the usual resolution is to take the mechanism and keep the policy in your own code.
Four questions, four answers
The debate usually collapses into "add it" versus "write it", which hides the two options that resolve most real cases. Wrapping and vendoring exist, and they are the right answer often enough to be on the table by default.
What decides between them is not how good the package is. It is how much of your codebase would end up knowing about it, and how much you would have to write if it disappeared.
How much does it do for us, how hard would it be to replace, what does it drag in, and who maintains it?
when It implements an external specification, it is called from few places, its graph is modest, and there is an organisation or an active maintainer behind it. Date arithmetic, TLS, a database driver, a JSON schema validator.
cost A permanent upgrade obligation and a scanner surface. Accept it, pin it, and put it on the quarterly read.
when It is volatile, or its vocabulary would otherwise reach into many files. A payment SDK, an HTTP client, a feature-flag service, an ORM.
cost Indirection forever plus a lowest-common-denominator API — and the first caller who needs an advanced feature will test your resolve (Anti-Corruption Layer).
when It is small, it is policy rather than mechanism, it has no specification behind it, and you will tune it repeatedly. Retry policy, feature-flag evaluation for three flags, a slug generator.
cost You own the edge cases, including the ones you have not thought of, and the code will grow. Give it a real home and a real test, or it becomes an unowned internal library.
when It is tiny, stable, unmaintained but correct, and you need it to keep working for a decade. A hundred lines that have not changed in five years.
cost You lose the advisory feed and the upstream fixes together, and the copy will diverge the first time somebody patches it locally. Record where it came from and at which commit.
when The requirement dissolves under two minutes of thought — you needed one date format, not a date library.
cost None, and this option is skipped more often than any of the others, because the package is already in the pull request by the time anyone asks.
The trade, scored
It is worth scoring the options once for a real case, if only to make visible that "depend directly" is not the winner on every axis and that "write it" is not the safe choice people assume.
The migration column is the one that decides most regretted decisions. It scores the exit, and the exit is what nobody prices in the pull request.
| Option | Simplicity | Flexibility | Testability | Operational | Migration cost | Note |
|---|---|---|---|---|---|---|
| Depend directly, imported at each call site | Fastest to working code and the honest default for most packages. Migration scores 1 because the library's policy objects end up in every service that retries, and leaving means finding all of them. | |||||
| Depend, behind our own `retry()` | One import site, our vocabulary everywhere else. Costs a module that does almost nothing, which reviewers will reasonably question until the day it earns its keep. | |||||
| Write the nineteen lines | Every future change is minutes and nobody is consulted. The score hides that the nineteen lines become sixty when you add budgets, circuit breaking and per-endpoint policy — at which point the first two rows look better. | |||||
| Vendor the library source | Right for tiny abandoned-but-correct code and wrong here: this library is actively maintained, so vendoring trades away exactly the thing you are paying for. |
caveat These scores are for one team, one requirement and one moment. They move hardest on two things the axes cannot express. First, team size: at eighty engineers "write it" scores far worse on operational and migration, because six teams write it six times. Second, whether the requirement is really this small — the whole case for the third row rests on nineteen lines staying nineteen lines, and the most common way this decision goes wrong is that it does not. Nothing here is a measurement; the numbers exist to make the axes arguable in the same units.
The shape that means the decision was never made
There is a recognisable pattern in codebases where nobody asked the question: a package appears in one file for one narrow purpose, and eighteen months later its types are part of the domain vocabulary. No commit did this. Every individual step was reasonable.
It is worth naming as a smell rather than a rule, because the same shape is completely correct in the case where the package genuinely is the right model for the domain — and telling the two apart requires knowing whether anyone chose.
looks like A third-party type appears in the parameters and return types of your own domain functions — a library's Money, Decimal, DateTime, Result or Schema object crossing module boundaries that have nothing to do with that library's job. Grep for the package name and it hits sixty files across every layer.
suggests The adoption decision was made once, narrowly, and then extended by drift. Nobody has priced the exit, and the exit is now the size of the codebase. It also means your modules are coupled to each other *through* the vendor type: change how you represent money and you are negotiating with a library's release schedule (Primitive Obsession with someone else's primitive).
fix Do not do a codebase-wide extraction. Define your own type at the boundary where it matters most — usually persistence or the public interface of one module — and convert at that edge. Then let the vendor type recede naturally as modules are touched. If the exit never becomes necessary, you have paid for one conversion function instead of a project (Incremental Migration).
How to build it
Most important first.
- Ask how much it does for you. A package that replaces twenty lines you could write and read is a different proposition from one that replaces two thousand you could not. Be honest that the specification, not the implementation, is often the expensive part.
- Ask how hard it would be to replace. Count the places its types and vocabulary would appear. One module is a reversible decision; every file is not (Reversible and Irreversible Decisions).
- Ask what it drags in — the graph, the licence, the peer constraints, the build-time weight, the runtime it forces you to stay compatible with.
- Ask who maintains it — release cadence, open issue age, bus factor of the project itself, and whether a company or a hobby is behind it. This is the question that predicts abandonment, and abandonment is the failure that actually arrives.
- Then choose one of four answers, not two: depend, wrap, write, or vendor. Most arguments about this are stuck because only the first and third are on the table.
- Write down the answer and the trigger that would reverse it. "We wrote our own; if we need retry budgets or per-endpoint circuit breaking, we adopt the library" is a decision. "We prefer not to add dependencies" is a mood (Revisit Triggers).
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.
- Writing it: the next tuning change is nineteen lines you own, in one file, with a test. Cost of the next change is minutes and nobody has to be consulted.
- Depending on it: the next tuning change is either a configuration option that exists — free — or one that does not, which is a fork, an upstream pull request, or a workaround, and which of those you get is not up to you.
- The asymmetry is the whole decision. Libraries make anticipated changes free and unanticipated changes expensive; your own nineteen lines make every change equally cheap and every *feature* equally expensive.
- Replacement cost compounds separately: if the library's vocabulary is in forty files, the next change might be cheap while the eventual exit is a project. Price both, because only one of them shows up in the pull request.
- Making this a deliberate ten-minute decision slows down a task that was four minutes, on a package that will usually be fine. Some of that time is genuinely wasted, and the return is concentrated in the few decisions that turn out badly.
- Writing your own means you own the bugs, including the ones the library fixed three years ago and you have never heard of. For anything with edge cases — timezones, unicode, floating point — that is a bad trade almost every time.
- Wrapping is the safe-looking middle and it has a specific cost: your wrapper is a lowest common denominator, and the first caller who needs the library's advanced feature either widens it or bypasses it, and both outcomes are worse than choosing.
What can go wrong
- The four questions are answered optimistically in the pull request and never revisited, so the record exists and is wrong — which is worse than no record because it stops the next conversation.
- The team writes its own, it works, and it slowly grows into an unversioned internal library with one author and no tests, at which point it is a dependency with all the drawbacks and none of the release notes (Bus Factor).
- A blanket policy is adopted — "no new dependencies without architecture review" — and engineers route around it by copying source, which converts a visible dependency into an invisible one.
- The package is adopted for one narrow use and its types spread. Nobody decided to make it the codebase's retry model; it just became one, and the decision to revisit never has an owner.
- Choosing "depend" couples you to a release schedule and a maintainer's attention; choosing "write" couples you to your own team's attention, which is scarcer than people assume but is at least visible in your planning.
- Choosing "wrap" adds a module you own that depends on a package you do not, which is the only option that gives you a place to stand during a migration (Boundary Adapters).
- Choosing "vendor" removes the graph and the advisory feed together; you now depend on someone noticing an upstream fix, and nobody will.
- "So prefer writing it ourselves." Only for small, policy-shaped, specification-free code. The moment there is a standard involved — TLS, JWT, timezones, CSV quoting rules — the library is almost certainly better than what you would write (Build, Library, SaaS or Managed Service).
- "Popularity is the maintenance signal." Popularity predicts that a fork will exist if it is abandoned. It does not predict that anyone is answering issues, and the two get conflated constantly.
- "Small packages are safe because they are small." Size predicts your ability to read it, not your exposure. A twelve-line package with a compromised maintainer account executes just as much code in your build as a large one (Transitive Dependencies).
- "This is a security question." It is partly, and Security Engineering owns that half. The half that lives here is design: how much of your codebase learns this package's vocabulary, which decides what leaving costs.
- utility-dumping-ground
- speculative-generality
Testing it, and how it ages
- If you write it, the test is small and belongs to you: backoff grows, jitter stays inside bounds, the cap is respected, cancellation is honoured.
- If you depend, test your *usage*, not the library — that your retry policy stops after the budget, and that a retried call is safe to repeat (What a Unit Is).
- For anything with a specification behind it — dates, currency, encoding — the fact that you cannot write a convincing test is itself evidence that you should not write the implementation.
- The nineteen lines you wrote grow. The honest version of "we will just write it" includes the second and third requirement, and by the fifth the library you rejected looks reasonable.
- Conversely the library shrinks in value as the runtime absorbs its job, and packages that were essential five years ago are now a built-in call (Revisit Triggers).
- Team size moves the answer more than anything technical: what is a sensible in-house utility at eight engineers is an unowned liability at eighty, and the same file has not changed at all.
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 eight engineers, an in-house nineteen-line retry helper is cheap and everybody knows where it lives. At eight hundred, the same file is duplicated in six services with three subtly different jitter implementations, and adopting one library is now the cheaper option precisely because it gives the six teams a shared vocabulary they did not have to negotiate. The technical merits are identical; the answer flips on coordination cost alone.
- DOMAIN-SPECIFICWhere the logic encodes a published specification — timezones, IBAN validation, tax rules, cryptography — the library wins even when it is large and even when you only need a fraction, because the expensive part is the specification and not the code. Where the logic encodes your own product's policy, the same reasoning inverts.
- CONTESTEDThe strongest opposing view is the "no dependencies" position held by parts of the Go and C communities: every dependency is an unbounded liability and a permanent coupling to strangers, so the correct default is to write it, and the apparent cost of doing so is repaid many times over in builds that keep working for a decade. It has genuinely produced some of the most durable software in existence. Its weakness is that it is usually argued by people with deep expertise in the thing they are re-implementing, and it generalises badly to a team that would be writing its first date library.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — the same four questions decide build-versus-buy for whole components (a queue, a search engine, an identity provider), where the replaceability answer is dominated by data gravity rather than by call sites.