RefactoringGENERALSCALE-SPECIFICCONTESTED

Extract Module

Pull out a module when a set of responsibilities has become cohesive enough to have its own reasons to change — and its own interface that hides them.

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.

The question

How do I know when a group of functions has become a module, rather than just a folder I put them in?

The requirement

A 3,000-line OrderService handles pricing, tax, fulfilment routing, notification and audit. Every one of the last six tickets touched it, and two of them conflicted in the same week.

The obvious build

The file is too big, so split it by type: put all the calculations in calculations.ts, all the database access in repository.ts, all the notifications in notifications.ts. Three smaller files, same code.

Why it breaks

Splitting by technical type puts every feature's knowledge in every file. A tax change then touches calculations, repository and notifications — three files instead of one, which is worse than where you started (Package by Layer).

How it breaks as requirements change
  • Splitting by technical type puts every feature's knowledge in every file. A tax change then touches calculations, repository and notifications — three files instead of one, which is worse than where you started (Package by Layer).
  • It creates no interface. Three files that all import each other's internals are one module wearing three names, and nothing is hidden (Decomposition by Folder).
  • The conflict problem does not improve. Two teams editing tax and fulfilment still both edit calculations.ts, because the split was orthogonal to the reason they were conflicting (Divergent Change).
  • And it makes the real seam harder to find later, because the cohesive groups are now scattered across the technical split and have to be reassembled before they can be extracted.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • The service is called from twelve places across the codebase and from one scheduled job.
  • It shares a database transaction with the code that calls it, so any boundary has to respect that (Consistency Boundaries).
  • There is no appetite for a service split; this is about structure inside one deployable (The Modular Monolith).
  • Two teams are actively editing this file, which is the acute pain and also the constraint on how the work can be sequenced.
Invariants
  • A module's invariants are enforced at its interface, so no caller can reach a state the module considers impossible (Where Invariants Live).
  • Nothing outside the module depends on how it stores or computes anything — only on what it promises (Information Hiding).
  • Extraction preserves behaviour. A module boundary is a structural change, not a redesign of the rules inside it.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The extracted module owns a coherent set of decisions — everything about how tax is determined, for instance — and owns the invariants that come with them.
  • The module owns its own data shape. If the caller has to know the module's table layout, the extraction is incomplete (Schema Leakage in Backend).
  • The caller owns orchestration: asking modules for things in an order, and owning the transaction (Where the Transaction Boundary Goes in Backend).
  • Nobody owns "utilities". A module that cannot be described without the word "and" is two modules, and one that is described as "shared helpers" is a folder (The Utility Dumping Ground).
Boundaries
  • The seam is where a set of responsibilities shares reasons to change and does not share them with the rest (Cohesion).
  • The evidence is in history: look at which parts of the file change together across the last thirty commits. Groups that co-change are candidate modules, and groups that never co-change are the seam between them (Divergent Change).
  • The interface boundary is what the caller must know. If extracting the module requires exporting six internal types, the boundary is in the wrong place (Exposing Too Much).
  • Transactional scope constrains it. A module that must commit atomically with its caller cannot own its own transaction, and pretending otherwise produces the dual-write problem (The Dual Write Problem in Backend).

What the god service is actually holding

Before drawing a boundary it is worth writing down what the unit knows, does and depends on — because the finding is usually in the number of distinct reasons it has to change, and that list is what tells you where the seams are.

responsibilitiesOrderServiceOrderService, 3,000 lines
Knows
  • Line-item prices, discount eligibility and currency conversion
  • Tax rates by country and product category, and which are inclusive
  • Which warehouse can fulfil which SKU, and the routing preferences
  • Which notifications are sent, to whom, in which locale
  • What has to be written to the audit log for finance
  • The order table's column layout, and three other tables' as well
Does
  • Computes an order total
  • Decides fulfilment routing and reserves stock
  • Sends confirmation and dispatch notifications
  • Writes audit entries
  • Owns the transaction that wraps all of the above
Depends on
  • The ORM entities
  • The mail provider
  • The warehouse API
  • A tax-rate table
  • The clock, read directly (Time as a Dependency)
Changes when — 6 distinct reasons
  • A tax rule changes in any country
  • A discount or promotion rule changes
  • A warehouse is added, removed or reprioritised
  • The notification copy or locale set changes
  • The finance team wants a different audit field
  • The order table schema changes

Six unrelated reasons to change is the finding, and the reasons map almost one-to-one onto the last six tickets and both of the merge conflicts. Three of them — tax, routing, notification — group cleanly with data that nothing else uses, which makes them the candidate modules. Pricing and audit are harder, because both touch the order shape itself; that difficulty is real information, and it is why the extraction is done one module at a time rather than as a redesign (Divergent Change).

Where the dependencies point afterwards

The extraction is only finished when the arrows go one way. A module that the service calls, and that calls back into the service for something it needs, has produced a cycle and none of the change locality that was the point (Dependency Cycles).

Notice which things ended up inside OrderWorkflow: the transaction and the sequence. Orchestration is a responsibility too, and giving it to one of the extracted modules is the most common way this goes wrong.

  • TaxRules owns tax_rates outright — nothing else may read it, which is what makes a rate change a one-module edit (State Ownership).
  • Nothing points back at OrderWorkflow. That is the property to check after every extraction, and the one an architecture test should assert (Dependency Direction).
  • Pricing depends on TaxRules rather than the workflow calling both, because a price without tax is not a thing the domain has a name for.
  • OrderWorkflow still touches the orders table directly. That is a deliberate incompleteness — the extraction was done three modules at a time, and stopping at a working point is the loop working as intended (The Refactoring Loop).
After three extractions
total(order)chooseWarehouse(order)orderConfirmed(order)vatFor(region, amount)reads and writesowns this tableCheckout handlerOrderWorkflow (owns the transaction and the sequence)PricingFulfilmentRoutingNotificationsorders tableTaxRulesWarehouse APItax_rates table
UserLLMAgentToolDataDecisionHumanGuardrail

Pricing the boundary honestly

The extraction makes the anticipated changes cheap and the unanticipated ones more expensive. Both halves need to be on the table, because the second half is what the objection to modularity is actually about.

Two requirements, priced before and after the extraction
The change

First: "VAT for Ireland moves to 23%." Second: "every order needs a channel field, used in pricing, routing and the audit log."

One 3,000-line OrderService
OrderService
testsorder_service_test (940 lines, covers everything)
1 module · 1 test file

Rate change: one edit, but in a file two teams have open, and the test run is the whole order path. Channel field: also one edit, and genuinely simpler — everything that needs it is already in the same file and the same scope.

OrderWorkflow orchestrating Pricing, TaxRules, FulfilmentRouting, Notifications
TaxRules — for the rate changeOrderWorkflow, Pricing, FulfilmentRouting, audit — for the channel field
teststax_rules_test — for the rate changefour module test files plus one integration test — for the channel field
2 modules · 2 test files

Rate change: one small module, one fast test file, no conflict with the fulfilment team. Channel field: four modules and three interface signatures, where before it was one edit in one scope.

what it cost The rate change got substantially cheaper and the cross-cutting field change got more expensive — four modules and three interfaces instead of one edit. That is not a defect in the extraction; it is what a boundary *is*. The bet is that per-responsibility changes outnumber cross-cutting ones, which the commit history supports here and would not in every codebase. The extraction also cost several days of behaviour-preserving work on a file two teams were editing, and it permanently added indirection for anyone reading the order flow end to end (The Cost of Change).

How to build it

Most important first.

  • Find the cohesive group before moving anything. Which functions share data, share vocabulary, and change in the same commits (Finding Seams)?
  • Name the module for the responsibility, in the domain's language: Pricing, TaxRules, FulfilmentRouting. If the best available name is OrderHelpers, you have not found a module (Ubiquitous Language).
  • Design the interface from the caller's side. Write the three or four calls the caller wants to make, then make them work — this is what stops the module's internals leaking into its signature (Designing a Module Interface).
  • Move in behaviour-preserving steps: extract functions first, group them, introduce the interface, then move the file. Each step is deployable (The Refactoring Loop).
  • Make the dependency direction deliberate. The extracted module should not depend back on the service it came from; if it does, the seam is wrong or something needs to move with it (Dependency Direction).
  • Stop when the interface is small and the module has one coherent reason to change. Continuing past that produces a module per class, which is a different problem (Over-Decomposition).

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.

Cost of the next change
  • Before: a tax change costs an edit to a 3,000-line file that two teams have open, a merge conflict, and a full regression of the order path because nothing bounds what else moved.
  • After: a tax change costs an edit inside TaxRules plus its own tests. Nothing else recompiles, nothing else is reviewed, and the two teams stop colliding because they are editing different files (Change Amplification).
  • The next change *across* modules is more expensive than before, not less: adding a field that pricing, tax and fulfilment all need now touches three modules and their three interfaces, where previously it was one edit. That is the trade, and a lesson that does not state it is selling something.
  • The extraction itself costs several days of behaviour-preserving work on code two teams are editing, which is the highest-friction moment to do it.
What the recommended approach costs
  • Every module boundary makes cross-boundary changes more expensive. Modularity buys locality for anticipated changes and charges for the unanticipated ones.
  • Interfaces are indirection. A reader following a call now leaves the file, and for someone reading the flow once that is a pure cost.
  • Extraction is risky work on running code that two teams are editing, and the risk is paid now against a benefit that arrives with the next few requirements (The Cost of Change).

What can go wrong

Failure modes
  • The module is extracted but the interface exposes its internals, so callers still reach through it and the boundary buys nothing (Leaky Abstractions).
  • A cycle appears: the new module needs something from the old service, which needs the new module. Almost always a sign that a third responsibility is sitting in the wrong place (Breaking Cycles).
  • The extraction is made along a technical line under pressure of time, and the team now believes the module problem has been addressed.
  • The mitigation fails: an interface designed to hide everything ends up with a getContext() method that returns the whole internal state, and the encapsulation is decorative.
  • Too many modules. Fifteen modules with one function each has all the ceremony of modularity and none of the change locality (Module Granularity).
Dependencies, and their direction
  • Callers depend on the module's interface, one direction, and the module depends on nothing volatile — not the HTTP layer, not the ORM entity of the caller (Volatile Dependencies).
  • The module usually needs its own data access, which is where extraction gets genuinely hard: shared tables are the most common reason a clean-looking module boundary cannot actually be drawn (State Ownership).
  • Extraction adds a compile-time or import-time dependency that the build can enforce. That enforceability is much of the value — a boundary nothing checks is a convention (Circular Dependencies).
Misreads
  • "So split the big file." Splitting is not extracting. A module has an interface that hides decisions; a file split has neither, and the technical-type split actively makes things worse (Decomposition by Folder).
  • "Every module should have an interface type." A module has an interface in the sense of what it exports. Declaring an abstract type for a single implementation is a different decision with its own justification, and it is not this one (Interface Versus Implementation).
  • "Extract as many modules as possible." Each boundary costs cross-boundary change. Modules should be as few as will contain the changes you expect (Module Granularity).
  • "This is the first step towards microservices." It is a step towards being *able* to make that choice. It is also a perfectly good final destination, and most systems should stop here (Designing a Monolith).
Smells this explains
  • god-object
  • divergent-change
  • utility-dumping-ground

Testing it, and how it ages

What to test, and at which boundary
  • Test the module through its interface, and delete tests that reached into what is now private — those are the tests that were welding you to the old structure (What a Unit Is).
  • One integration test that the caller plus the module still produce the same order totals as before, run against recorded real orders (Characterization Tests).
  • An architecture test asserting the dependency direction, because a boundary the build does not check will be crossed within a quarter (Dependency Cycles).
How this design ages
  • A good module boundary attracts the next few requirements into itself, which is how you know it was right. If the following three tax changes all land inside TaxRules, the seam was correct (Stable Boundaries).
  • A module that keeps growing internal structure is succeeding, not drifting — eventually it has its own submodules and its own interface, and that is the normal life cycle.
  • What forces a rethink: a requirement that genuinely spans two modules every time. Repeated cross-module changes mean the seam is in the wrong place, and the fix is to move it rather than to add coordination (Shotgun Surgery).
  • If the module later needs to be deployed separately, this boundary is what makes that affordable — which is the practical argument for modular monoliths (The Modular Monolith).

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.

  • GENERALCohesion as the criterion for a module boundary holds across languages; what differs is enforceability — a language with real module privacy makes the boundary checkable, while one relying on convention leaves it to review and to architecture tests.
  • SCALE-SPECIFICBelow a few thousand lines and two or three engineers, one well-organised file is genuinely easier to work with than four modules, and the extraction returns nothing. The forcing function is not size but concurrent editing: the moment two people need to change different responsibilities in the same file weekly, the boundary starts paying.
  • CONTESTEDThe strongest opposing view is that internal module boundaries in a monolith are unenforceable in practice — people import across them, the build check gets suppressed, and the boundary erodes to a naming convention while imposing indirection the whole time. Teams that have watched a modular monolith degrade into a distributed ball of mud in one process hold this seriously. The counter is that the same erosion happens to service boundaries too, just more expensively, and that an eroded module boundary is at least cheap to redraw.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • System Design — whether this module should later become a separately deployed service is a different question with different criteria; the module boundary is what makes that choice available rather than what decides it.