What does monolith ownership actually mean, and what can CODEOWNERS express?

When engineers say a team "owns" part of a monolith, they usually mean three different things at once. Separating them early prevents most ownership disputes later.

Concept What it is Where it lives Question it answers
Review-routing rule A path pattern mapped to users or teams The CODEOWNERS file Who gets asked to review this change?
Accountable maintainers A named team plus one escalation contact Team docs, module README, on-call records Who decides, and who gets paged?
Module boundary A directory with a declared public interface The code layout and its build or lint rules What may other modules depend on?

CODEOWNERS expresses only the first row. It matches changed file paths against patterns and requests review from the listed owners. Combined with branch protection, it can also block merges until an owner approves. It cannot say who is accountable for an outage, which functions are public, or whether one module is allowed to import another. A tidy CODEOWNERS file on top of a tangled codebase gives you routing without real boundaries.

This guide is for backend and platform engineers working in a single shared repository that several teams commit to. In that setting, teams adopt ownership for three practical reasons:

  • Predictable review routing. A change to billing code always reaches the billing team, whoever opened the pull request.
  • Clear accountability. Each module has a named team and a person to escalate to.
  • Less reviewer hunting. Authors stop reading git blame or asking in chat to find out who should approve.

How does CODEOWNERS pattern matching work?

Each non-comment line in a CODEOWNERS file is a path pattern followed by one or more owners. The pattern syntax is modeled on .gitignore. A leading / anchors the pattern to the repository root. A trailing / matches a directory and everything beneath it. * matches within a single path segment, and ** spans any number of directories. A pattern with no slash, such as *.sql, matches that filename anywhere in the tree. The syntax is only gitignore-like, though. Some gitignore features, such as negation with !, are not supported on every host, so don't assume a .gitignore habit will carry over.

For any changed file, the host evaluates every line, and the last matching line wins. Owners from earlier matches are not merged in; they are replaced. Order is therefore a decision about ownership, not a style choice. Put the broad catch-all first so that every more specific rule below it can override it:

  • * @org/platform-core: the fallback for any file not claimed by a later line.
  • /billing/ @org/billing: overrides the fallback for everything under billing/.
  • /billing/migrations/ @org/billing @org/dba: a later, narrower rule. Migrations need both teams, so @org/billing is listed again.

If the catch-all came last, it would silently reassign the entire repository to platform-core.

Hosts differ on several details, and those details can change over time:

  • the precedence rules themselves
  • support for sections
  • optional owners
  • maximum file size
  • whether an owner's approval counts toward required reviews when their team lacks write access

Check your host's current documentation before relying on any of these.

# CODEOWNERS: the last matching line wins, so go from broad to narrow.

# 1. Catch-all fallback. It sits first so every later rule can override it.
*                       @org/platform-core

# 2. Anything under billing/ belongs to billing. This replaces platform-core for those paths.
/billing/               @org/billing

# 3. A narrower rule further down. Owners are replaced, not merged,
#    so @org/billing is listed again next to @org/dba.
/billing/migrations/    @org/billing @org/dba

# 4. A pattern with no slash matches SQL files anywhere in the tree.
#    It comes after rule 3, so it wins for .sql files under billing/migrations/ too.
#    Here that is intended, because DBAs review all raw SQL.
*.sql                   @org/dba

# How a few changed files resolve (last matching line wins):
#   README.md                         -> @org/platform-core       (only rule 1 matches)
#   billing/invoice.py                -> @org/billing             (rule 2 overrides rule 1)
#   billing/migrations/0042_add.py    -> @org/billing @org/dba    (rule 3 is the last match)
#   billing/migrations/0043_fix.sql   -> @org/dba                 (rule 4 is the last match, so billing drops off)
#   reports/monthly.sql               -> @org/dba                 (rule 4 overrides rule 1)
#
# If you move rule 1 to the bottom, every file above resolves to @org/platform-core.
# Check your host's current documentation for its precedence, section and approval rules.

*An annotated CODEOWNERS file showing that the last matching line wins, which is why the catch-all goes first and why a later broad pattern like .sql can unexpectedly override a narrower directory rule.

How should you structure a monolith so module ownership maps onto paths?

Ownership rules can only be as clean as the directory layout they match against. Give each module one top-level directory, such as modules/billing/ or modules/identity/, and declare its public interface in one place inside it, for example modules/billing/api/. Other modules import only from that interface. Each module then needs a single CODEOWNERS line, and a pull request that reaches into another team's internals is visible in review as exactly that.

Avoid layer-based layouts

Shared models/, services/, or utils/ directories that every team edits have no meaningful owner. If you assign them to one team, that team becomes a review bottleneck. If you assign them to everyone, the rule enforces nothing. Move the code into the module that uses it. If several modules genuinely need it, move it into a platform-owned library with its own declared interface.

Teams, not individuals

Assign every path to a team, not a person. People go on leave, change teams, or leave the company, and a single named owner blocks merges when they are unavailable. Require at least two active reviewers in each owning team so that one absence does not stall work.

Cross-cutting files

Name cross-cutting files explicitly. Otherwise they fall through to a catch-all rule or end up with no owner.

  • Build configuration, dependency manifests, and CI definitions: the platform team.
  • Migrations: the owning module's team plus a database reviewer, because schema changes affect every module that shares the database.
  • Generated code: whoever owns the generator or source schema, not the output.
  • The CODEOWNERS file itself: a small trusted group, because anyone who can edit it can reassign review of every path in the repository.
# .github/CODEOWNERS
# Later matching rules override earlier ones, so general rules come first.
# Every owner is a team. Keep at least two active reviewers in each team.

# Catch-all: unowned paths go to platform, so they are visible, not silently unowned.
*                                   @acme/platform

# One line per module. The module's api/ is its public interface.
# api/ is covered by the module line, so changes to it reach the owning team.
/modules/billing/                   @acme/billing
/modules/identity/                  @acme/identity
/modules/catalog/                   @acme/catalog

# Shared code lives in a platform-owned library with its own interface,
# not in shared models/ or utils/ directories.
/libs/platform/                     @acme/platform

# Cross-cutting: build config, dependency manifests, CI.
/Makefile                           @acme/platform
/go.mod                             @acme/platform
/go.sum                             @acme/platform
/.github/workflows/                 @acme/platform

# Migrations: module team plus database reviewers.
# Both teams are requested, but one approval from either satisfies this rule,
# so require the database sign-off with a separate check if it is mandatory.
/modules/billing/migrations/        @acme/billing @acme/db-reviewers
/modules/identity/migrations/       @acme/identity @acme/db-reviewers
/modules/catalog/migrations/        @acme/catalog @acme/db-reviewers

# Generated code: owned by whoever owns the generator, not by the output's directory.
/tools/codegen/                     @acme/platform
/modules/*/api/generated/           @acme/platform

# Last, so nothing overrides it: only a small trusted group can change ownership.
/.github/CODEOWNERS                 @acme/codeowners-admins

A CODEOWNERS file for a module-per-directory monolith. It assigns each module to a team, names cross-cutting files explicitly, and restricts edits to CODEOWNERS itself to a small trusted group.

How do you enforce code review routing and team boundaries?

A CODEOWNERS file on its own only requests reviews. To make it binding, turn on the branch protection setting that requires approval from a code owner before merging. Without that setting, an approval from any teammate satisfies the merge rule, and ownership becomes advisory.

Next, add a CI check that keeps the file honest. A short script can run git diff --name-only against the merge base to list the changed paths, then match each path against the CODEOWNERS patterns and fail if a file resolves to no owner. A scheduled job can do the same over git ls-files to find unowned areas across the whole repository. The script should also extract every team and user handle from the file and check each one against your organization's directory, failing on any that no longer exist. A deleted team doesn't produce an error. Its paths just stop routing reviews to anyone.

Path ownership controls who reviews a change. It doesn't control what code is allowed to depend on what. Add a module-boundary check using your build tool's visibility rules, an import-restriction lint rule, or an architecture test, so that modules call each other only through declared public interfaces. This prevents a change in one module from quietly reaching into another module's internals.

Finally, keep in mind that CODEOWNERS describes paths, not behavior. A shared config value, a schema migration, or a dependency bump can break a module without touching any of its files. Give those files owners, and rely on boundary checks and tests to catch the effects that review routing can't see.

#!/usr/bin/env python3
# CI check: fail if a changed file has no owner, or if CODEOWNERS names a
# handle that is missing from an exported list of current teams/users.
# Usage: check_owners.py .github/CODEOWNERS known_handles.txt origin/main
# Matching is a simplified gitignore-style approximation; last match wins.
import re, subprocess, sys

def git(*args):
    out = subprocess.run(["git", *args], check=True, capture_output=True, text=True)
    return out.stdout.splitlines()

def to_regex(pat):
    # A leading or inner slash anchors the pattern at the repo root.
    anchored = pat.startswith("/") or "/" in pat.strip("/")
    body = re.escape(pat.strip("/")).replace(r"\*\*", ".*")
    body = body.replace(r"\*", "[^/]*").replace(r"\?", "[^/]")
    # Trailing (/.*)? lets a directory pattern cover everything beneath it.
    return re.compile(("^" if anchored else "^(.*/)?") + body + "(/.*)?$")

codeowners, handles_file, base_ref = sys.argv[1:4]
rules = []
for line in open(codeowners):
    line = line.split("#", 1)[0].strip()
    if line:
        pat, *owners = line.split()
        rules.append((to_regex(pat), owners))

errors = []
# A deleted team raises no error on the host; its paths just stop routing.
known = set(open(handles_file).read().split())
for handle in sorted({o for _, owners in rules for o in owners} - known):
    errors.append(f"unknown owner in CODEOWNERS: {handle}")

# Changed, non-deleted files since the merge base. For a scheduled
# whole-repo audit, replace this list with git("ls-files").
base = git("merge-base", base_ref, "HEAD")[0]
for path in git("diff", "--name-only", "--diff-filter=d", base, "HEAD"):
    owners = [o for rx, o in rules if rx.match(path)]
    if not owners or not owners[-1]:
        errors.append(f"no owner: {path}")

print("\n".join(errors) or "all changed files have valid owners")
sys.exit(1 if errors else 0)

A host-agnostic CI script uses git merge-base and git diff --name-only to list changed files, applies approximate CODEOWNERS matching where the last matching rule wins, and fails on files with no owner or on owner handles missing from an exported directory list.

What should a module maintainer be responsible for?

CODEOWNERS can route a review, but it cannot say what the owning team has agreed to do once the request arrives. Write that agreement down in the module's README or a shared ownership doc so expectations don't depend on who happens to be on call.

Responsibility Default policy Why
Review response First response within one working day Required owner review is a blocking dependency. Slow owners push authors toward bypasses.
Public interface changes Approval from a module maintainer, not just any team member Callers depend on the interface. Someone accountable for compatibility should sign off.
Internal changes Any member of the owning team Keeps routine work from waiting on a few people.
Cross-team disputes Engineering leads of both teams, then a named architecture owner A fixed path stops a PR from stalling between two teams.
Ownership transfer One PR that edits CODEOWNERS, approved by both outgoing and incoming teams Both sides confirm the handoff, and the history records when it happened.

Large-scale changes need their own rule. Examples include a logging API rename, a framework upgrade, or a lint autofix across hundreds of directories. These should not need sign-off from every owner. Designate a small group, typically platform engineers, whose approval satisfies ownership for changes that are mechanical and generated. Require two things in return:

  • Advance notice: announce the codemod with the script and a target date before merging.
  • Separate commits: keep manual fixes out of the generated diff, so owners still review any human-written change through normal routing.

How do you add ownership to an existing monolith and keep it accurate?

Bootstrapping the owner map

Do not write the first CODEOWNERS file from memory. Generate a draft from history: for each top-level module directory, run git shortlog -sn --since="12 months ago" -- <path> to list recent committers, then map those people to their teams. The output is a proposal, not an answer. Heavy committers are sometimes contractors, people who have since moved teams, or someone who ran a one-time rename. Review the draft with each team lead and have them accept or reject each path explicitly.

Start with the directories that change most often. git log --since="6 months ago" --name-only --format='' piped through sort | uniq -c shows churn per file, and you can roll that up to directories. High-churn paths cause most reviewer-hunting, so assigning them first gives the fastest payoff. Track the number of paths with no matching rule and report it each sprint. It should go down steadily. If it stops falling, the boundaries are unclear and no team will claim the remaining code.

Auditing

A stale rule is worse than no rule. It routes reviews to people who ignore them, so approval becomes a formality. Every quarter, list three things:

  • Paths with no owner.
  • Owners with no commits or reviews in those paths for several months.
  • Pull requests whose longest wait was for a code owner's approval.

Each finding calls for an action: assign the path, reassign or merge the boundary, or split a team that owns too much.

Illustrative estimate: suppose 40 engineers each spend 15 minutes a week finding a reviewer. That adds up to about 10 engineer-hours per week, which accurate routing mostly eliminates.

#!/usr/bin/env bash
# Bootstrap and audit helper for CODEOWNERS in a monolith.
# Run from the repo root. Output is a draft for team leads to review, not a final answer.
set -euo pipefail
CODEOWNERS="${1:-.github/CODEOWNERS}"

# 1. Churn per top-level directory over 6 months. Assign owners to the top of this list first.
echo "== Churn by top-level directory (last 6 months) =="
git log --since="6 months ago" --name-only --format='' \
  | { grep '/' || true; } | cut -d/ -f1 | sort | uniq -c | sort -rn | head -20

# 2. Top recent committers per directory, to seed the owner proposal.
#    HEAD is passed explicitly so shortlog does not wait on stdin in scripts.
echo "== Candidate owners (last 12 months) =="
git ls-tree -d --name-only HEAD | while read -r dir; do
  echo "-- $dir"
  git shortlog -sn --since="12 months ago" HEAD -- "$dir" | head -3
done

# 3. Rough unowned count: top-level dirs with no rule that starts with that path.
#    This is a simple prefix check, not full CODEOWNERS glob matching.
#    A catch-all '*' rule hides gaps, so audit without one.
#    Report this number every sprint. It should keep falling.
unowned=0
while read -r dir; do
  if ! grep -Eq "^/?${dir}(/|[[:space:]])" "$CODEOWNERS"; then
    echo "unowned: $dir"
    unowned=$((unowned + 1))
  fi
done < <(git ls-tree -d --name-only HEAD)
echo "unowned_top_level_dirs=$unowned"

A bash script that ranks top-level directories by recent churn, lists the main recent committers in each as candidate owners, and counts directories that have no CODEOWNERS rule so the number can be tracked as a metric.

When should you avoid strict CODEOWNERS ownership, and what are the alternatives?

Strict ownership costs more as a change touches more paths. A rename across five modules needs five approvals, and the slowest owner sets the merge time. Engineers react in two ways. They batch work into large PRs so they only pay the approval cost once, or they push for broad exceptions that weaken the rules. Refactoring suffers most, because changes that improve boundaries are the ones that cross them. A catch-all platform owner for shared directories turns into a queue, and a queue under pressure starts rubber-stamping. Stale entries send reviews to people who have moved on. Hard gates can also create silos, where only owners feel allowed to touch a module.

Use gate-style review (required owner approval) only where a mistake is expensive: authentication, billing, schema migrations, public interfaces. Elsewhere, request owners as reviewers but don't block on them. GitHub's code-owner enforcement is set per branch rule, not per path. Mixing the two styles therefore usually means CODEOWNERS for gated paths and a bot or metadata file for notification-only paths.

Approach Boundary strength Main cost
Notification-only ownership Weak, social Owners can be ignored
Ownership metadata file alongside CODEOWNERS Weak to medium Two files to keep in sync
CODEOWNERS plus boundary checks Medium Review latency, upkeep
Multiple repositories Strong No atomic cross-module changes, split tooling
Full architectural separation Strongest Network contracts, deployment overhead

CODEOWNERS with dependency checks is a middle ground. It controls who reviews a change. It does not control what depends on what at runtime, so it cannot replace architectural separation.

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 monolith ownership, 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.