Structuring a codebase to reduce merge conflicts Jump to heading

Some files conflict because two people happened to change the same function. Others conflict because the architecture requires every change to touch them — a route table, a barrel export, a dependency-injection map, a feature-flag list. The second category is predictable, measurable and removable, and removing it is usually an improvement to the code independent of merging. This recipe finds those files and changes their shape, within conflict prevention by design.

When to use this approach Jump to heading

  • One or two files appear in most of your conflict resolutions.
  • Adding a feature requires editing a central list.
  • A file is edited by more people than any module should be.
  • Conflicts recur in the same file across years and across changing personnel.
  • If a file conflicted three times last quarter and never before, that is circumstance rather than structure — leave it alone.

Step 1 — Identify structural collision points Jump to heading

The distinguishing feature is persistence: the same file, across long periods and changing authors.

# Files in the most merge commits, over a long window
git log --merges --since='2 years ago' --name-only --format='' \
  | grep -v '^$' | sort | uniq -c | sort -rn | head -10
# Distinct authors per file — high counts mean the file spans responsibilities
git log --since='1 year ago' --format='@%an' --name-only \
  | awk '/^@/{a=substr($0,2); next} NF{print a "\t" $0}' \
  | sort -u | cut -f2 | sort | uniq -c | sort -rn | head -10
# Verification: does the file still conflict with different people involved?
git log --merges --format='%H' -- src/routes/index.ts | head -5
Structural or circumstantial?A file that conflicts persistently, across years and changing personnel, is a structural collision point and worth reshaping. One that conflicted several times recently is usually a busy month in a coherent module, and restructuring it would make the code worse.Has this file conflicted across years and across different people?yes, persistentlyStructuralreshape itonly recentlyCircumstantialleave it aloneonly during one refactorSchedulingcoordinate sweepsrestructuring a circumstantial conflict produces an interface that exists for no technical reason

Step 2 — Replace registries with discovery Jump to heading

A registry is any file that must gain a line whenever something is added. It is a conflict per feature, by construction.

// Before: src/routes/index.ts — every feature edits this file
import { refunds } from './handlers/refunds';
import { invoices } from './handlers/invoices';
import { webhooks } from './handlers/webhooks';
export const routes = [refunds, invoices, webhooks];
// After: the directory listing is the registry
import { readdir } from 'node:fs/promises';
const dir = new URL('./handlers/', import.meta.url);
export const routes = await Promise.all(
  (await readdir(dir))
    .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
    .map(async (f) => (await import(new URL(f, dir).href)).default),
);
# Verification: adding a handler touches exactly one file
git show --stat HEAD | tail -3

Discovery has a real cost: the wiring becomes implicit, and a handler that fails to load is harder to trace than a missing import. Where that matters, generate the list at build time instead and mark it generated — the conflict disappears either way, and the explicitness survives.

Step 3 — Split files along ownership, not along size Jump to heading

A five-hundred-line file owned by one person is fine. A fifty-line file edited by four teams is a coordination point.

# The candidates: high author count, low line count
git log --since='1 year ago' --format='@%an' --name-only \
  | awk '/^@/{a=substr($0,2); next} NF{print a "\t" $0}' | sort -u | cut -f2 \
  | sort | uniq -c | sort -rn | head -5 | while read -r n f; do
      printf '%2s authors  %4s lines  %s\n' "$n" "$(wc -l < "$f" 2>/dev/null)" "$f"
    done
# After splitting a configuration map into a directory
mkdir -p config/services.d
# one file per owning team, composed at load time
cat config/services.d/*.yaml > /dev/null && echo "composes cleanly"
# Verification: authors per file after the split
git log --since='1 month ago' --format='@%an' --name-only -- config/services.d/ \
  | awk '/^@/{a=substr($0,2); next} NF{print a "\t" $0}' | sort -u | cut -f2 | sort | uniq -c
One shared file against one file per ownerA single configuration map edited by four teams conflicts whenever two of them change it in the same week. Splitting it into a directory composed at load time means each team edits only its own file, and the composition is deterministic.One shared fileOne file per ownerauthors per filefour teamsone teamconflicts per monthseveralnoneeffective config visiblein one placecomposed at loadownership rulesimpossibleone line eachthe one thing the split costs is seeing the whole configuration in a single file

Step 4 — Keep append-only lists out of source files entirely Jump to heading

A list that only grows is better expressed as a directory, and where that is impossible, union merge removes the conflict.

# Migrations: a directory, with ordering from the filename
ls db/migrations/ | tail -3
# Feature flags: one file per flag rather than one map
ls config/flags/ | head -3
# Where the file must stay: union merge, for genuinely append-only content
printf 'CHANGELOG.md merge=union\n' >> .gitattributes
git check-attr merge -- CHANGELOG.md

The union approach and its limits are covered in union merge for append-only files.

Step 5 — Verify the change with the original measurement Jump to heading

A restructure that did not reduce conflicts addressed something else, which is worth knowing.

# Re-run the measurement a quarter later, same window length
git log --merges --since='3 months ago' --name-only --format='' \
  | grep -v '^$' | sort | uniq -c | sort -rn | head -10
# The restructured file should have fallen off the list entirely
git log --merges --since='3 months ago' --name-only --format='' | grep -c 'src/routes/index.ts'
The same file, before and afterA route registry that appeared in most merge conflicts drops to zero once feature handlers are discovered from a directory. The files that replace it in the ranking are ordinary source files with occasional genuine overlap.conflicts per quarter, route registrybefore: central registry23after: directory discovery0next file in the ranking4the third bar is what a healthy distribution looks like: no file dominating

SAFETY WARNING — do not restructure a module during a period when several long-lived branches are open against it. The restructure conflicts with every one of them, and the people resolving those conflicts are reconciling a change they did not make against work they did. Check what is open first, announce the change, and land it when the affected paths are quiet — the procedure is in coordinating a large refactor without conflict storms.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Does runtime discovery hurt performance or traceability? Jump to heading

Traceability more than performance. A directory scan at startup is cheap; the real cost is that a handler which fails to register produces no error at the point of registration. Mitigate it with a startup assertion — the expected count, or a check that every file in the directory produced an entry — so a silent omission becomes a loud failure.

What if the framework requires an explicit list? Jump to heading

Generate it. A list produced by a build step from the directory contents is explicit at runtime and generated at merge time, which gets both properties: the framework sees a static list and the file is resolved by regeneration rather than by hand.

How small should files be? Jump to heading

Small enough to have one owner, which is a different question from line count. A module with a coherent responsibility and one owning team can be large without causing conflicts; a tiny file that four teams must edit will conflict regardless of how neat it is.