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.
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.
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.
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;
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.
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.
@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()@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.
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.
@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.
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.
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.
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.
const orders = await orderRepo.list(customerId);
for (const order of orders) {
order.customerName = await customerClient.get(order.customerId); // N calls
}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.
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.
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.
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:
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.
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.
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.
Four of the checks that ship in this package - each runs at the PR level, on full or scheduled scans, or several at once.
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.
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.
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.
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.
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.
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.
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.
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.