Repository navigation
ADR 0002 Module Boundaries
状态:草稿(待 Haisu 评审)
日期: 2026-07-26
作者: Claude Code
关联任务: RND-224(父任务 RND-212)
| 版本 | 日期 | 变更内容 |
|---|---|---|
| v1 | 2026-07-26 | Initial draft - Architecture boundaries & composition root enforcement |
The WeCom Archive codebase has evolved with a clear architectural intent expressed in DEV_AGENT_RULES.md:
-
Service layer (
backend/app/services/): Business logic, domain services, worker functions -
Router layer (
backend/app/routers/): HTTP endpoints, request/response handling -
Composition root (
backend/app/main.py): App bootstrap, dependency injection
However, this boundary is not enforced at the CI level. There's no automated check to detect:
- Service modules importing from routers (reverse dependency)
- Router modules importing from main (circular dependency)
- main.py containing inline business logic, database queries, or HTML/CSS/JS
This leads to:
- Violation of separation of concerns
- Increased coupling between layers
- Difficulty in refactoring (e.g., RND-218~223 would be harder without explicit guards)
- "Main.py creep" where business logic accumulates in the composition root
Why this matters beyond code hygiene (motivation revision, 2026-07-24
11:49): the primary risk this ADR guards against is AI-agent context bloat,
not just style. RND-212 split business logic out of main.py/an all-in-one
router specifically so an AI agent working on one feature doesn't have to
load an oversized, unrelated module into context. If business logic flows
back into main.py or a reverse "service→router" dependency forms, that
regression re-inflates the context every future agent conversation has to
load — this ADR and its guardrail test exist to make that regression fail
CI instead of silently accumulating.
The following dependencies are strictly forbidden:
service/domain → routers ❌
routers → main.py ❌
main.py → business logic / DB queries / HTML-CSS-JS ❌
Allowed patterns:
routers → services/domain ✓
services/domain → db/models, schemas, other services/domain ✓
main.py → routers, services, db (composition root sees everything) ✓
"Service/domain layer" is broader than app/services/. The RND-212
chain landed a mixed layout: a app/services/ package (listing_service,
timeline_service, media_access, sync_worker, decrypt_worker,
media_worker) plus a set of flat, single-file domain modules living
directly under app/ (e.g. media_download.py, media_storage.py,
structured_message_parser.py, message_type_registry.py, auth.py,
reachability_audit.py, wecom_contacts.py, web/, sdk/). Both count as
"service/domain" for the reverse-dependency rule. The explicit, current
list lives in the _FLAT_SERVICE_MODULES constant in
backend/tests/test_architecture_boundary.py — that constant, not this
prose, is the source of truth when the two disagree.
Two module pairs share a basename across layers and must be classified by
full dotted path, never by basename: app.auth (service) vs.
app.routers.auth (router), and app.reachability_audit (service) vs.
app.routers.reachability_audit (router). The guardrail test's layer_of()
classifier and its unit tests exist specifically to keep this
disambiguation correct as the codebase grows.
Exceptions: a narrow, explicit ALLOWED_EXCEPTIONS set in the
guardrail test can whitelist a specific (importing_module, imported_module) pair. It is empty by default; any addition requires
Haisu's approval, an inline reason in the test, and generally its own ADR.
main.py must contain only:
- FastAPI app instantiation
- Dependency overrides/configuration
- Health probe routes (
/health,/health/live,/health/ready)
Any business route registration, database query, or HTML/JS embedding is prohibited.
Simple queries or simple CRUD directly inside a router are allowed — not every endpoint needs a dedicated service. Splitting is driven by functional cohesion (one domain = one service + interface), never by file size: no line-count, function-length, or route-body-length threshold exists in the guardrail test, and none should be added. This is a deliberate non-goal (see the RND-224 ticket's explicit "no mechanical line-count gating" requirement) — over-fragmenting a small router into services it doesn't need would itself work against the context-budget motivation in §1, by forcing an agent to load more files to understand one feature, not fewer.
We implement pure stdlib-only architecture tests in backend/tests/test_architecture_boundary.py:
- Uses
ast+pathlib— no third-party dependencies - Classifies every module into a layer via an explicit
layer_of()rule table (see §2.1), then scans the wholeapp/tree for reverse dependencies and scansmain.pyfor route/query/inline-markup violations - Includes synthetic positive/negative test cases (against real production functions, not a parallel reimplementation) to prove detection actually fires and doesn't false-positive on legitimate patterns
The test runs automatically via:
cd backend && python -m pytest tests/test_architecture_boundary.py -qThis is part of the full make verify command, ensuring CI catches violations before merge.
- Architectural clarity: The layered structure becomes hard-enforced, not just documented
- Refactoring safety: Future refactors (like service extraction) have clear guardrails
- No new dependencies: Pure stdlib implementation keeps the project lightweight
- Fast feedback loop: Architects see boundary violations immediately in CI
-
Documentation alignment:
DEV_AGENT_RULES.mdnow has corresponding executable spec
- False positives risk: Complex but legitimate patterns might be flagged (mitigated by test coverage)
-
Explicit-list upkeep: because the service/domain layer includes flat modules under
app/(not justapp/services/), a new flat domain module must be added to_FLAT_SERVICE_MODULESto be covered — an omission fails open (unenforced), not closed. This is a deliberate trade-off (see §2.1's "other" fallback) but means the list needs occasional maintenance as the codebase grows - Limited expressiveness: AST-based checking can't understand semantic relationships (acceptable given scope)
This task does not:
- Add any third-party dependencies
- Modify existing runtime behavior
- Require migration scripts
- Change API contracts
The existing codebase already conforms to these rules after RND-218~223; this simply codifies them.
Approach: Leverage established Python architecture testing tools.
Rejected because:
- Requires adding third-party dependency to
requirements.txt - Tool configuration overhead (YAML configs, profile definitions)
- Overkill for simple dependency graph checking
- Violates the principle of minimal dependencies
Approach: Run lint-checks via pre-commit, not CI.
Rejected because:
- Local hooks don't catch PR submissions from forks
- Not deterministic across developer environments
- CI is the single source of truth for quality gates
Approach: Rely on human review to catch violations.
Rejected because:
- Not scalable as team grows
- Human error inevitable
- Inconsistent enforcement over time
- Doesn't support the "shift-left" QA philosophy
Approach: Custom pure-Python checks using ast and pathlib.
Chosen because:
- Zero dependencies — aligns with project simplicity goal
- Full control over rule definition
- Transparent and understandable implementation
- Fast execution (< 1s on entire backend)
- Easy to extend with new patterns if needed
- Related work: RND-224 (this ADR), parent RND-212 (modularization chain); these are historical migration identifiers, while GitHub Issues are the active tracker.
-
Implementation:
backend/tests/test_architecture_boundary.py -
Rule Documentation:
DEV_AGENT_RULES.md§ Architecture Boundaries,docs/AGENTS.md§ Architecture Boundaries, rootAGENTS.md - Related Tasks: RND-218 (legacy routing out of main), RND-219 (listing service), RND-220 (timeline resolution), RND-221 (media access service), RND-222 (worker functions), RND-223 (App Factory + typed settings — this ADR codifies the boundary the chain converged on)
Last updated: 2026-07-26