What 'microservices-ready' means: properties you can verify today
A microservices-ready monolith is not a plan to split the system later. It is a codebase whose modules already behave as if they could be separated. You can check four properties today:
- Each module owns its data. Only that module reads or writes its tables. Other modules never query them directly.
- No module reaches into another's internals. Private classes, helper functions and ORM entities stay inside their module.
- Every cross-module interaction goes through a published interface. That interface is a small, deliberate surface, not whatever happens to be public.
- Cross-module calls are designed to tolerate slowness or failure. The caller assumes that a call which is fast and in-process today may become a network hop with timeouts and partial failures.
Folder structure alone proves none of this. Two directories named billing and orders are no more separate than one directory if orders joins against billing's tables. A controllers/services/repositories split is worse. It slices the code by technical layer, so every business capability cuts across all three layers. You cannot extract "the repositories" as a service, because they do not form a unit of behavior.
This approach is for backend and platform teams with a growing monolith, several teams sharing one codebase, or real pressure on scaling or deployment. Teams adopt it for practical reasons:
- An engineer can reason about one module without loading the whole system into their head.
- Teams can work in parallel with fewer collisions.
- Ownership is clearer.
- If extraction is ever needed, it becomes a contained change instead of a rewrite.
+-------------------------------- monolith --------------------------------+
| |
| +---------- orders ----------+ |
| | published interface | |
| | OrdersApi.placeOrder() | |
| +----------------------------+ +---------- billing ---------+ |
| | internals (private) | | published interface | |
| | OrderService ------------+--------->| BillingApi.charge() | |
| | Order (ORM entity) | timeout/ +----------------------------+ |
| | | | fallback | internals (private) | |
| | v | | BillingService | |
| | [ orders tables ] | | Invoice (ORM entity) | |
| +----------------------------+ | | | |
| | v | |
| | [ billing tables ] | |
| +----------------------------+ |
| |
| allowed: orders --> BillingApi (may become a network hop) |
| forbidden: orders --> billing tables, BillingService, Invoice ORM |
| |
+--------------------------------------------------------------------------+
Two modules in one deployable: each owns its tables and keeps its internals private, and orders reaches billing only through billing's published interface. Orders treats that call as one that can be slow or fail.
Finding clean boundaries from the domain and from change patterns
Technical layers are the wrong starting point. A "repositories" module or a "services" package cuts across every business capability, so any real feature change touches all of them. Start from domain concepts instead: nouns and workflows that have their own rules, vocabulary and lifecycle, such as billing, catalog or fulfillment. When the same word means different things in two places (a "customer" in billing versus in support), that is usually a boundary.
Then check the domain model against evidence. Version control history shows which files actually change together. Run git log --name-only over a window of several months, group files by commit, and count how often each pair appears in the same commit. Pairs that frequently co-change but sit in different proposed modules indicate a misplaced boundary. Files in one module that never change together may hold two concepts. Ignore bulk commits such as formatting passes or dependency bumps, which add noise.
Team ownership is the next signal. A module edited by three teams will not stay clean. Also look for places where needs actually differ: a component with spiky load, a separate release cadence or stricter compliance requirements is a natural seam.
Be cautious about timing. Drawing hard boundaries before the domain is understood locks in a bad decomposition, because every contract you add makes moving code more expensive. Some workflows also need strong consistency across what look like separate domains, such as reserving inventory and charging payment in one step. In those cases, the correct move is often to merge the modules rather than build coordination machinery between them.
#!/usr/bin/env bash
# Count how often pairs of files change in the same commit.
# Usage: ./cochange.sh [since] [path] e.g. ./cochange.sh "6 months ago" src/
set -euo pipefail
SINCE="${1:-6 months ago}"
PATHSPEC="${2:-.}"
MAX_FILES=20 # skip bulk commits (formatting passes, mass renames, dependency bumps)
# One marker line per commit, followed by the files it touched.
git log --since="$SINCE" --no-merges --name-only --pretty=format:'@@commit' -- "$PATHSPEC" \
| awk -v max="$MAX_FILES" '
function flush( i, j) {
# Only count commits that touch a plausible feature-sized set of files.
if (n >= 2 && n <= max)
for (i = 1; i <= n; i++)
for (j = i + 1; j <= n; j++)
# Emit each pair in a stable order so A,B and B,A count together.
if (f[i] < f[j]) print f[i] "\t" f[j]; else print f[j] "\t" f[i]
n = 0
}
/^@@commit$/ { flush(); next }
# Lockfiles change with everything and hide real coupling.
/(package-lock\.json|yarn\.lock|go\.sum|poetry\.lock)$/ { next }
NF { f[++n] = $0 }
END { flush() }
' \
| sort | uniq -c | sort -rn | head -n 25
# Read the output against your proposed modules:
# high count, different modules -> boundary is probably in the wrong place
# files in one module that never appear together -> module may hold two concepts
A git-log and awk script that ranks file pairs by how often they change in the same commit, skipping merges, bulk commits and lockfiles, so proposed module boundaries can be checked against real change patterns.
Data ownership: the hardest boundary and how to find violations
Code boundaries are cheap to fake. Data boundaries are not. If the billing module joins directly against orders and customers, it has no clean boundary: it depends on another module's private schema, and any column rename or table split breaks it silently. The rule is that each module owns its tables or schema outright. Other modules never join against them. Cross-module reads go through the owning module's API or through a read model that the owner publishes and keeps current.
Violations hide in raw SQL strings, ORM relationship mappings, and shared repository classes. To find them, map which module's code touches which table. One option is a static scan that attributes each query and entity mapping to its source package. A stronger check uses per-module database roles: give each module its own credentials and grant it only its own schema. Any cross-module access then fails at runtime, so tests and staging expose the remaining violations.
| Cross-module read | Before enforcing ownership | After enforcing ownership |
|---|---|---|
| Order history with customer name | SQL join | API call or a local copy of customer names |
| Consistency | Immediate, single transaction | Eventual, if a read model is used |
| Ad hoc reporting | One query across everything | Needs a separate path |
The cost is real. Joins turn into API calls or duplicated read models, and reporting that once ran as a single query now spans owners. Most teams end up building a dedicated reporting or analytics path, such as a replicated warehouse, rather than letting reports quietly reintroduce the coupling.
-- PostgreSQL: one login role per module, one schema per module.
-- Placeholder passwords; load real credentials from your secret store.
CREATE ROLE orders_svc LOGIN PASSWORD 'placeholder-orders';
CREATE ROLE billing_svc LOGIN PASSWORD 'placeholder-billing';
-- Close the shared default schema so nothing lands there by accident.
REVOKE ALL ON SCHEMA public FROM PUBLIC;
-- The orders module owns its private schema and a published one.
CREATE SCHEMA orders AUTHORIZATION orders_svc;
CREATE SCHEMA orders_published AUTHORIZATION orders_svc;
CREATE SCHEMA billing AUTHORIZATION billing_svc;
-- Run each module's migrations as its own role so it owns its tables.
SET ROLE orders_svc;
CREATE TABLE orders.orders (
id bigint PRIMARY KEY,
customer_id bigint NOT NULL,
total_cents bigint NOT NULL,
internal_risk_score numeric -- private column, never exposed
);
-- Read model the owner publishes. A view runs with its owner's
-- privileges, so readers need no access to orders.orders itself.
CREATE VIEW orders_published.order_summary_v1 AS
SELECT id, customer_id, total_cents FROM orders.orders;
GRANT USAGE ON SCHEMA orders_published TO billing_svc;
GRANT SELECT ON orders_published.order_summary_v1 TO billing_svc;
RESET ROLE;
-- Run against staging to prove the boundary holds:
SET ROLE billing_svc;
SELECT id, total_cents FROM orders_published.order_summary_v1; -- allowed
SELECT * FROM orders.orders; -- ERROR: permission denied for schema orders
RESET ROLE;
PostgreSQL roles and schemas that give each module access only to its own tables plus the read model another module explicitly publishes, so cross-module joins fail at runtime instead of slipping through.
How to implement explicit contracts and enforce them mechanically
A module's public contract should look like something you would be willing to send over a network. That means passing DTOs or value types, not ORM entities. An entity carries lazy-loading proxies, a live session and mutable state, so callers end up depending on your persistence model without realizing it. Return identifiers or small summaries rather than object graphs. If orders hands back a Customer with navigable addresses, every caller is one property access away from a query you own. Watch for chatty call sites too. A loop that calls pricing.priceFor(item) per line item costs microseconds in-process and becomes an N+1 network problem after extraction. Design batch operations like priceFor(items) now, while the change is cheap.
Conventions alone erode, so enforce the boundary mechanically, in layers:
- Language visibility. Use package-private classes in Java,
internalin Kotlin or C#, or a module system that exports only an API package. - Separate build modules. If
billingdoes not declare a dependency oninventory-impl, the compiler refuses the import. - Architecture tests in CI. Dependency-rule checks fail the build on forbidden edges and catch what visibility misses, such as cycles or reflection-based access.
Rules decay when nobody owns them. Each suppression or allow-list entry should have a named owner and an expiry, and someone should review the list regularly.
Treat the shared kernel the same way. A common library that starts with money types and ends up holding domain enums, validation rules and base entities couples every module to every release. That is as binding as a shared table, and harder to see.
package com.example.shop;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import jakarta.persistence.Entity;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
// Runs as part of the normal test suite, so a forbidden edge fails the CI build.
@AnalyzeClasses(packages = "com.example.shop", importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleBoundaryTest {
// Other modules may only use inventory's api package, never its internals.
@ArchTest
static final ArchRule inventoryInternalsArePrivate = noClasses()
.that().resideOutsideOfPackage("..inventory..")
.should().dependOnClassesThat().resideInAPackage("..inventory.internal..");
// Public contracts carry DTOs and IDs, not ORM entities with live sessions.
@ArchTest
static final ArchRule apisDoNotExposeEntities = noClasses()
.that().resideInAPackage("..api..")
.should().dependOnClassesThat().areAnnotatedWith(Entity.class);
// Cycles between top-level modules make later extraction impossible.
@ArchTest
static final ArchRule modulesAreAcyclic = slices()
.matching("com.example.shop.(*)..").should().beFreeOfCycles();
// Keep the shared kernel small: no domain dependencies, no persistence types.
@ArchTest
static final ArchRule kernelStaysGeneric = classes()
.that().resideInAPackage("..common..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage("..common..", "java..");
}
An ArchUnit test, run in CI, that fails the build when a module imports another module's internals, when a public API exposes JPA entities, when modules form a dependency cycle, or when the shared common kernel starts depending on domain or persistence code.
Cross-module transactions, in-process events and network semantics
To find workflows that depend on one transaction spanning modules, look for every transaction scope whose body writes through more than one module's repositories or tables. Those workflows are where extraction would break atomicity. For each one, ask whether the business requires both writes to succeed or fail together. If it does, as with debiting a balance and recording the ledger entry, the two pieces likely belong in the same module. If a short delay is acceptable, as with reserving stock after an order is placed, move the second step to eventual consistency.
To make that move, have the owning module write its state change and a domain event to an outbox table in the same local transaction. A dispatcher then reads the outbox and delivers each event through an in-process bus or mediator. Publishers depend only on the bus and the event type, so they never reference their consumers. That indirection is what later lets you replace the dispatcher with a message broker without touching publishers.
Practice network semantics now, before there is a network:
- Handlers are idempotent. Each handler records processed event IDs, so redelivery is harmless.
- Event IDs are stable. An ID is assigned once at write time, never regenerated on retry.
- Contracts are versioned. Event schemas evolve additively, and consumers tolerate unknown fields.
- Timeouts and partial failure have a defined meaning. Each boundary call specifies what happens when it fails: retry, compensate, or surface the error.
This has a real cost. You take on duplicate delivery, out-of-order events and stale reads that a single transaction would have prevented. Apply it only to boundaries you expect to split. Elsewhere, keep direct calls, and remember that in-process calls make latency and failure invisible until the day they cross a network.
A per-module readiness checklist and scoring extraction candidates
Score each module against six yes/no checks, one point each:
- Owns its data. No other module reads or writes its tables.
- Explicit API. Callers use a published interface, never internal classes.
- No cyclic dependencies. It depends on others, but nothing it depends on depends back on it.
- No cross-module transactions. Its writes commit independently.
- Isolated tests. Its suite runs with other modules stubbed.
- Nameable config and secrets. You can list every setting and credential it needs.
These are binary because each one is a blocker. A module with a cycle cannot be deployed separately, however clean its API is.
Illustrative scoring (hypothetical modules)
| Module | Data | API | Acyclic | No shared tx | Isolated tests | Config | Score |
|---|---|---|---|---|---|---|---|
| Notifications | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 6 |
| Catalog | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | 5 |
| Billing | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | 2 |
Notifications is the obvious candidate. Catalog has one specific gap to close. Billing needs refactoring before extraction is worth discussing.
What extraction looks like for a passing module
First, implement the existing interface with a remote client. Callers don't change, and a configuration flag picks the in-process or remote implementation. Next, move the module's tables to their own database. Since nobody else touches them, this is a data copy and cutover, not an untangling job. Finally, shift traffic to the remote implementation gradually, so you can compare behaviour and roll back by flipping the flag. The design work was done during modularization. What remains is mostly infrastructure: deployment, networking, observability.
When to avoid it, alternatives, and impact on cost and engineering time
…pipelines, service discovery, and observability. None of that requires redesigning the module.
When readiness work is not worth it
Readiness has a cost, and sometimes it buys nothing. Defer or skip it in these cases:
- One small team owns the whole codebase. There is little coordination cost for boundaries to reduce.
- The domain is still shifting. Boundaries drawn now will likely be wrong soon, and enforced boundaries make that mistake expensive to correct.
- You have no plausible reason to deploy a module separately.
- A workflow needs atomic updates across two would-be modules. Splitting them, only to rejoin them through compensating actions, is pure loss. Keep them in one module.
| Option | Upfront effort | Operational cost | Future flexibility |
|---|---|---|---|
| Loosely structured monolith | Low | Low | Low: every extraction starts with untangling |
| Modular monolith as end state | Medium | Low | High |
| Straight to services | High | High: network, deploys, per-service observability | Medium: boundaries frozen early |
| Extract one hot path only | Medium, localized | Moderate: one extra service | Targeted |
The costs are upfront refactoring, CI enforcement tooling, read models that replace cross-module joins, outbox machinery, and friction whenever code must move across an enforced boundary. The returns are parallel work, fewer cross-team conflicts, and a later extraction that is cheaper and less risky.
Illustrative estimate (assumed numbers, not a benchmark): Net hours = (conflicts avoided per month × hours per conflict × months) − (upfront hours + upkeep per month × months)
Assume 8 conflicts per month, 3 hours each, over 12 months: 288 hours saved. Assume 200 hours upfront plus 5 hours per month of upkeep: 260 hours spent. The net is 28 hours. That is roughly break-even, so the decision depends on team count and on whether extraction is likely.
Readiness does not commit you to migrating. A well-modularized monolith is a legitimate end state.
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 microservices ready monolith, 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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.