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 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 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' 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.
Related Jump to heading
- Conflict Prevention by Design — the parent topic and the four collision shapes.
- Detecting Conflict-Prone Files From History — the measurement in full.
- Path-Based CODEOWNERS in a Monorepo — making the ownership boundaries explicit once they exist.