What module ownership in a monolith is, and how Conway's Law shows up without a network
Module ownership in a monolith is an architecture decision, not a line in a CODEOWNERS file. A team owns a capability-shaped module, such as billing, catalog or identity. Ownership covers three things: the public interface other modules may call, the data the module reads and writes, and how the module behaves at runtime, including its errors, latency and background jobs. Everything else inside the module is private. The owning team can change it without asking anyone.
Conway's Law does not need a network to show up. Suppose three teams share one deployable with no enforced boundaries. The module dependency graph will mirror how those teams actually talk. Where two teams coordinate informally, you get direct imports into each other's internals. Where nobody coordinates, you get duplicated helpers. Where one team becomes the bottleneck, its package turns into a hub that everything depends on. The graph is a record of communication paths, whether or not anyone planned them.
The inverse Conway maneuver turns this around. You draw the module boundaries and their interfaces first. Then you assign one team per module. Team communication then follows the paths the dependency graph permits.
This approach suits organizations where several teams commit to one codebase and ship through one deploy pipeline. Teams adopt it for three reasons:
- Autonomy over internals. A team can change its module's internals without coordinating with others.
- Clearer review routing. Changes to a module go to the team that owns it.
- Less cross-team coordination. Teams only need to agree on the public interfaces.
They get these benefits without paying for network calls, distributed transactions or independent service versioning.
+----------- one deployable: one codebase, one deploy pipeline ------------+
| |
| +--------------------+ +--------------------+ +--------------------+ |
| | billing | | catalog | | identity | |
| | owned by Team A | | owned by Team B | | owned by Team C | |
| +--------------------+ +--------------------+ +--------------------+ |
| | PUBLIC INTERFACE | | PUBLIC INTERFACE | | PUBLIC INTERFACE | |
| | charge() |-->| getProduct() |-->| currentUser() | |
| +--------------------+ +--------------------+ +--------------------+ |
| | private internals | | private internals | | private internals | |
| | (changed freely | | (changed freely | | (changed freely | |
| | by owner team) | | by owner team) | | by owner team) | |
| +--------------------+ +--------------------+ +--------------------+ |
| | owned data: | | owned data: | | owned data: | |
| | invoices | | products | | users, sessions | |
| +--------------------+ +--------------------+ +--------------------+ |
| | runtime behavior: | | runtime behavior: | | runtime behavior: | |
| | jobs, errors, | | jobs, errors, | | jobs, errors, | |
| | latency | | latency | | latency | |
| +--------------------+ +--------------------+ +--------------------+ |
| |
| --> calls go only through another module's PUBLIC INTERFACE |
| internals and data stay private: no deep imports, no shared tables |
+--------------------------------------------------------------------------+
Three teams each own a module inside one deployable, covering its public interface, private internals, data and runtime behavior. Cross-team dependencies run only through public interfaces, so the dependency graph reflects the communication paths the boundaries allow.
How to draw ownership units around business capabilities, not layers
Start with the question "which team gets paged when this breaks?" and answer it in business terms. Modules such as billing, catalog and fulfillment map to capabilities that a team can reason about from end to end: the rules, the data, and the endpoints that expose them.
Layer-based splits fail for a structural reason. Most features touch a controller, a model and a persistence path. If one team owns controllers/ and another owns models/, almost every change crosses team lines and needs a cross-team review. Conway's Law then works against you, because the code organizes around handoffs instead of outcomes.
| Split by | Typical feature touches | Who decides a pricing rule |
|---|---|---|
| Layer (controllers, models, utils) | Several teams | Unclear, so it gets negotiated |
| Capability (billing, catalog, fulfillment) | Usually one team | Billing |
Keep the shared kernel small
The platform team owns a shared kernel for auth, logging and common types such as Money or TenantId. Code is admitted to it only if it meets explicit rules:
- At least two capabilities need it.
- It contains no business logic.
- It changes rarely.
- A platform owner approves it.
Without these rules, shared/ turns into the new utils/. Every module ends up depending on it, and nobody can change it safely.
Expect to redraw boundaries
Boundaries drawn early are often wrong because the domain isn't understood yet. Fixing them later has real costs:
- File moves. Moves break naive
git blame, so people need to know about rename detection or--follow. - Open work. In-flight branches conflict with the moved files.
- Ownership. Ownership files and review routing must be renegotiated with the teams involved.
Start with coarse boundaries and split them once the seams are clear.
How to implement enforceable boundaries: public interfaces, visibility and CI checks
Give each module one public entry point: a facade or api package that exposes commands, queries and events, and nothing else. Other modules call that surface. They never reach into its repositories, entities or helpers. The facade is the contract the owning team agrees to keep stable, and everything behind it can change without a cross-team review.
Hide the internals with whatever the language enforces. Go's internal directories, Java package-private classes or separate build modules, and C# or Kotlin internal make an illegal import fail to compile, which is stronger than any convention. Where visibility can't express the rule, add an architecture test or import linter that fails CI when module A imports anything from module B other than B's API package.
Then route reviews. A CODEOWNERS file maps paths to teams, and branch protection can require an owner's approval before merge. This only works once directories match module boundaries. If billing logic is spread across services/, models/ and utils/, path rules either assign the wrong team or end up with dozens of fragile entries.
A tangled codebase will fail these checks on day one. Record the existing violations in a baseline file. CI then blocks only new ones, and the baseline should only shrink.
Expect friction:
- False positives from shared test fixtures or generated code need explicit exclusions.
- Allow-lists grow when exceptions are cheap, so require an owner and a reason for each entry.
- Rules that block legitimate work teach engineers to bypass checks or merge with admin rights. When that happens, treat it as a sign the boundary is wrong.
# check_boundaries.py: fail CI on new cross-module imports that bypass a module's api package.
# Layout assumed: src/shop/<module>/..., public surface at shop.<module>.api
import ast, pathlib, sys
ROOT = pathlib.Path("src/shop")
BASELINE = pathlib.Path("boundary_baseline.txt") # existing violations, may only shrink
EXCLUDE = ("_generated", "testing/fixtures") # explicit exclusions for known false positives
def violations():
found = set()
for path in ROOT.rglob("*.py"):
rel = path.relative_to(ROOT)
if len(rel.parts) < 2 or any(e in rel.as_posix() for e in EXCLUDE):
continue
owner = rel.parts[0] # the module this file belongs to
for node in ast.walk(ast.parse(path.read_text(), filename=str(path))):
if isinstance(node, ast.Import):
names = [a.name for a in node.names]
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
names = [f"{node.module}.{a.name}" for a in node.names]
else:
continue
for name in names:
p = name.split(".")
# Only cross-module imports are checked, and they must go through <module>.api
if len(p) >= 3 and p[0] == "shop" and p[1] != owner and p[2] != "api":
found.add(f"{path.as_posix()} -> {name}")
return found
baseline = set()
if BASELINE.exists():
baseline = {l.strip() for l in BASELINE.read_text().splitlines() if l.strip() and not l.startswith("#")}
current = violations()
new, fixed = current - baseline, baseline - current
for v in sorted(new):
print(f"NEW boundary violation: {v}")
for v in sorted(fixed):
print(f"Fixed, delete this line from {BASELINE}: {v}")
# Fail on new violations, and on stale entries so the baseline is forced to shrink
sys.exit(1 if new or fixed else 0)
A minimal Python import-boundary check that lets cross-module imports through only via each module's api package, tolerates violations recorded in a baseline file, and fails CI on new violations and on stale baseline entries so the baseline only shrinks.
How to own data in a shared database
Code boundaries mean little if every module can still read and write any table. The rule is one owning module per table, or per schema if your database supports schemas. Only the owner writes to those tables, and no other module joins against them. The reason is change cost. A table that three modules query has three sets of assumptions baked into SQL, so the owning team cannot rename a column or split a table without coordinating with everyone. That coupling is the problem ownership was meant to remove.
Other modules get data through the owner's public interface. Take an invoicing query that joins invoices to customers to print customer names. Under ownership, invoicing loads its invoices, collects the customer IDs, and calls something like CustomerDirectory.getSummaries(ids) on the customer module's facade. The join becomes a batched lookup. That adds one query, and in return the customer team can restructure its tables freely. If the call pattern is too slow or too frequent, the owner can maintain a read model, such as a denormalized table or view it populates and documents as a contract. Consumers then depend on that contract, not on internal tables.
Expect this to take a long time. Reporting queries, ad hoc joins and migrations that touch several modules' tables are usually the last things to move. Some will need temporary exceptions. Record each exception in the same allowlist your CI checks use, with an owner and a target date. Exceptions that nobody tracks tend to become permanent.
// customers module: public facade. Its tables are private to this module.
export interface CustomerSummary { id: string; displayName: string; }
export interface CustomerDirectory {
// Batched lookup so callers avoid one query per row.
getSummaries(ids: string[]): Promise<Map<string, CustomerSummary>>;
}
// invoicing module: before, it ran
// SELECT i.*, c.name FROM invoices i JOIN customers c ON c.id = i.customer_id
// which baked the customers schema into invoicing's SQL.
import type { CustomerDirectory } from "../customers";
interface InvoiceRow { id: string; customerId: string; totalCents: number; }
interface InvoiceRepo { findByPeriod(period: string): Promise<InvoiceRow[]>; }
export async function buildInvoiceLines(
period: string,
invoices: InvoiceRepo, // queries only tables invoicing owns
customers: CustomerDirectory, // the only path to customer data
) {
const rows = await invoices.findByPeriod(period);
// Collect distinct IDs so the owner can answer in one query.
const ids = [...new Set(rows.map((r) => r.customerId))];
const byId = await customers.getSummaries(ids);
return rows.map((r) => ({
invoiceId: r.id,
totalCents: r.totalCents,
// Tolerate missing entries; the owner decides what exists.
customerName: byId.get(r.customerId)?.displayName ?? "(unknown customer)",
}));
}
The invoicing module drops its SQL join against the customers tables. It loads its own invoices, then makes one batched call through the customer module's facade to get display names.
Migrating a tangled monolith and extending ownership to runtime
Migrating incrementally
Treat the tangled monolith as a ratchet, not a rewrite.
- Map the current dependency graph. Generate it from static imports, not from architecture diagrams. The diagrams describe intent, while the import graph shows what the code actually does.
- Assign provisional owners. Every module gets a team, even if the fit is poor. An imperfect owner can be reassigned later, but code with no owner never gets cleaned up.
- Record violations in a baseline. Snapshot every existing cross-boundary call into a checked-in file.
- Block new violations. CI fails on anything not in the baseline, so the debt stops growing on day one.
- Burn the baseline down. Each owning team removes its own entries. Track the count per team so progress is visible and attributable.
Ownership at runtime
Owning code without owning its behavior in production is only half of ownership. Extend it to four areas:
- On-call rotations. Each module's owning team carries the pager for that module.
- Alert routing. Alerts route to that team's rotation.
- Error attribution. Tag logs, traces and exceptions with the module name, derived from the entry point or the top in-repo stack frame, so incidents land with the right team instead of a shared triage queue.
- Feature flags. Scope flags to modules so a team can disable its own behavior without a deploy that touches everyone else's code.
Where ownership silently fails
A CODEOWNERS file creates false accountability when the listed team does not also hold the on-call, the roadmap time and the staffing for that code. Reviews get rubber-stamped, and incidents escalate to whoever happens to remember the code.
Reorgs make this worse. Teams dissolve, but their modules stay. Fail CI when a module's owner does not exist in the current team registry, and treat orphaned modules as a reorg deliverable.
When to avoid it, its limits, and the alternatives
Skip module ownership when a single team of five owns everything. Boundaries then add review steps without separating anyone's work. Skip it for a codebase scheduled for rewrite, and for a domain still too unclear to name capabilities. Boundaries drawn too early encode guesses that later fight every change.
Hard limits
Ownership is organizational, not physical. Every team still shares one pipeline and one release cadence, so a broken build or a slow test suite blocks all of them. Runtime isolation is weak. A memory leak, thread-pool exhaustion or a runaway query in one module takes down the whole process. Strict ownership also creates review bottlenecks and silos, which lowers the bus factor. The team holding the shared kernel tends to become everyone's dependency and gets overloaded.
| Approach | Deployability | Isolation | Coordination cost | Operational overhead |
|---|---|---|---|---|
| Collective ownership, unstructured | One unit | None | High, implicit | Low |
| Layer-based teams | One unit | None | High, since every feature crosses teams | Low |
| Module ownership | One unit | Code-level only | Moderate, explicit via interfaces | Low |
| Microservices | Independent | Process and data | Contract negotiation | High |
A module is a real extraction candidate when one of these limits hurts that module specifically. It might need a different scaling profile, its own release cadence or failure isolation. It should also already talk to other modules only through its public interface and own its tables. In that case, extraction mostly means replacing in-process calls with network calls. Clean boundaries make the decision cheap to test and reversible: if the service adds more operational cost than it saves, you can fold it back in without untangling anything.
Impact on development cost and engineering time, and how to measure it
Ownership costs arrive early and savings accrue slowly, so record a baseline before you start enforcing anything.
| Where engineering time goes | Where engineering time comes back |
|---|---|
| Building and maintaining boundary checks in CI | Fewer merge conflicts between teams in the same files |
| One-off refactoring to move code behind public interfaces | Reviews routed to the owning team automatically |
| Extra review when a public interface changes | Smaller code surface each engineer has to understand |
| Ongoing upkeep of the shared kernel | No network, service discovery or cross-service tracing to operate |
Illustrative estimate (assumed numbers, not a benchmark)
Net weekly hours = (misrouted reviews × hours lost each) + (cross-team conflicts × hours each) − weekly boundary upkeep.
Assume 20 misrouted reviews at 0.5 h (10 h), 6 conflicts at 1.5 h (9 h) and 8 h of upkeep. The net is 11 h per week. Payback weeks = one-off refactoring hours ÷ net weekly hours. With an assumed 200 h of refactoring, payback takes about 18 weeks. If your upkeep exceeds your savings, the boundaries are too fine-grained.
Signals to track
- Cross-module change frequency: the share of merged changes that touch more than one module. A persistently high share means a boundary cuts through a capability.
- Correctly routed reviews: the share of reviews where the first reviewer belongs to the owning team.
- Boundary violation count: the number of violations allowed by the CI baseline. It should only go down. Any increase means someone widened the allowlist and should explain why.
- Incidents attributed per module: how many incidents are traced to each module. Incidents that cannot be attributed to a module point to an ownership gap.
Watch the trend over time rather than chasing a fixed target. These signals only matter compared with your own baseline.
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 monolith code ownership teams, 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.