Code reuse vs. runtime coupling: what a shared library actually couples
A shared library in a microservices fleet is an internal package that many services import: logging setup, auth helpers, HTTP clients, domain types, validation. Teams build one to stop rewriting the same code, to fix a bug once instead of in twenty repositories, and to keep behavior consistent across the fleet.
Reuse alone is cheap. Services can be pinned at different library versions without trouble, because each one runs whatever it was built with and nobody else notices. The cost appears when correctness depends on every service running the same version. At that point the library is no longer just shared code. It is a shared runtime contract that happens to be delivered as a package. Common triggers:
- Serialization formats. A changed field encoding or enum mapping means an old consumer misreads a new producer's messages.
- Auth token parsing. If one service starts issuing tokens with a new claim layout, every verifier on an older version rejects them or accepts them incorrectly.
- Shared schema or validation rules. One service accepts a record that another, still on old rules, refuses to process.
- Retry or timeout policies that peers depend on. A caller that retries more aggressively can overload a callee whose capacity assumed the old behavior.
You can usually see this coupling in your delivery process before it causes an outage:
- Releases need a documented deploy order.
- A single feature produces change tickets across many repositories.
- A service cannot roll back on its own because a peer has already moved to a newer library or wire format.
(a) Code reuse: pinned versions differ, and that is fine
+-----------+ +-----------+ +-----------+
| Service A | | Service B | | Service C |
| lib v1.2 | | lib v1.4 | | lib v2.0 |
+-----------+ +-----------+ +-----------+
| | |
v v v
+-------------------------------------------+
| shared-lib, imported at build time |
+-------------------------------------------+
each service runs what it was built with; peers never notice
(b) Runtime coupling: correctness needs every peer on the same version
+--------------+ +--------------+
| Service A |--- serialized msgs ---->| Service B |
| lib v2.0 |--- auth tokens -------->| lib v1.4 |
| (producer) |--- schema/validation -->| (consumer) |
| |--- retries/timeouts --->| |
+--------------+ +--------------+
B misreads, rejects,
or is overloaded
+------------------------------------------------+
| Lockstep signals already in your process: |
| - releases need a documented deploy order |
| - one feature -> change tickets in many repos |
| - B cannot roll back alone once A moved to v2 |
+------------------------------------------------+
Services can safely run different versions of a shared library when it only supplies code, but once the library defines wire formats, tokens, validation rules or retry policies that peers depend on, mismatched versions break correctness and force lockstep deploys.
What belongs in a shared library: a risk classification
The useful test for anything in an internal SDK is what happens when two services run different versions of it at the same time. If nothing observable changes at the boundary, the code is safe to share. If behaviour diverges, the library is carrying coupling.
| Risk | Typical contents | Why |
|---|---|---|
| Low | Logging, metrics, tracing setup, HTTP client wiring (timeouts, retries, header propagation) | Effects stay inside the process. Version skew changes how a service reports, not what it agrees to with peers. |
| Medium | Generated API clients, contract definitions | They encode a wire agreement. They are safe only while the schema evolves compatibly and the generator is not tied to one framework version. |
| High | Shared domain models, business rules, validation logic | They spread one team's model into every consumer. A rule change becomes a fleet-wide behaviour change that ships only as fast as the slowest upgrader. |
A shared library is the right call when the code is cross-cutting, changes slowly, and has a dedicated owner, usually a platform team, who treats it as a product with a changelog and a deprecation policy. Avoid one when the main motivation is avoiding duplicated domain logic. That duplication is usually a sign that two services actually model the concept differently.
Going thin has a cost of its own. A library that only exposes primitives pushes assembly back onto each service. Teams then wire tracing differently, map errors to status codes in their own ways, and attach auth tokens inconsistently. The result is a fleet where traces break at certain hops and incident responders have to learn each service's conventions. Deciding what the library should own is a design decision in its own right.
Wire compatibility vs. library compatibility
A shared client library can only be upgraded on each service's own schedule if the bytes on the wire tolerate mixed versions. Library compatibility asks whether your code still compiles against the new release. Wire compatibility asks whether a message written by version N can be read by version N−1, and the reverse. Only wire compatibility lets two services ship independently, because at any moment during a rollout both versions are live and exchanging traffic.
Three rules make that work. First, changes are additive only: new fields get new identifiers, and existing fields keep their type and meaning. Second, readers are tolerant. A consumer that gets a field it doesn't recognize skips it instead of rejecting the message. Protobuf parsers do this by default. JSON deserializers often need to be configured to ignore unknown properties, and a strict default there quietly brings back lockstep deploys. Third, identifiers are never reused. In Protobuf the field number is what goes on the wire, not the name. If you delete field 4 and later assign 4 to a new field with a different meaning, an older producer's bytes will be misread without any error. Marking retired numbers and names as reserved makes the compiler refuse that reuse.
These rules have a cost. Fields are never really removed. They pile up as deprecated or reserved entries, and the schema stops telling you which data a consumer actually needs. Answering that takes tracing real reads across services, which is expensive work and easy to put off.
# Schema evolution (Protobuf), shown as comments:
# v1: message Order { string id = 1; int64 amount_cents = 2; string coupon = 3; }
# v2: message Order {
# reserved 3; // old wire number can never be reassigned
# reserved "coupon"; // old name can't come back with a new meaning
# string id = 1;
# int64 amount_cents = 2;
# string region = 4; // additive: new field, new number
# }
# Protobuf parsers skip unknown field numbers by default. JSON readers often
# don't, so the tolerant behavior has to be written in:
import json
from dataclasses import dataclass, fields
@dataclass
class Order:
id: str
amount_cents: int
region: str = "" # added in v2; the default keeps v1 payloads readable
KNOWN = {f.name for f in fields(Order)}
def read_order(raw: bytes) -> Order:
data = json.loads(raw)
# Order(**data) would raise TypeError on any unknown key. That strict
# default forces every consumer to upgrade before any producer can.
# Keeping only known keys lets newer producers ship first.
return Order(**{k: v for k, v in data.items() if k in KNOWN})
# A newer producer adds "priority"; this v2 reader drops it and carries on.
print(read_order(b'{"id": "ord-123", "amount_cents": 4999, "priority": "high"}'))
A Protobuf message that retires field 3 with reserved and adds field 4, alongside a Python JSON reader that discards unknown keys so newer producers don't break older consumers.
Versioning shared code without forcing lockstep
Library code has the same problem as schemas, and you can manage it with honest semantic versioning. Any change that can break a caller gets a major bump. That includes a renamed method, a narrowed input type, or a changed default timeout. A minor release that quietly changes behaviour teaches every team to pin exact versions, and then nobody upgrades. Keep the public surface small, because every exported symbol is a promise you must version. Mark a symbol deprecated, with a pointer to its replacement, at least one minor release before you remove it. Callers then see compiler or linter warnings before they see failures. Support the previous major for a stated window so teams can schedule the upgrade instead of dropping work to do it.
Version ranges in a manifest, such as "compatible with 3.x", only help if every resolver involved can honour them. The hard case is the diamond dependency. Service A depends on libraries B and C. B needs the shared library at 2.x and C needs 3.x.
| Resolution model | Diamond outcome | Typical examples |
|---|---|---|
| Multiple versions per process | B and C each load their own copy. The build works, but types from the two copies don't interoperate. | npm-style nested installs; Go, where each major version has its own import path |
| Single resolution | One version wins. Code in B or C fails at runtime, or the build fails outright. | JVM classpath, Python environments |
Supporting old majors reduces how often teams are forced to upgrade. The cost is backporting fixes to two or three branches. It can also leave services running stale versions with known vulnerabilities after the window closes. Treat the end of the support window as a hard deadline.
// Consumer side: package.json excerpt (shown as a comment because JSON has no comments)
// "dependencies": {
// "@acme/shared-http": "^3.2.0"
// }
// The caret range means >=3.2.0 <4.0.0. The service picks up minors and patches
// automatically but never crosses into 4.x, where breaking changes live.
// Library side: @acme/shared-http 3.4.0, src/index.ts
export interface RequestOptions {
timeoutMs?: number;
}
// Part of the public contract: changing this default is a breaking change
// and requires a major bump, even though no signature changes.
export const DEFAULT_TIMEOUT_MS = 5000;
// The replacement API. Callers should migrate to this during 3.x.
export function request(url: string, options: RequestOptions = {}): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
);
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer));
}
/**
* @deprecated Since 3.4.0. Use `request(url, { timeoutMs })` instead.
* Scheduled for removal in 4.0.0.
*/
// Still works in every 3.x release, so nothing breaks. The TypeScript language
// service and deprecation-aware lint rules flag call sites, which gives teams
// warnings before 4.0.0 turns them into failures.
export function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
return request(url, { timeoutMs });
}
A consumer pins a caret range to stay within major version 3, while the shared library marks an old function @deprecated in a 3.x minor release, points callers to its replacement, and removes it only in 4.0.0.
Upgrade mechanics at scale
Supported versions only matter if you can see who is running what. The rest of the fleet work is mechanics.
Automated update PRs. A dependency bot opens a pull request in every consumer repository when a new library version ships. This turns an upgrade into a reviewable diff instead of a migration project. The risk is volume. Twenty services multiplied by weekly releases produces a queue that teams learn to ignore or rubber-stamp. A merged bump that changes retry defaults or serialization behavior then reaches production with no human ever reading the changelog. To limit this:
- Batch patch releases together.
- Require a passing contract suite before a PR can be merged.
- Never auto-merge minor or major bumps.
Contract tests that ship with the library. Publish the library's behavioral tests as an artifact that consumers run against their own configuration and wiring. The example CI job below pulls the suite for the version the consumer pins and runs it against the consumer's build. A failure blocks the upgrade PR in the repository that would actually break, not in the library's own pipeline.
A version inventory. The illustrative script below parses each service's lockfile and reports the resolved library version per service. Feed that report into a dashboard. The long tail becomes a named list with owners, and you can retire a version when its count reaches zero.
The monorepo alternative. A monorepo with a single-version policy removes version skew entirely: every service builds against the same commit. The coupling becomes explicit and permanent, though. Every library change must pass every consumer's tests before it merges, so you exchange drift for lockstep.
#!/usr/bin/env python3
# Illustrative inventory: report which version of a shared library each service resolves.
# Assumes one checkout per service under a root directory, each with an npm package-lock.json.
# Usage: python3 -I lib_inventory.py /path/to/checkouts
import json
import sys
from collections import defaultdict
from pathlib import Path
LIB = "@example/platform-client" # placeholder name for the shared library
def resolved_version(lock_path: Path) -> str:
data = json.loads(lock_path.read_text())
# lockfileVersion 2/3 keys installed packages by their node_modules path
entry = data.get("packages", {}).get(f"node_modules/{LIB}")
if entry is None:
# lockfileVersion 1 keeps a top-level "dependencies" map instead
entry = data.get("dependencies", {}).get(LIB)
return entry.get("version", "unknown") if entry else "not-installed"
def main(root: Path) -> None:
by_version = defaultdict(list)
for lock in sorted(root.glob("*/package-lock.json")):
by_version[resolved_version(lock)].append(lock.parent.name)
# One CSV row per service, so a dashboard can group by version and join on owners
print("service,version")
for version, services in sorted(by_version.items()):
for service in services:
print(f"{service},{version}")
# Summary to stderr: a version is safe to retire when it no longer appears here
for version, services in sorted(by_version.items()):
print(f"{version}: {len(services)} service(s)", file=sys.stderr)
if __name__ == "__main__":
main(Path(sys.argv[1]))
An illustrative script that reads each service's package-lock.json and reports the resolved shared-library version per service, with a per-version count to show when an old version can be retired.
Alternatives to a shared library and what each one moves
Each alternative to a shared library relocates the coupling rather than eliminating it. The useful question is where you would rather pay for it.
Copying small utilities on purpose. A retry helper or ID formatter pasted into each service has no deploy coupling: nobody waits on anybody. The cost moves into maintenance. Copies drift as teams tweak them locally, and a bug fix has to be found and applied in every repository that holds a copy. This works for code that is small, stable and unlikely to carry security fixes.
Generating clients from an API definition. Each consumer generates its own client from a shared schema, so the contract is the coupling point rather than a library binary. Consumers now depend on the generator, including its naming conventions, nullability handling and runtime dependencies. Regenerating after a generator upgrade can change method signatures and break callers even when the API itself has not changed.
Moving behavior into a sidecar or gateway. Authentication, retries or rate limiting run outside the service process, so services stop compiling against that logic. The coupling becomes a runtime one: an extra network hop, a new component that can fail or saturate, and a rollout of its own that has to stay compatible with every service it fronts.
Running it as a separate service. This is the same trade as the sidecar, made more strongly. You get one deployable owner and one implementation. In exchange, every caller takes on latency, a dependency on that service's availability, and versioning of its API.
| Alternative | Coupling removed | Coupling introduced |
|---|---|---|
| Deliberate copying | Deploy and version coupling | Drift; fixes applied in N places |
| Generated clients | Shared library binary | Generator version and quirks |
| Sidecar / gateway | Build-time dependency | Network hop, failure point, rollout coordination |
| Separate service | In-process code sharing | Availability, latency, API versioning |
Ownership, governance and the impact on cost and engineering time
Treat the library as a product with exactly one owning team. Shared ownership usually means nobody triages bugs and nobody can refuse a feature. The owners publish a changelog written for consumers. Each entry states what changed in behavior, whether the change breaks callers, and what a service must do to upgrade. Commit subjects alone don't answer those questions. Other teams contribute through a documented path: an issue first, then a pull request reviewed by the owners. Write down what the library will not accept. Business rules, domain models, and code that only one service needs belong in that service. Each exception widens the set of services that have to move together.
The cost case cuts both ways. A well-scoped library removes duplicated plumbing, such as auth, telemetry and retries. It also lets one patch close a security hole everywhere. A poorly scoped library turns every release into a coordinated multi-repo change.
Illustrative estimate (assumed numbers, not a benchmark)
Assumed inputs:
- 30 services
- 3 hours to upgrade one service
- 12 library releases per year
- 8 hours of cross-team coordination for each lockstep release
- Under independent upgrades, each service averages 4 upgrades per year, including mandatory security patches
Lockstep cost = services × hours × releases + coordination × releases = 30 × 3 × 12 + 8 × 12 = 1,176 hours/year
Independent cost = services × hours × upgrades per service = 30 × 3 × 4 = 360 hours/year
Replace these inputs with your own numbers. The gap grows with release frequency, because lockstep charges every service for every release.
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 shared library microservices, 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.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
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.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- 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.