What a shared library in a monorepo is, and why granularity and ownership are one decision
A shared library in a monorepo is an internal package: a directory of code with a declared build target and a public interface, which other services and tools in the same repository import directly from source rather than as a versioned artifact from a registry. There is no publish step and usually no version pin. Every consumer builds against whatever is at HEAD.
Teams extract these packages for three reasons. Reuse avoids writing the same retry wrapper or auth client five times. Consistent behavior means every service parses tenant IDs or emits trace headers the same way. Shared fixes let a security patch or bug fix land once and reach every consumer in the same commit. Backend engineers who consume internal packages and platform engineers who create them both depend on these benefits, and both pay when the library is badly shaped.
The thesis of this guide is that granularity and ownership are a single decision. Cut a library along a boundary that one team can own and change on its own schedule. If you cannot name that owner, the code is not ready to be a library.
The common/utils grab-bag shows what happens when this rule is ignored. A string formatter, an S3 helper and a date parser end up in one dependency closure. A change to any one of them invalidates builds and tests for every consumer, including services that never call it. Because the package belongs to everyone, no team treats its quality, review or deprecation as their job.
When to extract a library and when to keep duplicating
The default should be to copy code once. A second copy costs little, and it shows you which parts actually vary between callers. Extract only when a second or third real consumer needs the same behaviour. "Might need it later" doesn't count.
Extraction checklist
Extract only when all four hold:
- Stable interface. The signatures haven't changed across the last few edits in either copy. If they still churn, a library just spreads that churn to more call sites.
- At least two real consumers with the same requirements. If one caller needs flags or special cases to fit, it doesn't share the need.
- A named owning team that reviews changes and handles breakages.
- Its own tests. These should exercise the public surface directly, not only through one consumer's integration suite.
The trade-off
| Extract early | Duplicate early | |
|---|---|---|
| Cost to undo | High. Every consumer couples to the abstraction. | Low. Delete or merge the copies. |
| Main risk | A premature abstraction that accumulates parameters | Copies drift apart |
| Worst domain | Fast-moving product logic | Security and correctness code |
Drift matters most where behaviour must be identical. Examples are token validation, input sanitisation, money rounding and retry semantics on idempotent writes. In these areas, a fix applied to one copy and missed in another is a defect, so extract them sooner.
When to avoid a library entirely
Skip the library for:
- Thin wrappers over a standard library or framework call.
- Code tied to one service's domain model.
- Configuration constants that teams should set independently.
- Test fixtures that encode one service's assumptions.
In each case, sharing couples teams without removing any real duplication.
How to choose library granularity
File count is a poor proxy for library size. Two better signals are reason to change and dependency weight.
Split along reasons to change. If the retry policy and the money-formatting helpers sit in one common package, a change to either one re-tests and re-reviews consumers of both. Merge in the other direction too: if a schema type and its validator always change in the same commit, keeping them apart only adds a version-skew hazard and a second review.
Split along dependency weight when consumers differ. A pure data-types package with no transitive dependencies can be imported by a CLI, a batch job and a frontend build. Bundle those types with the code that pulls in a database driver or an HTTP client, and every consumer inherits that driver, its build time and its security patches.
| Dimension | Fine-grained | Coarse |
|---|---|---|
| Rebuild scope | Narrow | Wide |
| Coupling | Explicit, per edge | Hidden inside the package |
| Discoverability | Harder; many names to search | Easier; one obvious place |
| Build configuration | Many targets to maintain | Few targets |
| Review routing | Precise owners per package | Broad, often noisy |
| Cognitive load | High at graph level | High within the package |
Fine-grained has a failure mode of its own. Hundreds of tiny packages produce a dependency graph that nobody can hold in their head. Cycles then get "fixed" with extra shim packages, and engineers stop knowing where new code belongs. Split when consumers or change patterns actually diverge, not in advance.
How it works: public surface, layering and dependency direction
Mechanical enforcement starts with the public entry point. Each library should expose a small surface, and everything behind it should be unreachable rather than just marked private by convention. If an internal type can be imported, someone will import it, and from then on you can't change it freely. Everything public is a contract.
Tools that make internals unreachable
- Go: code under an
internal/directory can only be imported from inside the tree rooted at that directory's parent. - TypeScript and Node: the
exportsfield inpackage.jsonlists the only subpaths consumers may import, for example"exports": { ".": "./dist/index.js" }. Deep imports intosrc/then fail to resolve. - Bazel:
visibility = ["//payments:__subpackages__"]on a target turns any outside dependency into a build error.
Layering and allowed edges
Dependencies flow one way: apps → domain libraries → foundation libraries. Apps may also depend directly on foundation libraries. A domain library never depends on an app, never depends on another domain's library, and foundation never depends on anything above it. A downward-only graph has no cycles, and you can tell a library's blast radius from the layer it sits in.
Checking it in CI
Run one CI job that builds everything, which surfaces visibility errors. The same job then walks the dependency graph (go list -deps, bazel query 'deps(...)') and fails if it finds a cycle, an upward edge or a cross-domain edge. Go already rejects import cycles at compile time, but it says nothing about layers.
The friction this creates
Teams will file exception requests. Some will copy code to get around a boundary. Allowlist entries get added "temporarily" and are never removed. Give every exception an owner and an expiry date, and make CI fail once the date has passed.
#!/usr/bin/env bash
# CI boundary check for a Go monorepo laid out as:
# apps/<app>/... domains/<domain>/... foundation/<lib>/...
# Allowed: apps -> same app|domains|foundation; domains/X -> domains/X|foundation;
# foundation -> foundation. Every other edge fails the build.
set -euo pipefail
MOD="example.com/mono"
ALLOWLIST="ci/boundary-allowlist.txt" # "from to" lines; each needs an owner and expiry in review
# Building first surfaces internal/ violations and import cycles (both compile errors).
go build ./...
layer() { # print "<layer> <group>" for an import path inside the module
local rel="${1#"$MOD"/}" rest
case "$rel" in
apps/*) rest="${rel#apps/}"; echo "apps ${rest%%/*}" ;;
domains/*) rest="${rel#domains/}"; echo "domains ${rest%%/*}" ;;
foundation/*) echo "foundation -" ;;
*) echo "other -" ;;
esac
}
fail=0
while read -r pkg imports; do
read -r from_layer from_grp <<<"$(layer "$pkg")"
for imp in $imports; do
[[ "$imp" == "$MOD"/* ]] || continue # ignore stdlib and third-party
read -r to_layer to_grp <<<"$(layer "$imp")"
ok=1
case "$from_layer:$to_layer" in
domains:apps|foundation:apps|foundation:domains) ok=0 ;; # upward edge
apps:apps|domains:domains) [[ "$from_grp" == "$to_grp" ]] || ok=0 ;; # sideways edge
esac
if (( ! ok )) && ! grep -qxF "$pkg $imp" "$ALLOWLIST" 2>/dev/null; then
echo "boundary violation: $pkg -> $imp"; fail=1
fi
done
done < <(go list -f '{{.ImportPath}} {{join .Imports " "}}' ./...)
exit "$fail"
A CI script that runs go build to catch internal/ and import-cycle errors, then walks go list output and fails on any upward or cross-domain edge that isn't on an explicit allowlist.
Implementing enforceable ownership
Ownership only holds if the review system enforces it. In CODEOWNERS, map each library directory to exactly one team handle, such as /libs/auth/ @acme/identity, and never to a list of individuals. Individuals change teams and go on leave; a team handle survives both. Enable required code-owner review on protected branches so the rule blocks merges instead of only suggesting reviewers. In the common implementations the last matching pattern wins, so put broad defaults first and specific library paths below them.
CODEOWNERS answers "who reviews," not "who gets paged." Put a small metadata file in each library's root, e.g. OWNERS.yaml, with the owning team, an escalation channel, an on-call rotation, a lifecycle state (active, deprecated) and a last_reviewed date. Keeping it next to the code means it moves with the code during refactors. CI can also check that every library has one.
Platform-owned libraries need explicit service levels, such as first review response within one business day and a named response time for breakages at HEAD. Without them, these libraries end up below product work in every planning cycle and slowly starve.
Watch for three failure modes:
- Bottlenecks on cross-cutting changes. A repo-wide rename can need sign-off from dozens of teams. Allow a small, audited group to approve mechanical, tool-generated changes.
- Rubber-stamp reviews. Overloaded owners approve without reading. Track review latency and load per team, and treat sustained overload as a staffing problem.
- Stale metadata after reorgs. Teams get renamed or dissolved and handles stop resolving. Fail CI when an owner handle is empty or
last_reviewedis older than a set threshold.
# --- .github/CODEOWNERS ---
# Last matching pattern wins, so put broad defaults first.
# Each library maps to exactly one team handle, never to individuals.
* @acme/platform-core
/libs/auth/ @acme/identity
/libs/payments-client/ @acme/payments
/libs/logging/ @acme/platform-observability
# Ownership metadata changes go to the platform team, not the library team,
# so a team can't quietly reassign or drop its own ownership.
/libs/**/OWNERS.yaml @acme/platform-core
# --- libs/auth/OWNERS.yaml ---
# Answers "who gets paged" and lives next to the code.
team: "@acme/identity" # must match the CODEOWNERS handle
escalation_channel: "#identity-oncall"
oncall_rotation: identity-primary # name of the rotation in your paging tool
lifecycle: active # active | deprecated
service_levels: # required for platform-owned libraries
first_review_response: 1 business day
head_breakage_response: 4 hours
last_reviewed: 2026-09-30 # CI fails if older than the agreed threshold
A CODEOWNERS file that assigns one team to each library directory, next to an example OWNERS.yaml with the owning team, escalation path, service levels and review date that CI can check for staleness.
Changing libraries at HEAD: migrations, testing and lifecycle
With enforceable ownership, the owner also takes on the main duty of developing at HEAD: no caller can stay on an older version. Every change reaches every consumer at once, so the owner has to keep all of them green.
Make breaking changes in three steps: expand, migrate, contract. First, add the new API next to the old one. Next, move the callers over. Do this with a scripted or codemod rewrite, split into one change per owning team so each team reviews only its own diff. Finally, delete the old API when no callers are left. Mark the old symbol as deprecated, point to its replacement and give it a removal date, as in the example below. The date turns a deprecation note into a deadline. A lint rule that counts the remaining callers shows how far the migration has got.
Testing is split three ways:
| Test layer | Owned by | What it catches |
|---|---|---|
| Unit and contract tests on the public API | Library owner | Behaviour changes the owner promised not to make |
| Integration tests | Each consumer | Wrong usage and assumptions specific to one service |
| Affected-target CI before merge | Build system, run on the owner's change | Breakage in consumers that the owner didn't expect |
CI can only select targets through dependencies that are declared in the build graph. Runtime config, reflection, generated code and shared test fixtures add dependencies the graph doesn't show. When that happens, the affected consumers' tests are skipped and the breakage only appears after merge. Either declare these edges or keep these patterns out of library code.
Signs that a library's shape or ownership should change:
- Split it when consumers that have nothing to do with each other keep rebuilding because of each other's changes.
- Merge two libraries when they always change together.
- Hand it off when most commits come from another team.
- Delete it when no callers are left.
- Inline it back into its last consumer when only one remains.
// libs/money/src/format.ts
// Expand step: the new API ships next to the old one, and the old one
// becomes a thin wrapper so both paths run the same logic.
export interface Money {
amountMinor: number; // integer minor units, e.g. cents
currency: string; // ISO 4217 code, e.g. "EUR"
}
/** Formats an amount using the caller's locale and currency. */
export function formatMoney(money: Money, locale: string): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: money.currency,
}).format(money.amountMinor / 100);
}
/**
* @deprecated Use formatMoney({ amountMinor, currency: "USD" }, "en-US").
* Removal date: 2027-03-31. Owner: payments-platform team.
* Callers are being migrated by codemod, one change per owning team.
* After the last caller is gone, this function is deleted (contract step).
*/
export function formatPrice(cents: number): string {
// Delegate so the old and new paths cannot drift apart during the migration.
return formatMoney({ amountMinor: cents, currency: "USD" }, "en-US");
}
// Contract test owned by the library: both APIs must agree until removal.
// import { formatMoney, formatPrice } from "./format";
// expect(formatPrice(1999)).toBe(formatMoney({ amountMinor: 1999, currency: "USD" }, "en-US"));
A deprecated function kept as a wrapper around its replacement during the expand/migrate phase, with a JSDoc @deprecated tag that names the replacement and gives a removal date, plus a contract test that keeps both paths consistent.
Impact on cost and engineering time, with alternatives
Illustrative estimate (assumed numbers, not a benchmark)
Assume one shared package is used by all 40 services. After the split, 40 services depend on the types package, 15 on the client package and 30 on the observability package. Assume 50% of changes touch the client package, 30% touch observability and 20% touch types.
Expected services rebuilt per change = Σ (share of changes to package × consumers of package)
= 0.5 × 15 + 0.3 × 30 + 0.2 × 40 = 24.5 services, compared with 40 before. That is about 39% fewer affected targets. Replace the inputs with your own dependency graph and commit history.
The time savings come from three places. CI runs fewer test targets. Each review goes to one owning team instead of a broad group. A change to the client package can no longer break a service that only imports types, so cross-team regressions drop.
The split also adds costs. Each package needs its own build configuration and visibility rules. Library owners have to carry out migrations across every consumer. Owners also tend to add options and escape hatches to avoid that migration work later, and this produces wide, defensive APIs that are harder to change.
| Alternative | Gains | Costs |
|---|---|---|
| Deliberate duplication | Each copy changes independently | Fixes have to be applied to every copy |
| Versioned internal packages in a registry | Consumers upgrade on their own schedule | Version skew and a long tail of old versions |
| Service boundary | Behavior can be deployed independently | Network latency, more failure modes, operational overhead |
| Codegen from shared schemas | One contract, many languages | Generator upkeep and less readable output |
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monorepo shared libraries, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- The dependency graph answers "what does this touch?" in seconds instead of a manual audit.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- You can size a change's blast radius before merging, avoiding surprise cross-team firefighting.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.