The Shared Package Five Teams Were Afraid to Touch
A client's internal utility package started as a convenience. Two years later, it had 47 dependents, undocumented side effects, and a change to one date formatter broke invoice generation for three services.
The ticket said "date formatting bug in invoice exports." A customer was getting invoices with dates rendered as 2026-27-07 instead of 2026-07-27 — day and month swapped. Should be a twenty-minute fix. The developer who picked it up found the bug in about five minutes. She fixed it, pushed, and went to lunch.
By the time she got back, three Slack channels were on fire.
The innocent fix
The date formatting function lived in @acme/common, the company's internal shared package. Every backend service in the organization pulled it in. The function in question, formatDate, had a quirk: it used DD-MM-YYYY order by default, which was correct for the company's original European customer base. The invoice service had been calling it with an explicit MM-DD-YYYY format argument for US customers, but a separate reporting service was relying on the default. So was the notification service. And the audit log writer.
The fix was correct — she changed the default to ISO 8601 (YYYY-MM-DD), which is what the function's JSDoc already claimed it returned. But five other services had been depending on the old, wrong default. Within an hour of the deploy, reports were generating with mangled dates, notification templates looked broken, and the compliance team's audit exports were suddenly unparseable by their downstream tooling.
// Before: JSDoc said ISO 8601, implementation said DD-MM-YYYY
export function formatDate(date: Date, format = 'DD-MM-YYYY'): string {
// ...
}
// After: correct, but broke five consumers
export function formatDate(date: Date, format = 'YYYY-MM-DD'): string {
// ...
}One function signature change. Five broken services. Three teams scrambling on a Tuesday afternoon.
How the package got this way
I was already on-site for a broader architecture review when this happened, so I dug into the history. @acme/common started three years earlier as a small collection of TypeScript types and helper functions — the kind of thing every organization builds. Shared date utilities, string formatting, a few validation helpers. Maybe 400 lines of code.
By the time I looked at it, it was 11,000 lines across 94 modules. It contained database model definitions, API client wrappers for three different external services, a partial reimplementation of their authentication middleware, and a set of business rules for tax calculation that only one service actually used.
The package had 47 direct dependents in their monorepo. Every service imported at least something from it. The dependency graph looked less like a utility library and more like a second application hiding inside every service.
The real cost wasn't the date bug. It was velocity. I looked at the commit history and found that @acme/common had received exactly two meaningful updates in the previous four months. Both were followed by production incidents. The package had become so risky to change that teams had started copying functions out of it and into their own services — duplicating code rather than touching the shared source. One team had a local formatCurrency that was identical to the one in @acme/common except for a single rounding behavior they'd been afraid to fix upstream.
Warning
Breaking it apart
The fix wasn't clever. We split @acme/common into five focused packages: @acme/types for shared TypeScript interfaces, @acme/datetime for date and time utilities, @acme/validation for input validation, @acme/api-clients for external service wrappers, and @acme/tax for the tax calculation logic that only the billing service needed.
Each package got its own semver versioning and its own changelog. Services could pin to specific versions and upgrade on their own schedule. A breaking change in @acme/datetime no longer forced every team to coordinate a simultaneous upgrade.
The migration took about three weeks. Most of it was mechanical — updating import paths and adjusting package.json files. The hard part was the 12 modules that didn't fit neatly into any category because they mixed concerns. A userHelpers module that combined type definitions, validation logic, and database query builders had to be split across three packages. That kind of surgery took actual thought about which package owned which responsibility.
We also added a rule to their architecture decision records: no package in the @acme scope could exceed 2,000 lines without a review. Not a hard technical constraint — just a trigger for a conversation about whether the package was accumulating too many responsibilities.
The underlying mistake
Shared code feels free. You write a function once, import it everywhere, and you've avoided duplication. But every import is a coupling point. Every shared function is a contract with every consumer, whether you documented it that way or not.
The date formatting function's JSDoc was wrong for two years. Nobody noticed because nobody read it — they just relied on the runtime behavior. The actual behavior was the contract, and changing it was a breaking change regardless of what the documentation said.
I've seen this pattern at four different clients now. The shared package always starts small and useful. It grows because adding to it is easier than creating a new package. Nobody notices the coupling until a small change cascades into a multi-team incident.
The question I keep asking myself: is there a good heuristic for when a shared package has crossed the line from helpful to harmful? Lines of code is a crude proxy. Number of dependents is better but still imperfect. Maybe the real signal is simpler than that — if the last five people who thought about changing the package decided not to, it's already too late.