What CODEOWNERS ownership means in a modular monolith
In a modular monolith, ownership should follow the domain model, not the repository's history. Work in three steps:
- Identify bounded contexts. Find the areas where a term has one meaning and one team decides how it changes, such as billing, catalog, or fulfillment.
- Give each context one top-level directory. Split each into a public API that other modules may call and internals that no one else should touch.
- Use CODEOWNERS as the enforcement layer. It turns that structure into required reviewers.
The order matters. If you write CODEOWNERS before the boundaries exist, you encode whatever the codebase looks like today. That includes files that landed in a directory because someone was in a hurry, and teams that own paths because of a reorg two years ago. Routing based on git blame or the org chart sends reviews to whoever touched a file last, not to whoever is accountable for the invariant it protects.
When routing matches context boundaries, a change to billing/internal goes to the billing team. A change to billing/api also needs review from the billing team, because it alters a contract other contexts rely on. Code that every context depends on goes to platform.
src/
├── billing/
│ ├── api/ -> @billing-team (contract; changes are visible to consumers)
│ └── internal/ -> @billing-team
├── catalog/
│ ├── api/ -> @catalog-team
│ └── internal/ -> @catalog-team
├── fulfillment/
│ ├── api/ -> @fulfillment-team
│ └── internal/ -> @fulfillment-team
└── shared/ -> @platform-team (kernel, build, cross-cutting infra)
This approach pays off most for backend and platform engineers working in monoliths where several teams commit to one repository and the contexts are already recognizable, even if the code still blurs them.
+------------------+ +--------------------+ +---------------------+
| 1. Identify |--->| 2. One top-level |--->| 3. CODEOWNERS maps |
| bounded contexts | | dir per context | | paths to reviewers |
+------------------+ +--------------------+ +---------------------+
Other contexts may depend on api/ only; internal/ is private to its owner.
+----------------------+ +----------------------+ +----------------------+
| src/billing/ | | src/catalog/ | | src/fulfillment/ |
| +------------------+ | | +------------------+ | | +------------------+ |
| | api/ contract | | | | api/ contract | | | | api/ contract | |
| +------------------+ | | +------------------+ | | +------------------+ |
| | internal/ | | | | internal/ | | | | internal/ | |
| +------------------+ | | +------------------+ | | +------------------+ |
| -> @billing-team | | -> @catalog-team | | -> @fulfillment-team |
+----------------------+ +----------------------+ +----------------------+
| | |
v v v
+------------------------------------------------------------------------+
| src/shared/ kernel, build, cross-cutting infra -> @platform-team |
+------------------------------------------------------------------------+
Bounded contexts come first, each gets one top-level directory split into a public api/ and private internal/, and CODEOWNERS routes each path prefix to its owning team, with shared code routed to platform.
How CODEOWNERS pattern matching and review routing work
A CODEOWNERS file is a list of rules. Each rule pairs a path pattern with one or more owners. The patterns use gitignore-like syntax. A trailing slash matches a directory and everything beneath it. A leading slash anchors the pattern to the repository root. Wildcards match file names or extensions.
When a pull request touches a file, the platform checks that file against every rule. The last matching pattern wins. Earlier matches are discarded rather than merged, so a file has exactly one set of owners at a time.
Why order matters
Because the last match wins, the file should read from general to specific:
- A catch-all default at the top, such as a platform or architecture group.
- Context-level rules below it, such as the billing or identity module directories.
- Any narrower carve-outs inside a context at the bottom.
This ordering is also what makes the file fail quietly. Suppose someone later appends a broad rule near the end, for example a pattern covering all SQL migrations or every file under a shared source root. Every file it matches now belongs to that rule's owners. The billing team's ownership of its own migrations disappears, and nothing warns anyone. The file still parses, and reviews are still requested, just from the wrong people.
Notification versus enforcement
On its own, CODEOWNERS only routes review requests to the listed owners. Approval from those owners becomes mandatory only when branch protection or a ruleset requires code-owner review on the target branch. Without that setting, a misrouted rule costs you visibility. With it, the same mistake can block merges or put the approval decision in the wrong team's hands.
Platforms differ in several details:
- Where the file may live.
- Which pattern features are supported.
- How invalid lines are handled.
- Whether sections or approval counts exist.
Confirm the behavior on your own setup before you rely on it.
# CODEOWNERS for a modular monolith (gitignore-like patterns).
# The LAST matching rule wins, so order rules from general to specific.
# 1. Catch-all default: anything not matched below goes to the platform group.
* @example-org/platform
# 2. Context-level rules: each bounded context owns its module directory.
/src/modules/billing/ @example-org/billing
/src/modules/identity/ @example-org/identity
/src/modules/catalog/ @example-org/catalog
# 3. Narrow carve-outs inside a context go last.
# Billing's public contract also needs architecture review.
/src/modules/billing/api/ @example-org/billing @example-org/architecture
# --- MISTAKE: a broad rule appended at the bottom ---
# This matches every SQL file anywhere, including
# /src/modules/billing/migrations/0042_add_invoice_status.sql.
# Because it comes last, it wins: billing no longer owns its own
# migrations, and nothing warns anyone. The file still parses.
*.sql @example-org/dba
# FIX: move the broad *.sql rule up near the default, directly under
# the catch-all and above the context rules, so the context rules
# override it for files inside each module:
#
# * @example-org/platform
# *.sql @example-org/dba
# /src/modules/billing/ @example-org/billing
# ...
#
# If the DBA team should review billing migrations too, list both
# owners on a specific rule instead of relying on order:
#
# /src/modules/billing/migrations/ @example-org/billing @example-org/dba
*A CODEOWNERS file ordered from a catch-all default to context rules to narrow carve-outs, with a broad .sql rule placed last that silently takes billing's migrations away from the billing team, plus two ways to fix it.
Implementing context-aligned ownership rules
Step by step
- Map contexts and their public interfaces. List each bounded context and the types other contexts are allowed to call or consume. If you can't name a context's public surface, ownership rules will only describe the directory layout, not the domain.
- Give each context one path prefix. Move code so that, for example, everything in Billing lives under
modules/billing/. When a context is spread across several folders, you need many rules to cover it, and each extra rule is a place for gaps and accidental overlaps. - Assign teams, not individuals. A team handle survives vacations and reorganizations. If a context's only owner is a single named person, treat that as a bus-factor risk and fix the staffing, not just the file.
- Tighten contract files. Interfaces, published events, and shared DTOs affect consumers outside the context. Give them stricter or additional owners so a change to a contract gets reviewed from both sides.
- Assign cross-cutting paths deliberately. Build config, dependency manifests, migrations, the shared kernel, and CI definitions should have a named owner, usually platform. Otherwise they fall back to whatever catch-all rule exists.
Annotated rule set
Write broad rules first and specific rules last, so the narrower contract rules take precedence.
| Path pattern | Owners | Why |
|---|---|---|
* |
@org/platform-team |
Fallback, so no file is left unowned |
/modules/billing/ |
@org/billing-team |
One prefix per context |
/modules/billing/api/ |
@org/billing-team @org/architecture-guild |
Changes here affect consumers |
/modules/billing/events/ |
@org/billing-team @org/architecture-guild |
Published events are contracts |
/shared-kernel/ |
@org/platform-team |
Changes affect every context |
/db/migrations/ |
@org/platform-team |
Schema changes carry shared risk |
/build/, /.ci/ |
@org/platform-team |
Build and pipeline integrity |
# CODEOWNERS: last matching pattern wins, so broad rules go first.
# A later match REPLACES earlier owners, so contract rules repeat the context team.
# Owners are always teams, never individuals.
# Fallback: no file is left unowned.
* @org/platform-team
# One path prefix per bounded context.
/modules/billing/ @org/billing-team
/modules/orders/ @org/orders-team
/modules/shipping/ @org/shipping-team
# Contract files: public interfaces, published events, shared DTOs.
# Adding the guild means contract changes get reviewed from outside the context too.
/modules/billing/api/ @org/billing-team @org/architecture-guild
/modules/billing/events/ @org/billing-team @org/architecture-guild
/modules/billing/dto/ @org/billing-team @org/architecture-guild
/modules/orders/api/ @org/orders-team @org/architecture-guild
/modules/orders/events/ @org/orders-team @org/architecture-guild
# Cross-cutting paths: deliberate owners instead of the fallback rule.
/shared-kernel/ @org/platform-team @org/architecture-guild
/db/migrations/ @org/platform-team
/build.gradle.kts @org/platform-team
/settings.gradle.kts @org/platform-team
/gradle/ @org/platform-team
/.github/workflows/ @org/platform-team
# Protect the ownership rules themselves.
/.github/CODEOWNERS @org/platform-team @org/architecture-guild
An annotated CODEOWNERS file that maps each bounded context to one team-owned path prefix, adds architecture-guild owners on contract directories, and gives cross-cutting paths explicit platform owners. Rules are ordered so the later, more specific patterns take precedence.
Pairing CODEOWNERS with architecture tests and review policy
A CODEOWNERS file decides who reviews a change. It does not decide what the change is allowed to do. If a billing engineer adds an import from orders/internal/ in a file billing owns, the orders team is never asked to review it, because none of their paths changed. Boundaries are enforced by module dependency or architecture tests that fail the build when a context reaches into another context's internals instead of its published interface. Ownership routes the review, and the test blocks the illegal edge regardless of who approved.
Required approval vs. notification
Require approval where a bad change is expensive to undo: public interfaces between contexts, schema migrations, and shared kernel code. Use notification for a context's internal implementation when the owning team is the main author anyway. Required approval on every path turns cross-team fixes into queues, and engineers learn to route around them. If your platform cannot express "notify only," keep those paths out of CODEOWNERS and use a separate subscription or watch mechanism.
CI hygiene for the file itself
Treat CODEOWNERS as code and check it in CI:
- Every top-level module directory matches at least one rule.
- Every referenced team or user exists and has access to the repository.
- Paths that match no rule are reported, so new directories don't go unowned without anyone noticing.
- The file is part of the change whenever a context is split, merged or moved.
The check below lists each top-level module directory, searches CODEOWNERS for a matching path, and exits non-zero if any directory has no rule.
#!/usr/bin/env bash
# Fail CI if any top-level module directory has no explicit CODEOWNERS rule.
# Usage: ./check-codeowners.sh [module-root] (default: modules, no leading ./)
set -euo pipefail
MODULE_ROOT="${1:-modules}"
# Use the first CODEOWNERS file found in a conventional location.
CODEOWNERS_FILE=""
for f in .github/CODEOWNERS CODEOWNERS docs/CODEOWNERS; do
if [ -f "$f" ]; then CODEOWNERS_FILE="$f"; break; fi
done
if [ -z "$CODEOWNERS_FILE" ]; then
echo "No CODEOWNERS file found" >&2
exit 2
fi
# Keep only the path column of non-comment lines, then normalize
# "/modules/orders/**", "/modules/orders/" and "modules/orders" to "modules/orders".
# A catch-all "*" is deliberately not treated as ownership: it would hide new, unowned modules.
patterns=$(awk '!/^[[:space:]]*(#|$)/ {print $1}' "$CODEOWNERS_FILE" \
| sed -e 's#^/##' -e 's#/\*\*$##' -e 's#/\*$##' -e 's#/$##')
missing=0
for dir in "$MODULE_ROOT"/*/; do
[ -d "$dir" ] || continue # glob matched nothing
module="${dir%/}"
if ! printf '%s\n' "$patterns" | grep -Fxq "$module"; then
echo "UNOWNED: $module (add a rule to $CODEOWNERS_FILE)"
missing=1
fi
done
exit "$missing"
A platform-neutral shell check that lists each top-level module directory, looks for an exact matching path rule in CODEOWNERS (ignoring catch-all patterns), and exits non-zero if any module is unowned.
When to use it and when to avoid it
Context-aligned ownership pays off when two conditions hold: the bounded contexts have stopped moving much, and each one lives in its own top-level directory. In that case a CODEOWNERS entry is a single path per context. Review routing follows the same boundaries teams already use when they plan work.
Avoid strict rules, or keep them advisory, in three situations:
- The domain model is still evolving. If you require approval before boundaries settle, each context split or merge has to be negotiated across teams. Ownership then hardens a boundary that should still be cheap to move.
- Code is not organized by context. If billing logic is spread across
services/,models/andutils/, the rules turn into long file-level lists. Every new file falls outside them until someone remembers to update the list, so the lists drift. - Teams are too small. A three-person team that owns four contexts becomes a bottleneck for required reviews on most pull requests.
Granularity is the harder decision:
| Granularity | What goes wrong | Typical symptom |
|---|---|---|
| Coarse (one owner per large area) | One team is notified about everything | Reviews are rubber-stamped or ignored |
| Fine (owners per subpackage) | Responsibility fragments and required reviewers multiply | A small change waits on three teams |
Start with one owner per context. Add a finer rule only when a subdirectory has a different set of reviewers in practice, not because the code has a different module name.
Impact on cost and engineering time
Ownership rules cost something. Every required approval adds review latency, and the cost is not spread evenly. The owners of the shared kernel and the database migrations directory sit on many PRs, so they become the bottleneck first. Cross-cutting changes hurt most. A framework upgrade, a mass rename or a logging format change touches every context and needs sign-off from every team. You need an agreed bulk-change process, such as a designated approver or a time-boxed bypass. Audit how it is used, because a bypass that is never reviewed becomes the default path.
The time saved comes from three places. Authors no longer have to work out who should review a change. Fewer boundary violations reach main, which avoids later untangling. During an incident, it is clear which team owns the failing module. Routing is not accountability, though. A required reviewer can still approve without reading the change.
Illustrative estimate (assumed numbers, not a benchmark)
Net hours per week = (PRs × minutes saved finding a reviewer ÷ 60) + (violations avoided × rework hours) − (multi-owner PRs × extra wait hours × fraction of that wait that blocks the author)
Assumed inputs:
- 200 PRs per week
- 5 minutes saved per PR finding a reviewer
- 1 violation avoided per week, costing 6 hours of rework
- 30 PRs per week need multiple owners, each waiting 4 extra hours
- 25% of that wait blocks the author
Calculation: (200 × 5 ÷ 60) + 6 − (30 × 4 × 0.25) ≈ 16.7 + 6 − 30 ≈ −7 hours per week. With these inputs, the multi-owner wait is the term that decides the outcome.
Use that term as a design signal. If PRs routinely need three or more context owners, the boundary is likely in the wrong place or a module is missing an interface. Fix the design rather than adding reviewers.
Incremental migration and alternatives
Migrating without a big-bang restructure
Start with a single catch-all rule at the top of the file that assigns every path to a platform or architecture group. Because the last matching pattern wins, more specific context rules added below it take over cleanly, and nothing is ever left unowned.
Next, carve out the contexts whose directories already match a domain boundary, such as a billing package that nothing else writes to. These rules are cheap and low-risk, and they show which teams are ready to own review.
Move tangled code last, one context per release cycle. Relocate files into context-aligned paths in mechanical PRs that contain no behaviour changes. That keeps blame history readable and avoids a long-lived restructuring branch that conflicts with everyone's work.
Alternatives and complements
| Approach | Enforcement strength | Maintenance cost | Fit for an evolving domain |
|---|---|---|---|
| Notification-only ownership (no required review) | Low | Low | Good: boundaries can shift freely |
| Architecture tests alone | High for dependencies, none for review | Medium | Good if tests are updated with the model |
| Ownership documented outside the repo | None | High: drifts silently | Poor |
| Separate services or repositories | Very high | High: deployment and contract overhead | Poor: boundaries are expensive to move |
| Per-file ownership from commit history | Low | Medium: needs periodic regeneration | Poor: encodes who touched code, not who owns the domain |
Notification-only ownership and architecture tests work best as complements to context-aligned CODEOWNERS. Splitting into separate services is worth doing only after a boundary has held steady for a long period.
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 modular monolith codeowners, 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.