What belongs in a shared library (and what does not)

In a microservices system, a shared library is an internal package that several independently deployed services import at build time. Teams reach for one when every service needs the same cross-cutting behaviour and reimplementing it per service would produce subtle inconsistencies: one service propagates trace context, another drops it; one retries with jitter, another hammers a struggling dependency.

The test for a good candidate is whether the code encodes infrastructure policy or domain meaning. Infrastructure policy changes slowly and is the same across services. Domain meaning belongs to the service that owns the data, and sharing it as code couples deploy schedules.

Candidate Fit Why
Structured logging setup Good Uniform fields make logs queryable across services
Tracing and context propagation Good Broken propagation in one hop breaks the whole trace
Auth/token client Good Security fixes should land everywhere the same way
Config loading Good Consistent precedence rules reduce deploy-time surprises
Retry and timeout policy Good Sane defaults prevent retry storms
Shared domain models (e.g. Order) Bad A field change forces coordinated upgrades
Business rules (pricing, eligibility) Bad Logic drifts from the owning service's data and intent

Ownership matters as much as content. A platform or enabling team should own these internal packages, review changes, and publish releases. Product teams consume them as dependencies and should not have to track the library's main branch.

The framing for the rest of this article follows from that: treat the library as a published product. It has a narrow public surface, semantic versioning that is enforced and not just aspirational, and an explicit support window so consumers can upgrade on their own schedule.

Designing a small, explicit public surface

The first decision is where the boundary sits. Split every library into a public package that consumers import and an internal package they cannot. Use whatever the language enforces: Go's internal/ directories, Java module exports, or package visibility rules. Naming conventions alone are not enough, because if a consumer can reach a type, someone will depend on it, and refactoring it becomes a breaking change in practice even if the docs say otherwise.

Treat dependencies with the same suspicion. Every transitive dependency the library pulls in becomes a version constraint on every service that uses it. Two services pinned to incompatible versions of the same HTTP client can no longer share your library at all. Keep the dependency list short, and prefer the standard library where it is adequate.

The most expensive leak is a third-party type in a public signature. If your function returns a client library's request object or accepts a logging framework's logger, that dependency's breaking changes become your breaking changes. Wrap them in types you own, even if the wrapper is thin, so you can swap or upgrade the dependency behind a stable interface.

Thin versus thick

A thin library, such as a few helpers and shared types, rarely breaks. The cost is that each team reimplements retries, auth, and telemetry slightly differently. A thick platform library gives consistency, but it couples every service to one release train and changes often. Most teams do well with a thin core plus optional, separately versioned modules.

// module-info.java: only the API package is exported.
module com.example.platform.http {
    exports com.example.platform.http;   // public surface consumers compile against
    // com.example.platform.http.internal is deliberately NOT exported
    requires java.net.http;              // JDK client, so consumers inherit no third-party version constraint
}

// com/example/platform/http/ServiceResponse.java
// A type we own. Consumers never see java.net.http.HttpResponse,
// so we can change the underlying client without breaking them.
package com.example.platform.http;
public record ServiceResponse(int status, String body) {}

// com/example/platform/http/ServiceClient.java
package com.example.platform.http;
import java.io.IOException;
import java.net.URI;
public interface ServiceClient {
    ServiceResponse get(String path) throws IOException, InterruptedException;

    static ServiceClient create(URI baseUri) {
        return new com.example.platform.http.internal.JdkServiceClient(baseUri);
    }
}

// com/example/platform/http/internal/JdkServiceClient.java
// Public for use inside the module, but unreachable from other modules.
package com.example.platform.http.internal;
import com.example.platform.http.*;
import java.io.IOException;
import java.net.URI;
import java.net.http.*;
public final class JdkServiceClient implements ServiceClient {
    private final HttpClient http = HttpClient.newHttpClient();
    private final URI base;
    public JdkServiceClient(URI base) { this.base = base; }
    public ServiceResponse get(String path) throws IOException, InterruptedException {
        HttpResponse<String> r = http.send(HttpRequest.newBuilder(base.resolve(path)).build(),
                HttpResponse.BodyHandlers.ofString());
        return new ServiceResponse(r.statusCode(), r.body()); // translate to our own type at the boundary
    }
}

A Java module that exports only its API package, keeps the implementation in an unexported internal package, and wraps the HTTP client behind types it owns so no dependency type leaks into public signatures.

Semantic versioning as an enforced contract

A version number only helps if consumers can trust it. A major bump is required for anything that can break a consumer that compiled, deserialized, or ran correctly against the previous release:

Change Breaking? Caught by API-diff tooling?
Removed or renamed public symbol Yes Yes
Changed default value Yes No
Changed serialized output or wire format Yes Only with schema or golden-file checks
Stricter input validation Yes No
Raised minimum runtime or language version Yes Sometimes, via build metadata
New optional parameter or new symbol No Yes

In CI, an API-diff step compares the public surface of the candidate build against the last released artifact. It fails the build if the change is incompatible and the declared version bump is not major. This makes semver a gate that blocks merges, not a convention people are trusted to follow.

The table also shows the tooling's limits. A new default, a different exception type, or a retry that now takes longer all leave signatures unchanged. Those changes need a review checklist item ("does this alter observable behavior?") and consumer contract tests that run against the candidate release.

Applications should pin exact versions through lockfiles so that builds are reproducible. Libraries should declare ranges, because a library that pins its own dependencies creates conflicts for every service that uses it. How wide those ranges should be depends on the runtime. Where only one version of a package can be loaded per process, two libraries with non-overlapping ranges cannot coexist in the same service, so ranges should be as wide as you can actually test. Where multiple versions can load side by side, the conflict shows up later instead, as mismatched types at the boundaries between them.

#!/usr/bin/env bash
# CI gate: an incompatible public API change must come with a major version bump.
# Runs on every pull request against the shared library.
set -euo pipefail

# Last released version, taken from the most recent git tag (e.g. v2.3.1).
last_tag=$(git describe --tags --abbrev=0)
last_version=${last_tag#v}
last_major=${last_version%%.*}

# Version the author declared for this change (e.g. 2.4.0), kept in a VERSION file.
candidate_version=$(tr -d '[:space:]' < VERSION)
candidate_major=${candidate_version%%.*}

# ci/api_diff.sh wraps your ecosystem's API-compatibility checker.
# Contract: exit 0 if the candidate's public surface is backward compatible
# with the artifact released as $last_tag, non-zero otherwise.
if ./ci/api_diff.sh "$last_tag"; then
  echo "Public API compatible with $last_tag; minor or patch bump is allowed."
  exit 0
fi

# The API surface changed incompatibly, so only a major bump passes.
if [ "$candidate_major" -gt "$last_major" ]; then
  echo "Incompatible API change declared as major: $last_version -> $candidate_version"
  exit 0
fi

echo "ERROR: incompatible API change but version is $candidate_version (last: $last_version)." >&2
echo "Bump the major version or restore compatibility." >&2

# This gate cannot see changed defaults, new exception types, stricter
# validation, or timing changes. Those need the review checklist and
# consumer contract tests run against this candidate build.
exit 1

A CI script that fails the build when the API-diff check reports an incompatible change and the declared version is not a major bump, with a note on which behavioral changes it cannot detect.

How it works in practice: expand-and-contract and wire compatibility

A breaking change ships in three releases rather than one. First, a minor release adds the new API next to the old one. If retryPolicy(int) is being replaced by retryPolicy(RetryConfig), both exist, and the old one becomes a thin adapter that calls the new one. Two code paths with divergent behavior would let services on the same version act differently.

Second, the old API is marked deprecated, and the notice names the replacement and the major version that will remove it. "Deprecated" with nothing else gives a team no way to plan. Use the language's native mechanism so the warning shows up in the compiler or linter. Where the language has none, log once per process at startup rather than on every call. Count deprecated calls in a metric so you can see which services still depend on the old path before you remove it.

Third, the next major release removes the old API. By then the warning has been visible across several releases, and teams upgrade on their own schedule.

During all of this, production runs a mix. Service A might be on major N while Service B is on N+1, and they call each other constantly. That only works if the library never defines the wire contract. HTTP schemas, protobuf messages and event payloads need their own versioning and their own compatibility rules:

  • Add fields as optional.
  • Never reuse a field identifier.
  • Have consumers tolerate fields they don't recognize.

If upgrading a client library changes what goes over the network, then every producer and consumer has to upgrade together, which is the lockstep upgrade this process exists to avoid.

import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;

public final class ClientBuilder {
    // Per-API call counts; export these through your metrics system to see
    // which services still use the old path before the major release removes it.
    static final Map<String, LongAdder> DEPRECATED_CALLS = new ConcurrentHashMap<>();

    private RetryConfig retryConfig = RetryConfig.defaults();

    /** New API, added in minor release 2.4.0 alongside the old one. */
    public ClientBuilder retryPolicy(RetryConfig config) {
        this.retryConfig = Objects.requireNonNull(config, "config");
        return this;
    }

    /**
     * @deprecated since 2.4.0, scheduled for removal in 3.0.0.
     *             Use {@link #retryPolicy(RetryConfig)} instead.
     */
    // forRemoval = true makes javac emit a removal warning at every call site.
    @Deprecated(since = "2.4.0", forRemoval = true)
    public ClientBuilder retryPolicy(int maxAttempts) {
        DEPRECATED_CALLS
            .computeIfAbsent("ClientBuilder.retryPolicy(int)", k -> new LongAdder())
            .increment();
        // Thin adapter: delegate to the new API so old and new callers
        // share one code path and cannot behave differently.
        return retryPolicy(RetryConfig.builder().maxAttempts(maxAttempts).build());
    }
}

Two-step deprecation in Java: the old retryPolicy(int) becomes a thin adapter over the new retryPolicy(RetryConfig), its @Deprecated annotation and Javadoc name the replacement and the removal version, and each call increments a counter (version numbers are placeholders).

Support windows and measuring dependency drift

Once wire compatibility removes the forced coordination, the remaining question is how long old versions stay supported. Answer it with a written policy. A common default is to support the current major and one previous major. The current major gets features and fixes. The previous major gets security and critical fixes only. Anything older gets nothing.

The window you pick moves cost from one group to another:

Policy Backport cost for library owners Upgrade pressure on service teams
Current major only Low High: every major release forces near-term work
Current + one previous Moderate: one extra branch to patch Moderate: roughly one release cycle of slack
Current + several previous High: patches must be ported across diverging code Low, but drift accumulates silently

To enforce the policy you need an inventory. Lockfiles and build metadata already record the resolved version of every internal package in every service. A scheduled job can parse them from each repository's default branch, or from CI build artifacts, and store tuples of service, package, version and timestamp. From that table you can compute lag for each service, counted in majors and minors behind the latest release.

Only alert when a service falls outside the support window. Warnings about minor lag inside the window are noise, and teams learn to ignore noise.

Every out-of-window entry should name the owning team and a deadline. The deadline should follow from the policy, for example the date the previous major loses security fixes. A dashboard row with no owner and no date is usually ignored.

-- Inventory rows come from a scheduled job that parses lockfiles on each
-- repo's default branch: one row per (service, package, version) observation.
-- Policy: current major + one previous major are supported.
WITH latest_obs AS (
  -- Keep only the most recent observation per service/package.
  SELECT service, package, version_major, version_minor,
         ROW_NUMBER() OVER (PARTITION BY service, package
                            ORDER BY observed_at DESC) AS rn
  FROM dependency_inventory
),
current_major AS (
  SELECT package, MAX(major) AS latest_major
  FROM package_majors
  GROUP BY package
)
SELECT o.service,
       own.team                          AS owner_team,     -- every alert names an owner
       o.package,
       o.version_major || '.' || o.version_minor AS in_use,
       c.latest_major,
       c.latest_major - o.version_major  AS majors_behind,
       pm.security_eol                   AS deadline        -- date set by the policy
FROM latest_obs o
JOIN current_major c   ON c.package = o.package
JOIN package_majors pm ON pm.package = o.package
                      AND pm.major   = o.version_major
LEFT JOIN service_owners own ON own.service = o.service
WHERE o.rn = 1
  -- Alert only outside the window: older than previous major,
  -- or on a major whose security support has already ended.
  AND (c.latest_major - o.version_major > 1
       OR pm.security_eol < CURRENT_DATE)
  -- Minor lag inside the window is deliberately not reported.
ORDER BY deadline, majors_behind DESC;

A SQL query over a lockfile-derived version inventory that keeps the latest observation per service, computes how many majors each service lags, and reports only services outside the current-plus-one-previous support window, each with an owning team and a policy-derived deadline.

Impact on cost and engineering time

Drift persists because upgrades are expensive, so the most effective lever is the cost of one upgrade for one consuming team. Four practices reduce that cost:

  • Automated dependency update pull requests. A consumer team reviews a pull request instead of starting the upgrade from scratch.
  • Codemods or migration scripts shipped with each major release. The library team writes the mechanical rewrite once, so consumers don't each rediscover it.
  • Consumer-focused changelogs. Each entry states what a consumer must change and how to tell if they are affected. Internal refactors belong elsewhere.
  • A canary set of representative services. These run against every release candidate, so breakage surfaces before the release goes out and not in thirty separate repositories.

Automation only saves time when consumer test coverage is trustworthy. A green automated pull request is a claim that nothing broke. If the tests don't exercise the library's behavior, teams either merge regressions or re-verify by hand, and the hand-checking erases the savings.

Illustrative estimate (assumed numbers, not a benchmark)

  • Lockstep: S × R × (u + c)
  • Bounded drift: S × U × a + M

Assumed inputs:

  • S = 30 services
  • R = 4 breaking releases per year
  • u = 6 hours of manual upgrade work
  • c = 4 hours of coordination (freezes, synchronized deploys, waiting on other teams)
  • U = 2 upgrades per service per year, because the support window lets teams skip a major
  • a = 2 hours per assisted upgrade
  • M = 80 hours per year to maintain codemods and the canary set

Lockstep: 30 × 4 × 10 = 1,200 hours. Bounded drift: 30 × 2 × 2 + 80 = 200 hours.

The result depends most on a, and a stays low only if consumer tests can be trusted.

When to avoid shared libraries and the alternatives

A versioned package is the wrong tool when every change needs a coordinated rollout anyway. If consumers must upgrade together for the system to work, semantic versioning only records the coupling without removing it. The same applies when behaviour must be identical everywhere at the same moment. Policy decisions such as authorization rules, rate limits and feature entitlements are the clearest case. During any drift window, two services evaluate the same request differently, and that gap is a correctness or security bug, not a scheduling problem.

Alternatives compared

Option Drift Lockstep risk Added latency Operational owner Versioning burden
Monorepo, single version None High: one change touches every consumer None Each service team Low; CI replaces version numbers
Sidecar Per-pod, during rollout Low Local hop Platform team Sidecar API and image version
Central service None for logic Low Network call, plus a new failure mode Owning team, with on-call Service API
Generated clients from schemas Bounded by schema compatibility Low if schema evolution rules hold None Schema owners Schema, not code
Copying code Unbounded and invisible None None Each copier None, until a fix must reach every copy

Moving logic into a sidecar or central service does not remove the versioning problem. It moves it to the runtime API. That API still needs the same discipline: additive changes, deprecation windows, expand-and-contract, and drift metrics measured on callers instead of package imports.

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 libraries 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.