ADR-015: File-size threshold + modular split policy
Date: 2026-05-25 Status: Accepted Author: Rômulo Febasi
Context
src/mcp/server.rs crossed 870 lines during the v0.3 sequence
(tool args + tool handlers + resource enumeration + prompt rendering
- freshness enrichment + federation helpers, all in one file). The
trajectory is clear: every new MCP feature lands here, and the file
keeps growing. Beyond a certain point the file stops being readable:
code review costs explode, regressions multiply, new contributors
spend hours mapping responsibilities, and the temptation to “just add
one more
if” wins over proper layering.
The project already has clear architectural principles (BR-1.4 source-of-truth, BR-6.1 atomicity, the edge/core/data layering in ARCHITECTURE.md). What it lacks is an operational rule that triggers refactor before a file becomes a problem, not after.
Decision
Adopt a two-tier size threshold for source files:
| Tier | Lines | Action |
|---|---|---|
| Yellow | 500-800 | A reviewer must call out the file in the next PR that touches it. Refactor proposal welcome but not forced. |
| Red | 800+ | A refactor issue must be opened and linked from any PR that adds further code to that file. New code in a red file blocks merge unless it (a) already removes lines, or (b) is part of the refactor itself. |
The threshold is heuristic, not dogmatic, a 600-line file of
schemars-derived argument structs is fine; a 600-line file of
braided business logic is not. Reviewers apply judgement informed by
cohesion, not just line count.
Refactor playbook
When a file enters the red tier, the refactor follows this order:
- Inventory. List the responsibilities the file currently
carries. One bullet per concern (e.g. for
mcp/server.rs: tool arg shapes, tool handlers, resource list, resource read, prompt list, prompt render, freshness enrichment, federation routing, blocking-task helper). - Group. Cluster the bullets into 3-5 cohesive modules. Each module exports a public surface that the others depend on; the inter-module graph must be a tree, not a cycle.
- Open an issue + ADR in the same milestone. The issue lists the new module tree; the ADR records the architectural intent so the next maintainer knows why the split landed where it did.
- Land in one commit per module where possible, smallest first.
Each commit must pass
cargo clippy --all-targets -- -D warningsandcargo test. No big-bang. - Document. Update
docs/ARCHITECTURE.md(module tree) anddocs/CONTRIBUTING.md(file-size policy section) so the rule propagates beyond this ADR.
Architectural principles to respect
- Single Responsibility per module: a file does one thing well.
- Dependency Inversion at boundaries: handlers receive
&AppDeps, never construct the concreteServerHandler. - Use-case isolation: each MCP tool / each CLI command / each core operation lives in a file you can read without scrolling.
- Edge / core / data layering remains the spine (ARCHITECTURE.md §1). Modular splits land within a layer; never across.
Consequences
- + New code lands in small, reviewable files. Cohesion stays high without anyone having to remember the rule mid-PR.
- + Onboarding gets cheaper: the module tree maps directly to the feature list.
- + The “red tier blocks merge” rule prevents the death-by-1000- ifs failure mode that big files trend toward.
- − More files to navigate. Mitigated by clear naming and the module tree in ARCHITECTURE.md.
- − Some refactors will touch a lot of files at once. Mitigated by the “smallest first” landing order: bisect stays meaningful.
- − The tiers are heuristic and will need revisiting. ADR supersedes itself when the project’s median file size shifts.
Immediate follow-up
A v0.4 issue tracks the mcp/server.rs split as the first
beneficiary of this policy. Other current candidates (audit-listing
in cli/audit.rs, the federation fan-out helpers) sit in yellow
tier and stay as-is until the next PR touches them.
Playbook: what to do when the tier moves
The four-row patterns covered so far. Add a row when a new shape shows up.
| Symptom | Pattern | Reference |
|---|---|---|
A subcommand dispatcher (syn at, syn audit, syn ws, …) crosses 500 lines because the verbs keep growing | Convert to a mod.rs + one file per verb under cli/<name>/. Helpers shared by ≥ 2 verbs go in a sibling <helpers>.rs. Helpers shared by 1 verb live with their caller. | cli/at/ (SYN-152), cli/audit/ (SYN-153) |
A macro-bound impl block (rmcp #[tool_router], #[tool_handler]) blows the budget because each new tool has to land in the same impl | Dispatcher pattern: keep the #[tool] shim thin (3–15 lines, parse args → call core → wrap result); move all logic into core::<facet>::* modules outside the macro’s reach. When the impl ServerHandler block also has to leave the file, use #[tool_router(vis = "pub(crate)")] so its companion macro in a sibling module can still see the generated method. | mcp/server.rs + core/mcp/* (SYN-151) |
| A use case grows because it gained an optional async branch (progress, cancellation, sampling) | The async/transport-bound work stays in the shim at the transport boundary (it imports rmcp / tokio / signal types). The pure business work moves to core::<facet>::* returning Result<serde_json::Value> or a small Synapse type. | core::mcp::{reindex,federated_search,replay_proposal} (SYN-151) |
| The yellow-tier file has tests that exercise an internal helper | The tests stay next to the helper they exercise (keep test files small too: same tiers apply). When the helper moves, the test moves with it. | cli/at/tree.rs host of count_delta_between_two_commits (SYN-152) |
Enforcement (SYN-154)
A CI job file-size (in .github/workflows/ci.yml) and the
local .githooks/pre-push hook both run
scripts/check-file-sizes.sh. Yellow tier emits a GitHub Actions
warning annotation; red tier emits an error annotation and exits
non-zero so the PR cannot merge until the file is split.
The lint exists because a human-only enforcement of this ADR was
silently violated within two weeks of being written: SYN-150
reduced mcp/server.rs from 1380 → ~550, and by v0.4 ship two
new MCP features had brought it back to 844. Automation is the
fix that survives the next contributor.
Alternatives considered
- Hard line-count gate enforced by CI (e.g.
tokei+ fail). Rejected: too rigid; punishes legitimate cases (long arg-struct files) and misses cohesion-based bloat. - No policy, refactor when it hurts. Rejected; that is the
status quo and it just produced the 870-line
mcp/server.rs. The threshold exists precisely so the conversation happens before pain. - Refactor on every PR that touches the file. Rejected: too expensive; the yellow tier is the right calibration: visible but not blocking.
References
src/mcp/server.rs: the file whose growth triggered this ADR.docs/ARCHITECTURE.md§Module tree: primary documentation site for the resulting splits.docs/CONTRIBUTING.md§File-size policy: contributor-facing summary of the same rule.