God Object
One type with a huge API, a dozen dependencies and a dozen unrelated reasons to change. The finding is the reason count, not the line count — and the fix is rarely a six-way split.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
One class is 3,000 lines and everyone touches it. Is that the problem, or a symptom of one?
Add a "pause subscription" action. The estimate is two weeks, and every part of it lands in AccountManager, which four teams edit and nobody owns.
It is too big. Split it into six classes of five hundred lines each — AccountBilling, AccountAuth, AccountNotifications, and so on — and the problem goes away.
Six classes that all hold a reference to each other and to the same mutable account record are one god object with five extra files and a worse call graph. The reasons to change did not move; only the line boundaries did.
- Six classes that all hold a reference to each other and to the same mutable account record are one god object with five extra files and a worse call graph. The reasons to change did not move; only the line boundaries did.
- The split usually follows the *nouns already in the class* rather than the reasons it changes, so a pricing change still touches four of the six, and now also touches the wiring between them.
- Doing it in one pass means a 3,000-line diff nobody can review against an eleven-minute test suite nobody trusts. The risk is concentrated exactly where the system is least understood (Refactoring Without Tests).
- The genuinely expensive property — that every change to any account concern redeploys and regresses all of them — survives the split untouched, because it was never about file size.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- AccountManager is called from 60 places, including two scheduled jobs and an admin console outside this repository.
- Its test file takes eleven minutes to run and is the slowest thing in CI, so nobody runs it locally.
- Two of the four teams that edit it are in another timezone, so any coordination costs a day.
- An account must have exactly one billing state at any time, and every path that changes it must go through the same check.
- No caller outside the account module may set a billing state directly, whatever the shape of the code inside.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Something must own the account's billing state and the rule that guards its transitions. Exactly one thing, with a small API.
- Notification, audit and admin concerns are *consumers* of account events, and must not be co-owners of account state (State Ownership).
- Whoever extracts a piece owns removing the old path, because a god object that also has a new module beside it is strictly worse than before.
- Cut along rates of change, not along nouns. Billing rules change monthly, the notification copy weekly, the auth integration twice a year — three rates is three candidate boundaries (Finding Seams).
- The first boundary should go around the most volatile concern, because that is where the extraction pays back soonest and where the tests you write are most reused.
- Anything the invariant depends on must end up inside one boundary. Splitting a rule across two modules to make both smaller is the worst available outcome (Consistency Boundaries).
The finding is the reason count
Before proposing any split, write the unit down in the four terms that matter. It takes twenty minutes and it is the entire design argument — a module with one reason to change is fine at any size, and a module with nine is a problem at any size.
The list below is a real shape. Notice that no single entry is unreasonable, which is why nobody objected as each was added, and that the entries come from six different parts of the business, which is why every one of them forces a redeploy of the other five.
- — The account record and every column on it
- — Billing state and the plan catalogue
- — Password hashing parameters
- — Which email templates exist
- — The admin console's permission model
- — Two feature flags and what they gate
- — Creates and updates accounts
- — Changes plans and prorates charges
- — Authenticates and resets passwords
- — Sends welcome, dunning and cancellation email
- — Writes audit rows
- — Exports accounts for the data warehouse
- — Database
- — Payment provider SDK
- — SMTP client
- — Template engine
- — Feature flag client
- — Clock
- — Config
- — Audit log
- — A billing rule changes
- — The plan catalogue changes
- — Password policy changes
- — Email copy changes
- — The admin permission model changes
- — The audit schema changes
- — The warehouse export format changes
- — A feature flag is added or removed
Eight distinct reasons to change, owned by at least four teams, behind one eleven-minute test suite. That is the finding, and it is verifiable without anyone agreeing about what "too big" means. The first move is not a split into eight — it is to take billing, which changes monthly and is the most dangerous, and give it its own address with its own tests.
What you actually see
The observable symptoms are more useful than the size, because they are about how the module behaves under change rather than how it looks at rest. The fine case here is not a technicality: some systems genuinely have one central entity that everything is about, and pretending otherwise produces a worse design than admitting it.
looks like A type whose public API runs to dozens of methods; a constructor or import list touching every subsystem; a file that appears in the diff of most pull requests regardless of what the pull request is about; a test file measured in minutes.
suggests Responsibilities accumulated where the data already was. The module is now the coordination point for several teams, and its regression surface is the union of everything it does, so every change is priced against all of it.
fix Count reasons to change, not lines. Extract the most volatile reason first, taking its state and rules together, route callers through the new API incrementally, and delete the old path behind them. Stop when what remains has a coherent story.
Game in a board-game engine, a Document in an editor, an Order in a small commerce system. If the rules really are one interlocking set of invariants over one piece of state, splitting them puts the invariant in the seam between modules, which is strictly worse — you trade a large coherent unit for a distributed one you can no longer enforce in a single place (Aggregates). The test is whether the reasons to change are one story or eight.1// Coherent: one reason to change, and it is large because chess is large.2class Game {3 move(from: Square, to: Square): Result // legality, check, mate,4 // en passant, castling, promotion, repetition, the clock5}6 7// Incoherent: eight reasons to change, and they belong to six teams.8class AccountManager {9 changePlan(...) // billing team, monthly10 resetPassword(...) // security team, twice a year11 sendWelcome(...) // growth team, weekly12 exportForWarehouse() // data team, quarterly13}Both are big. Only one of them makes an unrelated team wait for your test suite. Line count cannot tell them apart and reason count can, which is why the reason count is the tool.
Pricing one requirement against the split
The argument for extraction has to be made in changes, not adjectives. Here is "pause subscription" priced twice — and the last field is the part that usually goes unsaid, because the bounded design is genuinely worse for one class of change.
A customer can pause billing for up to three months; the account stays active, invoices stop, and the paused period does not count towards the annual commitment.
The edit is small; the blast radius is not. Four teams are in the review, the slow suite gates the merge, and a mistake in the plan-proration code can break password reset in the same deploy because they ship together.
One owner, one fast suite, one team in the review. The dunning job changes because it must respect the paused state — that is a real dependency and it stays visible instead of being hidden inside a shared file.
How to build it
Most important first.
- Map it before you touch it: what it knows, what it does, what it depends on, and every distinct reason it has to change. The list of reasons is the design document (Designing by Responsibility).
- Extract the most volatile responsibility first, whole, with its state and its rules together — not the class's data on one side and its behaviour on the other (Extract Module).
- Give the extracted module a small API expressed in domain terms, and route the old callers through it one at a time rather than in a single cut-over (Incremental Migration).
- Delete the old path as each caller moves. An extraction that leaves both paths alive has added a synchronisation problem to the problem it was solving.
- Stop when the remaining thing has a coherent reason to change, even if it is still large. A 900-line module that changes only when billing rules change is a good module (Long Functions).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: "pause subscription" touches one file that four teams edit, forces an eleven-minute suite, redeploys billing, auth and notifications together, and risks regressions in concerns that have nothing to do with pausing. Merge conflicts are near-certain, and the review has no way to isolate the change.
- Before, in the part nobody counts: the discovery cost. Finding which of the 3,000 lines participate in a billing state change takes longer than writing the feature.
- After extracting billing: the same requirement touches one module with a 40-line test suite that runs in a second, and the other three teams are not involved. The next four billing requirements land in the same place.
- What did not get cheaper: any change that genuinely spans account concerns — a GDPR deletion, a tenant migration — now touches several modules and their wiring, and is somewhat *more* expensive than before. That trade is usually worth it and is not free.
- Splitting converts an intra-file problem into an inter-module problem. Cross-module changes need coordination, versioned internal contracts, and more integration tests than a single file needed (Internal Module Contracts).
- The extraction is a risky change to working code in the highest-traffic part of the system, made for a benefit that arrives later.
- Small modules with clear owners make cross-cutting changes slower on purpose. That friction is the mechanism; it is also a genuine cost when the cross-cutting change is legitimate.
What can go wrong
- Split by noun, and the change amplification stays the same while the call graph gets worse.
- Split into an anemic data holder plus six service classes, which relocates the behaviour but leaves the invariant unenforced anywhere (The Anemic Domain Model).
- Extraction stalls half done, and the codebase now has two ways to change a billing state — the old field write and the new module — with no rule about which wins.
- The mitigation fails: extracting the most volatile part first is right, and it means the first extraction is the riskiest one, done when you understand the module least.
- A god object typically depends on everything — database, clock, HTTP client, mailer, config — which makes it untestable in isolation and makes every one of those a reason to redeploy it (Volatile Dependencies).
- Its 60 callers depend on it, so its API is effectively frozen. Fan-in this high converts an internal class into an unversioned public contract (Fan-in and Fan-out).
- "Any large class is a god object." Size is a hint. A 2,000-line module that changes only for one reason and is edited by one team is fine, and cutting it up buys nothing (Single Responsibility, Carefully).
- "So we need microservices." Splitting a god object across a network makes every one of its internal calls a partial-failure case and does not change who owns what. Fix ownership first, in the monolith (The Modular Monolith).
- "The fix is to make the data private." Encapsulating the fields of a god object is worth doing and does not address the finding, which is the number of unrelated reasons the module has to change (Divergent Change).
- "We should rewrite it." The rewrite has to reproduce behaviour nobody has written down, which is exactly the property that made the module hard in the first place (The Risk in a Rewrite).
- god-object
- divergent-change
- feature-envy
Testing it, and how it ages
- Characterize the current behaviour of the concern you are about to extract, at its callers, before moving anything (Characterization Tests).
- The extracted module should be testable with no database and no clock. If it is not, the extraction took the dependencies with it and is not finished (Testing as Design Feedback).
- Keep one end-to-end test per invariant — "an account cannot be paused and billed in the same cycle" — at the outermost boundary, because that is the thing the split most plausibly breaks.
- God objects grow by accretion, not by decision. Each addition is individually reasonable — the account is already loaded here, so why not — and no one addition is worth objecting to in review.
- They stabilise once the module is genuinely feared, at which point people route around it by writing their own account logic elsewhere, and you get duplicate knowledge on top of the god object (Duplicate Knowledge).
- After a split, watch for the same accretion in whichever new module became the convenient one. The shape reappears wherever "it is already loaded here" is true.
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThe finding — many unrelated reasons to change in one unit — applies to a Go package, a Python module or a Rust crate as readily as to a Java class; what differs is only the unit of packaging you are counting reasons within.
- PARADIGM-SPECIFICIn OO the god object usually holds state and behaviour together; in a procedural or functional codebase the same problem appears as a god *module* of free functions over a shared record, where it is easier to miss because no single type looks large.
- CONTESTEDThe strongest opposing view is that a single large, coherent module with all the account logic in one file is genuinely easier to change than eight small ones with contracts between them: you can read the whole thing, grep finds everything, and there is no wiring. Practitioners who have lived through a bad decomposition hold this seriously, and they are right whenever the module has one real reason to change and one team. The argument is about reason count, not size.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — an eleven-minute test suite is a reliability problem before it is a design problem, and the feedback-loop argument for splitting it lives there.