Three kinds of coupling a shared library creates
A shared library in a microservice system is code that several independently deployed services import: logging setup, auth middleware, HTTP clients, domain types. Teams reach for it to keep behaviour consistent and to avoid writing the same retry logic five times. Each import also creates coupling, and that coupling comes in three kinds.
Build-time coupling comes from shared types and transitive dependencies. If the library pins a serialization or HTTP package, every consumer inherits that pin and has to resolve conflicts against its own dependencies.
Deploy-time coupling appears when a library change only works if producers and consumers ship together. A changed wire format or a renamed enum value turns independent deploys into a coordinated release.
Semantic coupling is the most expensive. A shared Order or Customer model encodes one team's view of the concept, including which fields are required, what states exist, and what "cancelled" means. Other services then build logic on assumptions that were never theirs. Nothing fails at compile time. The cost shows up later as awkward workarounds and bugs whenever the owning team's view of the concept shifts.
| Coupling type | Cause | Symptom | Detection difficulty |
|---|---|---|---|
| Build-time | Shared types, pinned transitive dependencies | Version conflicts, failing builds | Low: the build fails |
| Deploy-time | Wire-format or protocol changes in the library | Lockstep releases, coordinated rollouts | Medium: shows up during releases |
| Semantic | Shared domain models | Workarounds, subtle cross-service bugs | High: no tool flags it |
For any shared library, ask two questions: what coupling does it create, and can each service upgrade it on its own schedule?
What to share and what to keep local
The most useful filter is one question: would two services ever legitimately need different behavior here? If the answer is no, a shared library removes duplication without adding meaningful coupling. If the answer is yes, or even "eventually, probably," sharing the code forces every consumer onto one team's interpretation.
| Candidate code | Risk | Rationale |
|---|---|---|
| Logging and tracing setup | Low | Trace context only works if every service propagates it the same way |
| Metrics naming conventions | Low | Dashboards and alerts depend on consistent labels |
| Auth token validation | Low | Security rules should not vary by team |
| Retry and timeout helpers | Low | Backoff and jitter are easy to get wrong in ways that hurt other services |
| Config loading | Low | Mechanics are generic, and values stay per-service |
| Domain entities (Order, Customer) | High | Each service sees a different slice of the entity, and those slices change at different rates |
| Business rules | High | Pricing or eligibility logic belongs to one owner |
| Invariant-bound validation | High | One service's "required" field is another's optional |
common / utils packages |
High | They grow by accretion until every service depends on everything |
Platform teams benefit most from sharing the low-risk items. The alternative is not independence but inconsistency. Hand-rolled tracing leaves gaps where a request disappears between hops. Hand-rolled auth produces uneven enforcement. Hand-rolled retries without jitter or budgets can turn one slow dependency into a retry storm.
The calculus shifts for small teams. With a few services in a monorepo, an atomic commit can change a shared model and every consumer together. Tighter coupling can be a reasonable trade for speed in that setup, provided the team revisits it before services start deploying on separate schedules.
Shared client SDKs and schema-first contracts
When a provider team publishes a client SDK for its service, that SDK tends to become the actual contract, not the HTTP or gRPC interface underneath. Consumers start relying on its retry behaviour, its exception types, and its convenience methods that combine several calls. The SDK also pulls in transitive dependencies, such as an HTTP client, a serialization library, or a logging framework, and those versions must be compatible with every consumer's own dependency tree. When the provider fixes a bug or bumps a dependency, consumers inherit that release on the provider's schedule. The wire protocol may not have changed at all, yet teams still coordinate upgrades.
A schema-first approach makes the published definition the source of truth. That definition is an OpenAPI document or a set of .proto files. Each consumer generates a client from it or writes a thin one by hand, using its own HTTP stack and its own error handling. Compatibility rules carry most of the decoupling. Changes are additive. Readers ignore fields they don't recognise. Removed fields have their identifiers retired permanently. In protobuf, that means marking the old field number reserved so it is never reused with a different meaning. Copying a small DTO into each consumer is usually cheaper than tying several release cycles together.
This approach has costs. Code generation adds build steps, and generator versions can drift between teams. The schema files need their own versioning and distribution. A schema can still be broken by renaming a field, changing its type, or tightening validation. Schema-first does not remove the need for contract discipline. It relocates that discipline to the schema review, where breaking-change checks can run automatically before merge.
// orders/v1/order.proto: the published contract, not a client SDK
syntax = "proto3";
package orders.v1;
message Order {
string order_id = 1;
string customer_id = 2;
// Field 3 (legacy_status, a free-text string) was removed.
// Reserving both the number and the name stops anyone from reusing
// them later with a different type or meaning, which would let old
// readers misinterpret new data.
reserved 3;
reserved "legacy_status";
int64 total_minor_units = 4; // amount in cents, avoids float rounding
string currency = 5; // ISO 4217 code, e.g. "EUR"
// Additive change: new fields get new, never-used numbers.
// Older consumers skip unknown field 6 when parsing.
// Newer consumers reading old messages see the default (UNSPECIFIED)
// and must handle it.
OrderStatus status = 6;
}
enum OrderStatus {
// The proto3 zero value is the default when the field is absent,
// so it should mean "not set" rather than a real business state.
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_PAID = 2;
ORDER_STATUS_SHIPPED = 3;
}
A protobuf message that adds a field under a new number and reserves the number and name of a removed field, so older and newer readers stay compatible without sharing a client library.
Worked example: refactoring a shared Order model
Suppose a common-models package defines an Order entity used by checkout, billing, and shipping. Billing asks to rename total to grandTotal and to split tax into its own field. That change only matters to billing, but because all three services compile against the same type, checkout and shipping must bump the library, rebuild, retest, and redeploy in lockstep. If they don't, a service on the old version serializes an Order that a service on the new version misreads.
BEFORE AFTER
checkout ─┐ checkout ──publishes──► OrderPlaced (schema)
billing ─┼──► common-models:Order │ │
shipping ─┘ (one type, one billing ◄──consumes──────┘ │
release train) shipping ◄──consumes──────────────┘
(each owns its local Order type)
In the refactor, each service defines its own Order with only the fields it needs. The one shared artifact is the OrderPlaced event schema, which follows additive-only rules: new fields are optional, and no field is removed or retyped without a new event version. Consumers map the event into their local type with a tolerant reader. A tolerant reader ignores fields it doesn't recognize and applies defaults for optional fields that are missing. Billing can then add taxAmount to the event without shipping noticing.
Illustrative estimate. The inputs below are assumed, not measured:
- 2 model changes per month
- 3 engineer-hours per service to bump, test, and deploy
- 1 hour of synchronization per change
Before the refactor, the coordination cost is 2 × (3 × 3 + 1) = 20 engineer-hours per month. After the refactor, assume half the changes stay service-local and the rest are additive schema changes that consumers can adopt later. Coordinated work then falls to roughly the schema review.
// shipping-service/src/order.ts
// Shipping owns its Order type: only the fields shipping actually uses.
export interface ShippingOrder {
orderId: string;
items: { sku: string; quantity: number }[];
destinationPostalCode: string;
expedited: boolean; // optional in the event; defaulted below
}
// Tolerant reader for the shared OrderPlaced event (schema v1, additive-only).
// Reads only the fields it needs, ignores unknown ones (e.g. billing's new
// taxAmount), and applies defaults for optional fields that are missing.
export function fromOrderPlaced(event: unknown): ShippingOrder {
const e = event as Record<string, any>;
// Required fields: fail loudly if the contract is broken.
if (typeof e.orderId !== "string" || !Array.isArray(e.items)) {
throw new Error("OrderPlaced missing required fields");
}
if (typeof e.shippingAddress?.postalCode !== "string") {
throw new Error("OrderPlaced missing shippingAddress.postalCode");
}
return {
orderId: e.orderId,
items: e.items.map((i: any) => ({
sku: String(i.sku),
quantity: Number(i.quantity ?? 1), // default if absent
})),
destinationPostalCode: e.shippingAddress.postalCode,
expedited: e.expedited ?? false, // optional field added later; default keeps old events valid
};
// No reference to total, grandTotal or taxAmount: billing can change them freely.
}
Shipping maps the shared OrderPlaced event into its own local Order type with a tolerant reader. It ignores unknown fields such as billing's new taxAmount and defaults optional fields that are missing.
Dependency drift and versioning internal libraries
Even with that reduction, the remaining shared code will drift, and that is the normal state, not a failure. Services upgrade on their own schedules, so at any moment the fleet runs several versions of the library. Drift becomes painful in two cases. The first is transitive conflict: your library and another dependency need incompatible versions of the same HTTP or serialization package. The second is a security fix, which turns into an upgrade campaign across every service at once.
Make drift visible
You can't manage drift you can't see. Build an inventory from lockfiles or build metadata, not from what teams report. A nightly job can parse each repository's lockfile and record the resolved version of every internal library. For one-off checks, npm ls <package>, go list -m all or mvn dependency:tree show what a service actually resolves.
Version with discipline
- Use honest semantic versioning. Removing a field in a minor release teaches consumers to stop trusting your version numbers.
- Prefer additive changes. Deprecate before you remove, and keep a published deprecation window.
- Keep the public API small. Don't leak third-party types, or their upgrades become your breaking changes.
- Keep the dependency footprint small. Use a thin core, with optional add-on modules for framework integrations.
- Publish a support policy. For example, support the current major version plus the previous one.
Trade-offs
Wide support windows cost the owning team effort and slow down evolution. Narrow windows force upgrade campaigns on consumers. Never breaking anything accumulates cruft, so budget for a periodic major version rather than avoiding it.
#!/usr/bin/env python3
"""Nightly drift inventory: resolved versions of internal libraries per service.
Reads npm lockfiles (lockfileVersion 2/3 'packages' map) from checked-out repos.
Usage: python3 -I inventory.py /srv/checkouts
"""
import json
import sys
from collections import defaultdict
from pathlib import Path
INTERNAL_SCOPE = "@acme/" # placeholder scope for internal packages
def internal_versions(lockfile: Path):
data = json.loads(lockfile.read_text())
for path, meta in data.get("packages", {}).items():
# Nested paths like node_modules/x/node_modules/@acme/y are transitive copies.
name = path.rsplit("node_modules/", 1)[-1]
if name.startswith(INTERNAL_SCOPE) and "version" in meta:
yield name, meta["version"]
def main(root: str) -> None:
# library -> version -> set of services resolving that version
inventory = defaultdict(lambda: defaultdict(set))
for lockfile in sorted(Path(root).glob("*/package-lock.json")):
service = lockfile.parent.name
for name, version in internal_versions(lockfile):
inventory[name][version].add(service)
# Several versions per library is normal; the point is to see the spread.
for name, versions in sorted(inventory.items()):
print(f"{name}: {len(versions)} version(s) in use")
for version, services in sorted(versions.items()):
print(f" {version:<12} {', '.join(sorted(services))}")
if __name__ == "__main__":
main(sys.argv[1])
A minimal inventory script that reads each service's package-lock.json and reports which resolved versions of internal libraries are in use and by which services, including transitive copies.
Alternatives to a shared library
A shared library is one way to reuse code, and often not the cheapest. Pick the alternative based on what is being shared: code, a starting point, a cross-cutting behaviour, or a runtime capability.
| Option | Best fit | Coupling created | Main cost |
|---|---|---|---|
| Copy-and-own | Small, stable utilities (retry helpers, ID formatting) | None after the copy | Fixes must be applied to every copy, and copies drift |
| Service template / scaffolding | Project layout, build config, observability wiring | Only at generation time | Template improvements do not reach existing services |
| Sidecar, proxy or gateway | mTLS, retries, rate limiting, auth checks | Runtime and deployment config | Extra hop latency, more components to operate and debug |
| Dedicated service | Logic that needs shared state or one source of truth (pricing, entitlements) | Network API contract | Availability dependency, ownership, capacity planning |
Duplication is not free. A security patch to copied code means finding every copy, and over a year each copy picks up local edits until no two match. Accept that cost only for code small enough to read in one sitting.
Polyglot environments change the trade-off. A library helps one language ecosystem, so a Go team and a Python team either maintain parallel implementations with subtly different behaviour or go without. Sidecars and gateways enforce the same behaviour regardless of language. That is why they suit concerns that must be consistent everywhere. Infrastructure options move complexity into operations. Each adds a component that can fail, a hop that adds latency, and configuration that application developers may not see when debugging.
Ownership, tooling, and the impact on engineering cost
A library without an owner is a coupling trap by default. Nobody triages bugs, nobody decides what counts as a breaking change, and every consumer quietly pins whatever version last worked. Give each library a named owning team. Back that team with a release process, a changelog that marks breaking changes explicitly, and compatibility tests that run the new build against the previous public API. Consumer-driven contract tests add a stronger guarantee. Each consuming service publishes the behavior it relies on, and the library's pipeline fails before release if any of those expectations break. Automated dependency update tooling on the consumer side opens upgrade pull requests continuously. That turns upgrades from quarterly campaigns into small, reviewable diffs.
Be careful about using the library as the enforcement point for standards. If every logging rule, retry policy and security check ships only through the library, the platform team becomes a bottleneck. Product teams with deadlines will then fork it or copy the parts they need. Mandates to "upgrade by Friday" without a migration guide, a deprecation window or automated fixes increase drift, because teams that can't upgrade safely simply don't.
The cost trade-off is uneven. You save time on standardized plumbing such as auth, tracing and serialization, and on not reinventing the same helpers in every repository. You lose time to coordinated releases, upgrade campaigns and supporting several major versions at once.
Use a shared library when:
- the code is stable and changes rarely,
- it is free of domain logic,
- a team will own it.
Avoid one when:
- the code encodes a business model,
- it changes on product timelines,
- nobody will commit to maintaining it.
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 microservices shared library coupling, 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.