What are microservices? A working definition

James Lewis and Martin Fowler's 2014 description is still the most useful starting point. They define the microservice style as "an approach to developing a single application as a suite of small services, each running in its own process and communicating with lightweight mechanisms, often an HTTP resource API." Those services are built around business capabilities and are "independently deployable by fully automated deployment machinery." The authors also say there is no precise definition. What they offer is a set of common characteristics.

Independent deployability matters more than size

The word "micro" leads people to focus on lines of code or the number of endpoints. Size is a poor test. The property that matters is whether you can change, build, test and release one service without coordinating a release of any other.

Here is a simple check. Suppose the orders service cannot ship a schema change until the billing and inventory services ship at the same time. Then those three services form one deployment unit. It does not matter that they live in separate repositories, run in separate containers or are owned by separate teams. You are running a distributed monolith. You pay the costs of a network boundary and get none of the release independence.

Why teams choose it

Teams adopt microservices to let each one release on its own schedule, scale services separately and choose tools per service. That autonomy has a price. Calls that used to happen in memory now cross the network, so you have to handle partial failure, latency and data consistency across separately owned stores. The rest of this article treats microservices as that trade and helps you decide when the benefit is worth the cost.

How microservices work: boundaries, data ownership and communication

Boundaries follow business capabilities

A microservice system is split by what the business does (billing, catalog, shipping) rather than by technical layer (UI, business logic, database). A layered split means almost every feature touches every layer, so one change needs coordinated releases across several teams. A capability split keeps most changes inside one service, so one team can own that service from code to production and ship it on its own schedule. Lewis and Fowler describe this as organizing around business capabilities, with teams owning products rather than projects (source).

Each service owns its data

Every service keeps a private datastore, and other services reach that data only through the owning service's API. Nobody else reads its tables. This removes shared-schema coupling: billing can change its storage model without breaking catalog. You give up two things a single database provides for free. You can no longer join across services in one query, and you can no longer wrap a change spanning two services in one ACID transaction. Cross-service consistency becomes something you design for explicitly.

Smart endpoints, dumb pipes

Business rules live inside the services. The transport only moves bytes: synchronous HTTP for request/response, and a simple message broker for asynchronous events. Keeping routing, transformation and orchestration out of the middleware means each rule has one owner and one place to change.

 +-----------+   HTTP    +-----------+
 |  Catalog  |<----------|  Billing  |
 +-----+-----+           +-----+-----+
       |                       |
   [catalog DB]           [billing DB]
       |                       |
       +------ publish --------+
                  |
           +------v------+
           |   Message   |
           |   broker    |
           +------+------+
                  | subscribe
           +------v------+
           |  Shipping   |
           +------+------+
                  |
            [shipping DB]
 +-------------+               +-------------+               +-------------+
 |  catalog DB |               |  billing DB |               | shipping DB |
 |  (private)  |               |  (private)  |               |  (private)  |
 +-------------+               +-------------+               +-------------+
        ^                             ^                             ^
        | owns                        | owns                        | owns
        |                             |                             |
 +-------------+               +-------------+               +-------------+
 |   Catalog   |<---- HTTP ----|   Billing   |               |  Shipping   |
 |   service   |---- reply --->|   service   |               |   service   |
 +-------------+               +-------------+               +-------------+
        |                             |                             ^
        | publish                     | publish                     | consume
        v                             v                             |
 +-------------------------------------------------------------------------+
 |         Message broker: carries events only, no business logic          |
 +-------------------------------------------------------------------------+

Three services split by business capability each own a private datastore, call each other over synchronous HTTP, and share events through a message broker that holds no business logic.

Microservices vs service-oriented architecture and the modular monolith

Splitting a system into services is not new. Service-oriented architecture (SOA) did it first. The difference is mostly in where control sits. Classic SOA programmes usually ran integration through a central enterprise service bus, and the bus handled routing, message transformation and often business rules. Microservices push that logic out to the services themselves, an idea that Lewis and Fowler call "smart endpoints and dumb pipes". Each service owns its data, picks its own stack within agreed limits and ships on its own schedule. The line between the two terms is fuzzy, though. Some people describe microservices as SOA done well, and plenty of SOA systems never used a bus.

The alternative most teams should actually weigh is the modular monolith: one deployable unit split internally into modules with clear boundaries. Each module owns its tables and exposes a narrow interface. Calls between modules stay in-process, so you avoid network failures, distributed transactions and per-service pipelines. If the module boundaries hold up, you can extract a module into a service later.

Aspect Monolith Modular monolith SOA Microservices
Deployment unit Whole application Whole application Service, often released in coordination Each service, independently
Data ownership Shared schema Per module, enforced by convention or tooling Often shared enterprise databases Per service, private store
Communication In-process calls In-process calls through module interfaces Through a central bus (e.g. SOAP/XML) Direct HTTP/gRPC or a message broker
Governance Central Central Central, standards-driven Decentralized, per team
Operational load Low Low Medium to high High

Designing for failure across network boundaries

An in-process function call takes nanoseconds and either returns or throws. A call to another service crosses a network, so it adds latency and serialization cost on both sides. It can also fail partially: the request may time out after the remote side has already committed the work. That leaves the caller unsure what happened. Synchronous chains compound the risk. Illustrative estimate: if one request passes through five services that are each available 99.9% of the time, the whole request succeeds about 99.5% of the time (0.999⁵). Lewis and Fowler call out designing for failure as a core characteristic for this reason.

Baseline requirements

  • Timeouts on every call. Without one, a slow dependency holds threads and connections until the caller is exhausted too.
  • Bounded retries with exponential backoff and jitter. Backoff gives a struggling service room to recover. Jitter keeps many clients from retrying at the same moment.
  • Idempotency keys. The client sends a unique key with each request, and the server stores the result under that key. A retried request then returns the stored result instead of, for example, charging a customer twice.
  • Circuit breakers. After repeated failures, the breaker stops calls to the failing dependency for a while. This keeps the failure from spreading to other services.

The code example below combines a timeout, bounded backoff and an idempotency key using only standard-library constructs.

Consistency without shared transactions

Each service owns its data, so there is no shared transaction that spans services. Multi-step operations become sagas: a series of local transactions, each paired with a compensating action that undoes it if a later step fails. The resulting state is eventually consistent, so reads can be stale for a short time. Agree with the business on what users will see during that window and which processes can tolerate it.

import json, random, time, uuid
import urllib.error, urllib.request

def post_with_retries(url, payload, max_attempts=4, timeout_s=2.0, base_delay_s=0.2):
    # Reuse one key across all attempts so the server can recognise retries
    # of the same logical request and return the stored result.
    idempotency_key = str(uuid.uuid4())
    body = json.dumps(payload).encode("utf-8")
    for attempt in range(1, max_attempts + 1):
        req = urllib.request.Request(url, data=body, method="POST", headers={
            "Content-Type": "application/json",
            "Idempotency-Key": idempotency_key,
        })
        try:
            # The timeout stops a slow dependency from holding this caller indefinitely.
            with urllib.request.urlopen(req, timeout=timeout_s) as resp:
                return json.loads(resp.read())
        except urllib.error.HTTPError as err:
            # Client errors (except 429 Too Many Requests) will not succeed on retry.
            if err.code < 500 and err.code != 429:
                raise
            last_error = err
        except (urllib.error.URLError, TimeoutError) as err:
            # Connection failures and timeouts: the outcome is unknown, so retry
            # under the same idempotency key.
            last_error = err
        if attempt == max_attempts:
            raise last_error  # bounded: give up and let the caller decide
        # Exponential backoff with full jitter spreads out retries from many clients.
        time.sleep(random.uniform(0, base_delay_s * 2 ** (attempt - 1)))

# Example call against a placeholder internal endpoint:
# post_with_retries("",
#                   {"order_id": "ord-1042", "amount_cents": 2599})

A standard-library Python HTTP client that sets a per-call timeout, makes a bounded number of retries with jittered exponential backoff, and sends the same idempotency key on every attempt so the server can safely deduplicate retries.

Prerequisites for implementation: automation, observability and contracts

Those decisions are easier to make once the operational groundwork is in place, and that groundwork has to exist before the service count grows, not after.

Automation first

Each service needs its own automated test suite and its own deployment pipeline. If releasing one service requires a coordinated manual release, you have lost the main benefit of the style. Lewis and Fowler list infrastructure automation as a defining characteristic for this reason (source).

Observability across hops

A single user request may touch five processes, so you need centralized logging, per-service metrics and distributed tracing. Tie them together with a correlation ID. The edge service generates it, and every handler reads it from an incoming header (for example X-Correlation-ID), stores it in request context, adds it to every log line, and forwards it on outbound calls. One search on that ID then returns the full request path.

Contracts replace the compiler

In a monolith, renaming a field breaks the build. Across services, it breaks production. Version your APIs, keep changes backward compatible, and run contract or integration tests that check consumers against providers in CI.

Two failure modes

  • Distributed monolith: services share a database or must deploy in lockstep. You pay network costs without gaining independence.
  • Fragmentation: every team picks its own language and datastore. Decentralized governance is useful, but a short list of supported stacks keeps on-call and hiring manageable.
import { createServer } from "node:http";
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";

const HEADER = "x-correlation-id"; // Node lowercases incoming header names
const context = new AsyncLocalStorage<{ correlationId: string }>();

// Every log line carries the ID, so one search returns the whole request path.
function log(message: string, fields: Record<string, unknown> = {}) {
  const correlationId = context.getStore()?.correlationId;
  console.log(JSON.stringify({ ts: new Date().toISOString(), service: "orders", correlationId, message, ...fields }));
}

// Outbound calls forward the same ID to the next service.
async function callDownstream(url: string) {
  const correlationId = context.getStore()?.correlationId ?? randomUUID();
  return fetch(url, { headers: { [HEADER]: correlationId } });
}

createServer((req, res) => {
  // Reuse the caller's ID. Only the edge generates one, when no header is present.
  const incoming = req.headers[HEADER];
  const correlationId = (Array.isArray(incoming) ? incoming[0] : incoming) || randomUUID();

  // Everything inside run(), including awaited calls, sees the same request context.
  context.run({ correlationId }, async () => {
    res.setHeader(HEADER, correlationId); // echo it back so clients can report it
    log("request received", { method: req.method, path: req.url });
    try {
      const inventory = await callDownstream("");
      log("inventory responded", { status: inventory.status });
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify({ ok: true }));
    } catch (err) {
      log("inventory call failed", { error: String(err) });
      res.writeHead(502).end();
    }
  });
}).listen(3000);

A Node.js handler reads or creates an X-Correlation-ID and keeps it in AsyncLocalStorage. It adds the ID to every structured log line and forwards it on the outbound call to the next service.

When to use microservices and when to avoid them

Microservices pay off when the monolith itself is the constraint on delivery, not when the codebase merely feels large. The useful question is whether independent deployment would remove a bottleneck you can name today.

Signal What it indicates Recommendation
Several teams queue behind one release train Coordination, not code, limits throughput Extract the components those teams own
One component needs very different scaling or availability Shared deployment forces one profile on everything Split that component out
Deploy frequency drops because changes must be batched and co-tested Coupling at release time Consider services along the coupled seams
Small team, one or two squads No contention to relieve Stay with a modular monolith
Domain boundaries still shifting Any split is likely to be wrong Stay modular until boundaries stabilise
No mature CI/CD or observability You cannot operate many deployables safely Build the platform first
No on-call capacity per service Ownership will be nominal Stay modular

Getting boundaries wrong is the costly failure. Inside a monolith, moving a responsibility from one module to another is a refactor: change the code, run the tests, ship one artefact. Across services, the same change needs a new or versioned API, a data migration between stores that each service owns, a period where old and new paths both run, and coordinated releases by different teams.

For that reason, the lower-risk path is to start with a modular monolith. Enforce module boundaries and give each module its own data. Then extract a module into a service only after its boundary has held steady under real change.

Impact on development cost and engineering time

Every extracted service carries fixed costs that a module inside a monolith does not. The cost of the architecture comes from multiplying those costs by the number of services.

Illustrative estimate (assumed numbers, not a benchmark)

Assume 12 services and the per-service monthly upkeep below.

Per-service cost Assumed hours/month
CI/CD pipeline maintenance 2
Dashboards and alert tuning 2
Dependency and security patching 3
On-call ownership and incident follow-up 3
Total per service 10
Total for 12 services 120

Formula: ongoing overhead = number of services × hours per service per month. Here, 12 × 10 = 120 hours/month. At an assumed 160 working hours per engineer-month, that is about three-quarters of one engineer spent on upkeep before anyone builds a feature. Substitute your own service count and hours. The total grows linearly with each new service, and patching often rises faster when services use different language stacks.

Where time is saved

The return comes from removing coordination costs:

  • Fewer merge and release conflicts. Teams stop queuing behind a shared release train.
  • Smaller blast radius. A bad deploy affects one service, so rollbacks are faster and less risky.
  • Independent scaling. You scale the hot path, not the whole application.
  • No waiting. A team ships when its own change is ready.

These savings only appear if deployment contention was the real constraint. If teams were mostly slowed by unclear requirements or a slow test suite, splitting the system adds the overhead above and recovers none of 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 what are 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.