Architecture

Microservices Architecture Best Practices: 7 Rules for Service Boundaries

Every team that draws clean service boundaries on a whiteboard still watches them dissolve in production. This guide is about why that keeps happening even to teams who already know the theory, and what has to change about how the rules get enforced, not just written.

8 min read · 8 Principles · Event Driven
Clear acrylic stackable display cube with four shelves on a purple background.

TL;DR

  • A service's boundary is defined by who reads and writes its data, not by which repository the code lives in - direct database or source access from another service erases the boundary.
  • An inter-service contract needs its own dedicated type, separate from the internal model that produces it, or every internal refactor becomes someone else's breaking change.
  • Contract changes have to be additive and versioned, and consumers have to tolerate fields they don't recognize yet, or every producer improvement forces every consumer onto the same release train.
  • A write that spans two services needs an explicit way to fail - an outbox for atomic publish, a saga with compensating steps for atomic rollback.
  • Every inter-service call has a cost that compounds across loops, sequential awaits, and unbounded responses - batch, parallelize, and paginate by default.
  • A request's network origin is not proof of who's making it. Only a verified workload identity is, and peer addresses belong in configuration, never hardcoded.
  • A shared library that carries business logic rebuilds cross-service coupling through the one door every other rule in this guide left open.

What Microservice Boundary Discipline Actually Protects

Ask five engineers why a service boundary got violated and four will describe a shortcut someone took under deadline pressure. That's usually wrong. The person who introduced it wasn't cutting a corner - they were doing the fastest, most defensible thing available to someone who could only see their own service. Querying another team's table directly is less code than calling their API and handling its failure modes.

None of the seven principles below are really about carelessness. They're about the fact that the correct decision from inside a single service and the correct decision for the system are different decisions, and the gap between them is invisible from any seat that only has visibility into one service.

1. Data Ownership Defines the Service Boundary

You've paid the operational cost of splitting into services and gotten none of the benefit: two teams still can't deploy independently, because a migration in one service's schema silently breaks a query running in another. The rule is specific on purpose: a service's persisted data is read and written only by code that service owns, and every other service reaches it through that service's API or the events it emits.

ANTI-PATTERN — Orders reaching directly into Inventory's schema
SELECT o.id, o.total, i.sku, i.quantity_available
FROM orders.order_items o
JOIN inventory.stock i ON o.sku = i.sku
WHERE o.order_id = $1;
CORRECTED — Orders goes through Inventory's published interface
order = order_repo.get(order_id)
stock = inventory_client.get_stock(order.sku)  # owned by Inventory

The honest tradeoff: the SQL join is faster to write and run. Going through an API means handling latency and partial availability a local join never had to think about — that cost is the price of the services actually being independently deployable.

2. Publish a Contract, Not Your Data Model

The failure shows up as a "breaking change" nobody remembers agreeing to expose. An internal column gets added for an unrelated reason, and because that entity is what the endpoint returns, it's now in the public response. The fix: every inter-service interface gets a distinct type, mapped explicitly at the boundary, generated from a schema artifact committed to the repo.

ANTI-PATTERN — the endpoint returns the persistence model directly
@app.get("/orders/{order_id}")
def get_order(order_id: int, db: Session = Depends(get_db)):
    return db.query(Order).filter(Order.id == order_id).first()
CORRECTED — a dedicated contract type, mapped explicitly
@app.get("/orders/{order_id}", response_model=OrderContractV1)
def get_order(order_id: int, db: Session = Depends(get_db)):
    order = db.query(Order).filter(Order.id == order_id).first()
    return OrderContractV1.from_orm(order)

This is the Bounded Context idea applied at the wire level: the model you use to think about your own data and the model you expose to someone else's don't have to be the same object.

3. Every Contract Change Must Be Additive

A field gets renamed because it was never used - as far as the producer can tell. Three deploys later, a consumer's integration test fails, and the review discovers a service that had been reading that field the whole time. Contracts only grow: never remove, rename, or repurpose a field on a published contract. Do it additively, in a new version, and let old and new coexist until every consumer has moved.

CORRECTED — consumer tolerates fields it doesn't know about yet
@JsonIgnoreProperties(ignoreUnknown = true)
public class OrderPlacedEventV2 {
    private String orderId;
    private String eventVersion;
    private String promotionCode; // added additively
}

The proto3 language guide's section on updating a message type and the reasoning behind Consumer-Driven Contracts both amount to the same instruction: design the contract for the consumers you don't know about yet.

Tradeoff worth naming: additive-only evolution means contracts accumulate deprecated fields nobody's allowed to clean up quickly. That's real. It's cheaper than a breaking change that fails at a time and place you don't control.

4. Design the Failure Path Before the Happy One

A payment succeeds. The inventory reservation that was supposed to happen right after it fails. Now there's a charged customer with no order, no automatic reversal, and an on-call engineer patching rows at 2 a.m. A write that spans services needs local transactions with explicit compensating actions, coordinated by a saga — and a state change and its event should never be split across a commit boundary.

CORRECTED — state change and event write are one local transaction
BEGIN;
UPDATE orders SET status = 'CONFIRMED' WHERE id = $1;
INSERT INTO outbox (aggregate_id, event_type, payload, created_at)
VALUES ($1, 'OrderConfirmed', $2, now());
COMMIT;

The transactional outbox and saga patterns exist specifically because distributed writes lose the ACID guarantees a monolith took for granted, and pretending otherwise just moves the failure from architecture into an incident.

The tradeoff is eventual consistency and more infrastructure — a relay, a saga coordinator — in exchange for giving up the illusion that a synchronous call chain was ever atomic in the first place.

5. Every Inter-Service Call Carries a Price

An aggregation endpoint that lists a customer's orders looks fine with ten orders and falls over with two hundred, because it calls the customer service once per order. Batch instead of looping. Start independent calls concurrently instead of awaiting them one at a time. Bound every collection response with a server-enforced page size.

ANTI-PATTERN — one network call per order
const orders = await orderRepo.list(customerId);
for (const order of orders) {
  order.customerName = await customerClient.get(order.customerId); // N calls
}
CORRECTED — one batched call for the whole collection
const orders = await orderRepo.list(customerId);
const customerIds = [...new Set(orders.map(o => o.customerId))];
const customers = await customerClient.getBatch(customerIds);

Diagnostic worth running: pick any endpoint that aggregates data from more than one service, and check whether its p99 latency is closer to the SUM of its dependencies' latencies or the MAX. Sum means someone's awaiting sequentially when they didn't need to.

6. Network Location Is Not Authentication

An internal endpoint has no auth check because it's assumed nothing outside the cluster can reach it. That assumption holds right up until a lower-privilege service in the same cluster calls it directly. Every inter-service call needs to authenticate against a verified workload identity - a signed token or mutual TLS certificate - never implicitly trusted because of where the request came from.

Diagnostic worth running: can any pod in your cluster reach a given internal endpoint and get a 200 response without presenting a token or certificate? If yes, that endpoint isn't private - it's public with an informal gate.

NIST SP 800-207, Zero Trust Architecture, is the standards-body version of this instruction: stop treating network position as a substitute for verified identity. It's genuinely expensive to operationalize — certificate rotation, identity issuance, usually a service mesh — which is exactly why it needs to be checked rather than assumed.

7. Code Reuse Is How Boundaries Dissolve

Two teams independently notice they're writing nearly identical discount logic and extract it into a shared library both services import. Six months later, a pricing bug fix requires coordinating a release across nine services, because core business logic now lives in an import statement instead of inside any service's boundary. Shared libraries should carry technical plumbing only - logging, tracing, generated clients - never a business rule.

Rule of thumb: if a bug fix in a shared library requires redeploying more than one service before it takes effect, the library isn't shared infrastructure. It's an unshipped extra service.

This is the principle that makes the rest of this guide's argument land, because nobody involved in creating that shared library did anything a code reviewer would have flagged. Sandi Metz has made the opposite case of DRY — that tolerating duplicate code is usually cheaper than living with the wrong shared abstraction — and it's counterintuitive precisely because a standard engineering education points the other way.

How Pandorian Enforces This Guide

Every principle above survives a design review. Few survive six months of independent team decisions, because the violations aren't visible from inside any one pull request - they're visible only in the aggregate. Pandorian enforces this guide across three surfaces:

Caught at PR level

Every pull request is checked against the guideline before merge. A new foreign key across a service boundary, a schema change that narrows a field's type, a call sitting inside a loop - these surface with the specific line, the rule, and a suggested fix.

Full scans

An on-demand scan across the whole codebase establishes the day-one baseline: where direct database access already exists, which services lack workload identity checks, where shared libraries already carry business logic.

Scheduled scans

The same full scan, run on a cadence, catches drift introduced outside any pull request - a mesh policy change that drops mTLS enforcement, a new service deployed without the identity checks the rest of the fleet has.

A Taste of the Guideline Package

Four of the checks that ship in this package - each runs at the PR level, on full or scheduled scans, or several at once.

RULE WHAT IT FLAGS
ARC_10247
Never access another service's database directly - a live connection, foreign key, or cross-schema query into another service's tables.
ARC_10682
Never remove or repurpose a field in a published contract version.
PRF_11137
Never issue an inter-service call inside a loop - a network client call sitting in a for/while loop instead of a batched request.
SEC_11461
Authenticate every inter-service call with a workload identity - no token or mTLS verification in the request path.

FAQ

What is microservice boundary discipline?

+

Microservice boundary discipline is the practice of enforcing, in code, the rules that keep one service's data, contracts, and failure modes from leaking into another's. A boundary is defined by who reads and writes a service's data - not by which repository the code sits in. Discipline means those rules survive contact with delivery pressure, not just design review.

Most teams already agree on the rules. What breaks is that a boundary violation is almost never visible from inside the pull request that introduces it - it's only visible in the aggregate, months later.

What is a distributed monolith, and how is it different from microservices?

+

A distributed monolith is a system split into separate deployable services that still can't be deployed separately. The services communicate over the network, but they share a database, a release train, or a business-logic library - so a change in one forces a coordinated release across several. You pay the operational cost of microservices and get none of the independence.

The clearest test is a bug fix. If fixing a defect in one service requires redeploying others before it takes effect, the split is organizational, not architectural.

Should two services ever share a database?

+

Yes, in two narrow cases: a shared database instance where each service owns strictly separate schemas and never queries across them, and a deliberate, time-boxed transition state during a monolith decomposition. What is never defensible is two services reading and writing the same tables, because that makes a schema migration in one an outage in the other.

The distinction people miss is between sharing infrastructure and sharing data. Sharing a Postgres cluster to save on operational overhead is a cost decision. Sharing a table is a coupling decision, and it removes independent deployability whether or not anyone intended it to.

What is the transactional outbox pattern, and why does it matter for sagas?

+

The transactional outbox pattern writes a state change and the event announcing it into the same local database transaction, with a separate relay process publishing the event afterwards. It exists because a database commit and a message broker publish cannot be made atomic — so without it, a service can commit an order and never announce it, or announce one it never committed.

Sagas depend on this. A saga coordinates a multi-service write through a sequence of local transactions with compensating steps, and every step in that sequence is triggered by an event. If events can be silently lost between commit and publish, the saga stalls in a partial state with no signal that anything went wrong.

How do I tell if my services are actually independently deployable?

+

Run three checks. First, grep for connection strings and foreign keys that point at another service's schema - direct data access is the fastest way to find real coupling. Second, look at your last ten releases and count how many required more than one service to ship together. Third, check whether any shared library in your dependency tree carries a business rule rather than plumbing.

Any one of those turning up positive means the boundary exists on the architecture diagram and not in the codebase. The uncomfortable version of this check: pick your two most independent-looking services and ask whether either team could deploy on a Friday without notifying the other.

Can't code review catch boundary violations?

+

Rarely, because the reviewer and the author usually share the same blind spot. A developer adding a cross-schema query, or extracting discount logic into a shared library, is making the correct decision from inside their own service - and the reviewer, who sits in that same service, has no reason to flag it. The violation only becomes visible from a vantage point above any single repository.

That's the argument for automated enforcement rather than more diligent review: guidelines checked on every pull request, plus full and scheduled scans that catch drift introduced outside any PR at all — a new service deployed without workload identity, or a mesh policy change that quietly drops mTLS.

Get the Guideline Package

You'll get this as a guideline MD file - hand it straight to your own coding agent or CI setup. Or skip the DIY step: give these same guidelines real enforcement superpowers, with PR checks and scans live on your repos, through Pandorian.

From Principles to Enforcement

Every rule in this guide is easy to agree with in a design review and easy to violate six sprints later, one locally-correct decision at a time. Pandorian turns boundary discipline like this into CI guardrails - scoped to the repos, teams or services you choose.