What shared libraries in microservices are and why teams use them

In a microservices system, a shared library is code that several services import as a dependency instead of copying. It is usually published as an internal package to a private registry and versioned on its own schedule. Each service then pulls in whatever version it pins at build time. This differs from a shared service, which teams call over the network at runtime. A library is compiled or bundled into every consumer, so its behavior, and its bugs, live inside each service's process.

Teams adopt internal packages for practical reasons. Without them, every service reimplements the same logging setup, trace context propagation, token validation, retry policy and health check wiring. Each reimplementation drifts slightly. One service drops the correlation ID, another logs secrets, a third accepts expired tokens. A common package makes cross-cutting behavior consistent and cuts the boilerplate a new service needs before it can run in production.

The trouble is that lines of code removed is the wrong measure of success. Code that changes rarely and has one clear owner shares well. Code that changes often, or that encodes one team's business rules, becomes a coordination point every consumer has to track. This guide's thesis is that you should decide what to share by change frequency and ownership.

It is written for backend and platform engineers who decide what goes into common packages, and who later have to maintain those packages and upgrade every service that depends on them.

Classifying candidate code: plumbing, utilities, and domain logic

Most code that teams consider moving into internal packages falls into one of three buckets. Sorting a candidate into the right bucket tells you most of what you need to know about the cost of sharing it.

Infrastructure plumbing wires a service into the platform. Examples are logging setup, trace context propagation, HTTP client defaults such as timeouts and retries, and auth token validation. It changes when the platform changes, not when the business changes. A platform team usually owns it, and every service benefits from behaving the same way.

Domain-free utilities include string normalisation, pagination helpers, and retry backoff calculations. They have no business meaning, so they rarely need to change. When they do change, the change is mechanical.

Domain logic and entity models include a Customer type, order-pricing rules, and eligibility checks. This is the code that changes with the business, and each service tends to need a slightly different view of it. Sharing it means a single team's change to a pricing rule forces a coordinated release across every consumer. That is the coupling microservices were meant to remove.

Bucket Typical change frequency Coordination need Ownership clarity Suitability for sharing
Infrastructure plumbing Low to moderate, driven by the platform Low if changes are backward compatible High, usually a platform team Good
Domain-free utilities Low Minimal Moderate, often orphaned Good, if kept small
Domain logic and models High, driven by the business High, often requires lockstep releases Low, contested between teams Poor

How hidden coupling forms: shared models, DTOs, and transitive dependencies

That table puts shared models in the high-churn, lockstep-release row. Here is how that happens.

Shared models turn refactors into coordinated releases

Say the orders team renames a field or splits an Address into billing and shipping parts. If that type lives in a shared common-models package, the change is no longer local. Every service that imports it has to rebuild, retest and redeploy, or it keeps running an older shape of the model. Over time, services on different versions drift apart. Teams can still deploy separately, but in practice they release together, because the services are coupled through the types they share. That is a distributed monolith.

Pinned transitive dependencies

 orders-svc   billing-svc   shipping-svc   notify-svc
      \            |             |            /
       +-----------+------+------+-----------+
                          |
                   common-models
                    /     |      \
          json-lib X   http-client Y   db-driver Z
            (pinned)     (pinned)       (pinned)

A model package rarely stays just data. Once it gains serialization annotations, an HTTP client or a database driver, the version it pins becomes the version every consumer uses. If notify-svc needs a security fix in the HTTP client, it can't take that fix until common-models upgrades and the other three services accept the change.

Correlated blast radius

A shared library also means shared failures. Suppose a library update brings a regression, such as a date format the serializer now gets wrong. Every service that picks it up breaks the same way, often within the same deploy window. With isolated code, you would see one broken service. Here, every service fails in the same way at the same moment, which makes an outage harder to contain and harder to diagnose.

+------------+ +-------------+ +--------------+ +------------+
| orders-svc | | billing-svc | | shipping-svc | | notify-svc |
+------------+ +-------------+ +--------------+ +------------+
      |               |               |               |
      +---------------+-------+-------+---------------+
                              |
                              v
               +-----------------------------+
               |        common-models        |
               |  Address, Order DTOs, ...   |
               +-----------------------------+
                              |
              +---------------+---------------+
              |               |               |
              v               v               v
        +-----------+  +-------------+  +-----------+
        | json-lib  |  | http-client |  | db-driver |
        | X, pinned |  |  Y, pinned  |  | Z, pinned |
        +-----------+  +-------------+  +-----------+

  split Address in common-models   -> all 4 services rebuild and redeploy
  notify-svc needs http-client fix -> blocked until common-models moves Y
  regression in json-lib X         -> all 4 services fail in one window

Four services depend on one common-models package, which pins json-lib X, http-client Y and db-driver Z for all of them, so a model refactor, a blocked upgrade or a library regression affects every service at once.

When to share code and when to avoid it: a decision checklist

That blast radius is one reason to be selective. Before you move code into a shared library, check it against these questions:

  • How often does it change? Stable code is cheap to share, such as retry helpers or logging setup. Code that changes every sprint makes each change a coordinated rollout.
  • Does a change force every consumer to update at once? If it does, you have tied service deploys together and lost independent releases.
  • Is there one clear owner? Without one team accountable for it, nobody can say no to scope creep.
  • Is it language-specific in a polyglot environment? A Java library does nothing for your Python and Go services. Each language then needs its own implementation, test suite and maintainer, and the implementations drift apart over time.
  • Would a bug here break every service at once? If so, the library needs the same release rigor as core infrastructure.

These questions come down to two trade-offs:

Choice What you gain What you pay
Share the code Consistency: one fix lands everywhere Coupling: coordinated upgrades, shared failure modes
Duplicate the code Autonomy: each team releases on its own schedule Drift: fixes must be applied once per copy
Parallel per-language libraries Native fit in each stack Repeated effort and behaviour that slowly diverges

Warning signs that a library has become a coupling point

  • Release trains wait on a new library version before they can ship.
  • Many services are stuck on old major versions because upgrading costs too much.
  • A common or shared package keeps growing because it is the easiest place to put code.
  • Pull requests come from many teams, and nobody clearly owns the review.

How to implement internal packages safely

Each of those symptoms comes from treating the library as a place to put code rather than as a product. The fix is ownership and contracts.

Run each library as a product

  • A named owning team. That team reviews changes, triages issues, and decides what belongs in the library. Without one, every consuming team adds to it and nobody removes anything.
  • A changelog written for consumers. Each entry should say what changed and whether callers need to do anything. Commit logs don't tell them that.
  • Semantic versioning that means something. Bump the major version only for real breaking changes, and always bump it when one ships. If minor releases sometimes break callers, teams stop taking updates and pin versions, which defeats the point of versioning.
  • Deprecation windows and a written support policy. Publish how many major versions get security and bug fixes, and how long a deprecated API keeps working before it is removed. This lets consuming teams plan upgrades around their own release schedules.

Keep libraries thin and opt-in

Split by concern: a logging package, a retry package, a tracing package. Avoid a single core package. Then a service takes only what it needs, and a breaking change in one area doesn't force upgrades everywhere.

Don't run anything when the library is imported. No registering global handlers, no reading environment variables, no starting background threads. Side effects at import time make behavior depend on the order of imports and make tests hard to isolate.

Expose configuration and leave policy out. A library can offer a retry mechanism, but timeouts and backoff values belong to the calling service. The example below compares a package that sets up a global client when it is imported with one that provides an explicit setup function. In the second version, the service passes its own settings and gets back a handle it controls.

# --- Avoid: http_client_bad.py ---
# Importing this module changes process-wide state.
import logging
import os

logging.basicConfig(level=logging.DEBUG)  # overrides the service's logging setup
BASE_URL = os.environ["API_BASE_URL"]   # import fails if the variable is unset
TIMEOUT_SECONDS = 2                       # policy hard-coded by the library
_client = {"base_url": BASE_URL, "timeout": TIMEOUT_SECONDS}  # hidden global


# --- Prefer: http_client.py ---
# Importing this module has no side effects. The caller builds and owns the client.
from dataclasses import dataclass
import logging

logger = logging.getLogger(__name__)  # a named logger; handlers are left to the app


@dataclass(frozen=True)
class ClientConfig:
    base_url: str
    timeout_seconds: float  # required: the service decides its own timeout
    max_retries: int = 0    # opt-in: retries are off unless the caller enables them


class Client:
    def __init__(self, config: ClientConfig):
        self.config = config
        logger.debug("client created for %s", config.base_url)


def create_client(config: ClientConfig) -> Client:
    """Explicit setup: call it at startup, or in each test with its own config."""
    return Client(config)


# In the consuming service, policy values come from the service, not the library:
# client = create_client(ClientConfig(base_url="",
#                                     timeout_seconds=1.5, max_retries=2))

The first module configures logging, reads the environment and creates a global client when it is imported, while the second does nothing at import time and has the calling service pass its own timeout and retry settings to an explicit setup function.

Planning the upgrade path without lockstep releases

Once packages are explicit and side-effect free, the remaining risk is the release itself. The goal is that no library upgrade ever requires every service to deploy together.

Three practices make that possible:

  • Automated dependency updates. A bot opens pull requests in each consuming repository when a new library version is published. Upgrades then arrive as small, reviewable changes rather than a quarterly migration.
  • Consumer CI before release. The library's pipeline builds and tests a few representative services against the release candidate. Breakage then appears in the library's build, before consumers are affected.
  • Version coexistence as a rule. Any two adjacent library versions must work together across the network. During a rolling deploy, both will be in production at once.

Contracts should be shared separately from code. Publish schemas or IDL definitions, and let each service generate or own its types. A hand-written client that embeds retries, defaults, or validation spreads one team's behavior into every caller. A schema carries only the shape of the data. In Protocol Buffers, a backward-compatible change adds optional string locale = 4; under a new field number and never reuses or renumbers existing fields. Older readers ignore the new field.

These practices have costs:

  • Version sprawl delays security fixes. A patch is only fixed once every consumer upgrades.
  • Multiple live versions enlarge the support matrix. Small differences, such as a changed default, can cause behavior to diverge mid-rollout.
  • Contract-first generation adds build tooling. It also requires written schema evolution rules that CI enforces.
// order_events.proto (published schema; each service generates its own types)
syntax = "proto3";

package orders.v1;

message OrderPlaced {
  // Fields 1-3 shipped in the first release. Their numbers and types
  // are permanent: renumbering or changing a type breaks old readers.
  string order_id = 1;
  string customer_id = 2;
  int64 total_cents = 3;

  // Added in a later release under a new, never-used field number.
  // Older consumers skip this field because it is unknown to them.
  // Newer consumers can use has_locale() or the language equivalent
  // to detect messages from producers that predate the field.
  optional string locale = 4;

  // Field 5 was removed. Reserving the number and name stops anyone
  // from reusing them with a different meaning.
  reserved 5;
  reserved "coupon_code";
}

A Protocol Buffers schema adds a backward-compatible field under a new number and reserves a removed field, so producers and consumers on different versions can run at the same time.

Alternatives and their impact on cost and engineering time

A shared library is one option among several. Each alternative moves the cost to a different place.

Option Coupling Polyglot support Operational overhead Maintenance cost
Shared library High: build-time, version-bound One library per language Low Ongoing: versioning, compatibility testing
Deliberate duplication None Any None Fixes repeated per copy
Sidecar / platform service Runtime, over a network contract Any High: deploy, monitor, scale Centralized in one team
Code generation from schemas Contract-level only Any with a generator Low Schema governance, generator upkeep
Templates / scaffolding None after creation One template per stack None Copies drift over time

Shared libraries save time when the code is plumbing. A retry or tracing bug gets fixed in one place. Observability fields stay consistent across services, so dashboards and alerts work without per-service mapping. New services start with working defaults.

The costs are recurring. Someone has to own the library, triage issues, and publish releases. Each change must be tested against the consumers that still use older versions. Security fixes force coordinated upgrades across teams that may have other priorities.

Illustrative estimate (assumed numbers, not a benchmark)

To decide whether sharing a component pays off, compare two yearly costs:

  • Duplication cost = services × fixes per year × hours per fix
  • Library cost = owner hours + (services × upgrade hours × releases per year)

With assumed inputs of 10 services, 3 fixes per year, and 4 hours per fix, duplication costs 120 hours.

With assumed inputs of 40 owner hours, 1 upgrade hour per service, and 6 releases per year, the library costs 40 + 60 = 100 hours.

These totals are close. In that situation, the decision depends less on hours and more on whether services need identical behavior.

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