What CODEOWNERS does in a modular monolith, and who it is for
A CODEOWNERS file is a plain-text map from file paths to owners, which can be users or teams. When a pull request changes files that match a path, the platform automatically requests review from that path's owners. If branch protection is set to require code owner review, the pull request cannot merge until an owner of each affected path approves. That is the whole mechanism: paths in, reviewers out.
In a modular monolith, the useful way to treat this file is as a written record of your module boundaries. Each module directory, such as modules/billing/ or modules/identity/, gets exactly one owning team. The file then answers "who is accountable for this code?" in a form that tooling enforces on every change.
The common failure is to treat CODEOWNERS as a convenience list of whoever reviewed that area last. Entries name individuals, people change teams, and paths stop matching the directory layout after a refactor. The file then routes reviews to the wrong people and stops describing who owns what. Team-based entries that follow module directories avoid most of this drift, because the boundary and the owner change together.
This guide is for backend and platform engineers working in one repository shared by several teams, where module directories already exist or are being introduced.
One limit matters from the start. CODEOWNERS controls review routing and merge approval only. It does not restrict who can read or push code, and it does not stop one module from importing another's internals. You need separate tools for those problems.
Pull request: changed files
+--------------+--------------+
| modules/billing/invoice.py |
| modules/identity/login.py |
+--------------+--------------+
|
v
+-------------------------------------------------+
| CODEOWNERS (path -> owning team) |
| /modules/billing/ @org/billing-team |
| /modules/identity/ @org/identity-team |
+-----------+-------------------------+-----------+
| |
v v
+-----------------------+ +-----------------------+
| review request: | | review request: |
| @org/billing-team | | @org/identity-team |
+-----------+-----------+ +-----------+-----------+
| |
+------------+------------+
v
+-------------------------------------------------+
| Branch protection: require code owner review |
| merge blocked until each affected owner approves|
+------------------------+------------------------+
v
[ merge ]
NOT controlled by CODEOWNERS: read/push access, cross-module imports
CODEOWNERS maps each module directory to one owning team, so changes to a module request review from its owning team and, with code owner review required, cannot merge until those owners approve. It does not govern repository access or module imports.
How matching works: gitignore-style patterns and last match wins
Each line in a CODEOWNERS file is a path pattern followed by one or more owners. Patterns follow most gitignore conventions. A leading slash anchors the pattern to the repository root, so /src/billing/ matches only that directory. Without the slash, billing/ matches a directory named billing anywhere in the tree. A trailing slash makes the pattern match a directory and everything beneath it, recursively. A pattern like /docs/* matches only files directly inside docs, not nested subdirectories. Extension patterns such as *.sql match at any depth. Some gitignore syntax is not supported, including ! negation, so you cannot carve out exceptions with it.
The rule that matters most is precedence. For any changed file, only the last matching line applies. Earlier matches are discarded, not merged. This is why ordering mistakes fail silently: the file still parses, and reviews still get requested, just from the wrong team.
A common broken layout puts module rules first, such as /src/billing/ owned by the billing team. Someone later appends *.ts @org/frontend-platform at the bottom to cover tooling. Every TypeScript file in billing now routes to frontend-platform, and the billing team is no longer asked to review its own code.
The correct order runs from general to specific:
- A global
*fallback first. - Shared areas next, such as build config and common libraries.
- One block per module after that.
- The rule for the CODEOWNERS file itself last, so nothing can override who approves ownership changes.
# --- BROKEN (shown commented out): broad rule appended at the bottom ---
# /src/billing/ @acme/billing
# /src/payments/ @acme/payments
# Added later for tooling. Last match wins, so this line now claims
# every .ts file in the repo, including everything under /src/billing/.
# *.ts @acme/frontend-platform
# --- CORRECTED: .github/CODEOWNERS, ordered general to specific ---
# 1. Global fallback for any path not matched by a later line
* @acme/platform-leads
# 2. Shared areas: build config and common libraries
/package.json @acme/build-tooling
/tsconfig.base.json @acme/build-tooling
# Tooling is scoped to its own directory instead of using *.ts
/tools/ @acme/frontend-platform
/src/shared/ @acme/core-libs
# 3. One block per module. Anchored and recursive.
/src/billing/ @acme/billing
# Nested path listed after its parent so this rule wins for migrations
/src/billing/migrations/ @acme/billing @acme/dba
/src/payments/ @acme/payments
/src/catalog/ @acme/catalog
# 4. Ownership of this file itself, last so no earlier rule can override it
/.github/CODEOWNERS @acme/platform-leads
A CODEOWNERS file showing how a trailing *.ts rule silently overrides module ownership, followed by the corrected order: global fallback, shared areas, per-module blocks, then the rule protecting the CODEOWNERS file itself.
Implementing it: team owners per module, explicit shared areas, and a protected file
Give every module root exactly one owner, and make that owner a team handle such as @org/billing-team rather than a person. Teams survive reorgs and departures: when someone leaves, you change team membership in one place instead of editing dozens of lines. Review requests also go to the whole team, so the load spreads across members instead of piling onto whoever was listed years ago. With one handle per module, it is always clear who decides.
A team only counts as a valid owner if it is visible and has write access to the repository. If either condition is missing, GitHub skips that owner and no review gets routed to it. Check this whenever you add a team.
Shared areas need named owners. Otherwise they fall through to the catch-all, where nobody feels responsible for them.
| Area | Suggested owner | Why it needs its own rule |
|---|---|---|
| Shared kernel / common libraries | Architecture group | Changes ripple into every module |
| Build config and CI definitions | Platform team | Breakage blocks everyone's merges |
| Database migrations | Data or platform team | Often hard to reverse |
| Public API contracts | API or architecture group | External consumers depend on them |
| The CODEOWNERS file | Platform or architecture group | Controls who approves everything else |
After that, enable "Require review from Code Owners" in branch protection for the main branch. Merge flow changes in one way: a general approval no longer unblocks a pull request. Each changed file needs approval from one of its code owners. A PR that touches three modules therefore waits on three teams. This keeps cross-module changes visible, though it can slow them down.
# .github/CODEOWNERS
# The last matching pattern wins, so order runs from general to specific.
# Catch-all: anything not matched below. Keep it small and treat hits as a gap.
* @org/platform-team
# One team per module root. Never an individual.
/modules/billing/ @org/billing-team
/modules/catalog/ @org/catalog-team
/modules/orders/ @org/orders-team
/modules/identity/ @org/identity-team
# Shared kernel: changes ripple into every module.
/shared/kernel/ @org/architecture-group
/shared/common/ @org/architecture-group
# Build config and CI: breakage blocks everyone's merges.
/build.gradle.kts @org/platform-team
/settings.gradle.kts @org/platform-team
/.github/workflows/ @org/platform-team
# Database migrations: often hard to reverse.
# This rule comes after the module rules, so it overrides them
# for migration folders inside modules too.
**/migrations/ @org/data-team
# Public API contracts: external consumers depend on them.
/api/contracts/ @org/api-group
# Protect the ownership map itself. Keep this rule last so nothing overrides it.
/.github/CODEOWNERS @org/architecture-group
A CODEOWNERS file for a modular monolith that orders rules from the catch-all to the specific. Each module gets one team, shared areas get named owners, and the file itself is owned by the architecture group.
Pairing ownership with import boundary checks in CI
Review routing only fires when someone edits a file. CODEOWNERS has no opinion on what a file contains. If a developer in billing adds an import of orders/internal/pricing, only the billing owners are asked to review, because only billing files changed. The orders team never sees that a new dependency on its internals exists. The coupling arrives without anyone objecting, and the next refactor of pricing breaks a module its owners didn't know depended on it.
An import boundary check closes that gap. It doesn't decide who reviews. It decides which dependencies are legal and fails the build when one isn't.
| Concern | CODEOWNERS | Import boundary check |
|---|---|---|
| Question answered | Who must approve this change? | May this code depend on that code? |
| Triggered by | Files touched in the PR | Imports present in the code |
| Catches | Unreviewed edits to a module | Cross-module reach into internals |
| Misses | New dependencies on untouched modules | Who should have looked at it |
A language-neutral CI sketch
The first job enforces module boundaries:
- For each module, define a public surface, such as
orders/api, and treat everything else underorders/as internal. - Parse imports from every source file using your language's tooling rather than regex, so aliases and re-exports resolve correctly.
- Fail if any file outside
orders/imports a path underorders/that isn't in the public surface. Print the offending file and import.
The second job keeps ownership complete. It lists the top-level module directories and checks that each one matches a CODEOWNERS pattern. A new module that falls through to the catch-all rule fails the build, so it can't merge without a named owning team.
#!/usr/bin/env python3
"""CI check: fail when a top-level module directory has no explicit CODEOWNERS rule."""
import sys
from pathlib import Path
REPO = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
# Top-level directories that are not modules and may fall through to the catch-all.
NOT_MODULES = {".git", ".github", "docs", "scripts", "tools"}
def owned_patterns():
# Read the first CODEOWNERS file found in the conventional locations.
for candidate in (".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"):
path = REPO / candidate
if path.is_file():
patterns = set()
for line in path.read_text().splitlines():
parts = line.split("#", 1)[0].split()
# A pattern with no owners removes ownership, so it does not count.
if len(parts) > 1:
patterns.add(parts[0])
return patterns
sys.exit("No CODEOWNERS file found")
patterns = owned_patterns()
modules = sorted(
p.name for p in REPO.iterdir() if p.is_dir() and p.name not in NOT_MODULES
)
# Only an explicit per-module rule counts. Matching the catch-all '*' is the failure case.
missing = [
m for m in modules
if not ({f"/{m}/", f"{m}/", f"/{m}", f"/{m}/**"} & patterns)
]
for m in missing:
print(f"error: module '{m}/' has no CODEOWNERS rule naming an owning team", file=sys.stderr)
sys.exit(1 if missing else 0)
A CI script that lists top-level module directories and fails the build when any of them lacks an explicit CODEOWNERS rule with at least one owner, so a new module cannot merge while it is covered only by the catch-all rule.
Keeping the file healthy and using ownership beyond review
That check catches missing owners. It does not catch owners that are wrong. Stale entries build up in two ways. Teams get renamed, merged, or disbanded, so a pattern ends up pointing at a team with no active members. Modules get deleted or moved, so a pattern matches nothing at all. Neither case breaks a build, which is why both persist. Add a second CI step that runs the check in reverse. Every pattern in CODEOWNERS should match at least one existing path. Then, on a fixed schedule such as once a quarter or after any reorganisation, confirm that every listed team still exists and still works on that code.
The underlying rule is that the module layout and the ownership file change together. A pull request that creates, splits, or moves a module should also edit CODEOWNERS in the same pull request. Reviewers should treat a missing edit the same way they would treat a missing migration.
Once the path-to-team map is reliable, use it for more than review routing:
- Alert routing. Tag errors and metrics with the module that emitted them, then resolve the module to its team.
- On-call paging. Page the rotation that owns the failing module, not a generic backend queue.
- Incident triage. Map the files in a stack trace or a suspect deploy diff straight to a team.
This keeps review ownership and operational ownership the same, so the people who approve changes to a module are also the people who get paged when it breaks.
The time this saves is spent at the worst possible moment. During an incident, or with a stalled pull request, nobody has to search commit history or ask in chat channels to find out who is responsible. One lookup answers the question.
Trade-offs, cost, and when to avoid required code owner review
Ownership has a price, and most of it is paid as waiting time.
| Cost | Why it happens | Mitigation |
|---|---|---|
| Multi-team sign-off latency | Each matched team must approve, so a refactor touching eight modules waits in eight queues | Split PRs by module; agree on refactor windows |
| Fragile file | Last match wins, so a broad pattern added late silently overrides narrower rules | Validate the file in CI and make it owned by the platform team |
| Gatekeeping | Required approval becomes a veto that owners use to block changes | Set review response expectations and rotate reviewers |
| Shared code | One owner gets overloaded, or many owners share the code and nobody is accountable | Name exactly one owning team per shared area |
| Reviewer fatigue | Broad patterns request reviews on trivial diffs, which leads to rubber-stamp approvals | Keep patterns narrow and leave low-risk paths unowned |
| False sense of security | CODEOWNERS routes review requests. It does not restrict who can push code | Use branch protection and repository permissions for access control |
Write a deliberate policy for files that would otherwise trigger every owner:
- Generated output: belongs to whoever owns the generator.
- Lockfiles: belong to one platform team.
- Mass renames and formatting changes: go through an announced PR that a single designated owner approves.
Illustrative estimate (assumed numbers, not a benchmark). Use two formulas:
- Added waiting = PRs × multi-team share × extra wait per PR
- Saved searching = PRs × misroute rate × time spent finding the right reviewer
Assume 200 PRs a month, a 15% multi-team share, 4 hours of extra wait per PR, a 25% misroute rate, and 2 hours to find the right reviewer:
- Added waiting = 200 × 0.15 × 4 = 120 hours
- Saved searching = 200 × 0.25 × 2 = 100 hours
These figures are close to break-even. Waiting hours are mostly calendar time, while search hours are engineer time, so weigh them differently.
Avoid or defer required code owner review in three situations:
- Small repositories owned by a single team
- Modules whose boundaries are still changing
- Periods of heavy cross-cutting refactoring
In these cases, keep the file for review routing and turn on enforcement later.
Alternatives and complements to CODEOWNERS
None of these options replace CODEOWNERS outright. Each covers a different part of the problem: who reviews a change, what the code may depend on, and who answers for a module in production.
| Option | What it enforces | Maintenance cost | Fits best |
|---|---|---|---|
| Informal reviewer conventions | Nothing; relies on habit and memory | Near zero until people leave or teams reorganise | Small teams where everyone knows the codebase |
| Ownership manifest in each module | Nothing by itself, but ownership sits next to the code and moves with it | Low; needs a CI step to generate or check CODEOWNERS from it | Alongside CODEOWNERS, when modules are added or renamed often |
| Service catalog or ownership registry | Nothing at review time; records on-call, escalation and runtime metadata | Moderate; drifts unless it is synced from the repository | Alongside CODEOWNERS, for incident routing and audits |
| Separate repositories | Hard boundaries through repository permissions and versioned interfaces | High: release coordination, dependency upgrades, cross-repo changes | Modules that already deploy independently and rarely change together |
| CI architecture tests alone | Dependency direction and allowed imports | Low to moderate; the rules change as boundaries change | Single-team repositories where routing reviews adds nothing |
A per-module manifest is often the most useful complement. It makes the module the source of truth for ownership, and CODEOWNERS becomes generated output. This avoids a central file that drifts away from the directory structure.
Splitting into separate repositories is the strongest form of enforcement, but it gives up the main reason for running a monolith: atomic changes across modules. Use it only when the module boundary has stayed stable for a long time.
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 codeowners modular 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.
- 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.