atomic.md
raw
· source
A — Atomic
Rule: one responsibility per module. When a layer file passes ~700 lines, split per UI/data domain using the same prefix. No barrel re-exports.
The fourth and final letter of SAMA. Atomic is the rule that keeps every other property honest as the codebase grows. Sorted doesn't help if every file is 5,000 lines. Architecture doesn't help if a single c21_*.ts does fifteen unrelated things. Modeled doesn't help if the test file balloons to match.
One responsibility per module
The smallest version of the rule: a module should answer one question. c32_session.ts answers "how do we sign and verify session cookies?" — that's one question. If you find yourself adding "and also generate access tokens" or "and also handle CSRF", you have a second question and the file should split.
Concretely:
- One model per domain in
c31_*(c31_project_config.ts,c31_blog.ts,c31_games.ts). - One pure-logic concern per file in
c32_*(c32_judge.ts,c32_session.ts). - One I/O target per file in
c14_*(c14_github.ts,c14_forgejo.ts).
When in doubt: read the filename. If you can't say what's inside in one short clause without and, you have an atom problem.
The ~700-line split rule
Lines are a proxy for surface area. Past ~700 lines, even a "single responsibility" file usually contains several sub-domains that each deserve their own atom. The split rule:
When a layer file passes ~700 lines, split per UI/data domain using the same prefix.
The prefix stays — both halves are still the same layer. The suffix changes to name the sub-domain.
c51 split (UI/render layer)
c51_render_layout.ts chrome: renderPage, renderNotFound, escape, htmlResponse
c51_render_projects.ts /projects body builders
c51_render_reports.ts /reports body builders
Same layer (c51), three atoms. Layout holds the chrome that everyone reuses; the per-domain files hold body builders that only one part of the site uses.
c21 split (handlers layer)
c21_app.ts routes literal + dispatcher (the "router")
c21_handlers_agents.ts /agents/* handlers
c21_handlers_auth.ts OAuth handlers
c21_handlers_leaderboard.ts /leaderboard handler
c21_app.ts stays the dispatcher; per-cluster handlers move out the moment the dispatcher would otherwise grow large.
c13 split (database layer)
c13_database.ts connection + dispatcher (when needed)
c13_db_runs.ts runs-table SQL
c13_db_projects.ts projects-table SQL
Same shape: one "core" file, per-domain extensions when growth pushes past the threshold.
The number 700 is not magic. It's the line count at which one experienced reader finds it slower to navigate the file than to remember which of three smaller files holds what they want. If 600 is your number, use 600. The rule is a threshold exists, not exactly 700.
No barrel re-exports
When you split a layer file into multiple atoms, do not create an index.ts (or c51_render.ts, or c21_handlers.ts) that re-exports everything. Consumers import directly from the atom they need.
// good — direct from the atom
import { renderProjectDetail } from "./c51_render_projects.ts";
import { renderReportTile } from "./c51_render_reports.ts";
// bad — barrel hides the dependency direction
import { renderProjectDetail, renderReportTile } from "./c51_render.ts";
Why this matters:
- A barrel re-exports everything in the layer, which means importing one helper drags every other helper's transitive deps into your module's compile graph. Bun and TypeScript both pay for this.
- The grep that proves Sorted relies on direct imports. A barrel makes the grep useless because every cross-file reference goes through the same neutral name.
- Renaming or moving a function inside a barrel-ed layer becomes a coordination problem. Without barrels, the IDE follows the import to one obvious place.
Why ~700 (the rationale)
The threshold is set by what a coding agent can hold in a single tight context window without drift. Past about 700 lines, you start needing to cite line numbers in prompts ("look at the function around line 480") which signals the file has outgrown the agent's working memory. Splitting at the threshold keeps context windows small even for non-trivial domains.
It also keeps token cost bounded. An agent given "edit c51_render_reports.ts" pulls in one bounded file plus its sibling test, and not the entire layer. That's the same constraint the token-saving tips push from the prompt side; Atomic enforces it from the codebase side.
Common mistakes
- "It's still one responsibility, just bigger." Often true at 800 lines, less true at 1,200, and almost never true at 1,800. Re-read the file as if you were new to the codebase. If you'd want a table of contents, you have at least two atoms.
- Splitting along the wrong axis. Split per UI/data domain, not per "type of helper".
c51_render_helpers.ts+c51_render_pages.tsis a worse split thanc51_render_layout.ts+c51_render_projects.ts+c51_render_reports.ts. - Adding a barrel "for ergonomics". The five extra characters in the import path are not a real cost. The barrel is.
- Splitting too eagerly. A 200-line file with two clear functions is fine. Don't split before the threshold; the cost of two files is real, even if small.
What this gives you
- Every file fits in one context window with room to spare for its test.
- Renames and moves stay local — no barrel to update.
- An agent told to "work on the projects domain" reads
c51_render_projects.ts+c21_handlers_projects.ts(if exists) +c31_project_config.tsand that's the entire surface. Three small files, one domain, one trip.