Extract class
A class that quietly grew a second responsibility — a data clump plus the behaviour around it — wants splitting. Extract Class is SRP done by refactoring: read the seam from the clump, then move fields and methods incrementally under green tests.
A Person class started as a name and an email. Then someone needed a phone number, so they added areaCode and number. Then formatting: phoneAsString(). Then validation: isValidAreaCode(). The class still compiles, still passes its tests, still ships. But it is now two things wearing one name — a person, and a telephone number that happens to live inside the person.
You feel it when you change the phone format and have to scroll past name and address logic to find the right method, or when you want to reuse the phone formatting for a company and can’t, because it is welded to Person. The class works. It is just carrying a second class inside it, and the cost of every phone change is paid in Person’s blast radius. Extract Class is how you let that second class out.
After this lesson you can recognise the two signals that a class wants splitting — a data clump (fields that always travel together) and a cohesive method cluster that touches only those fields; perform Extract Class as a sequence of small, behaviour-preserving moves under green tests (new class, move fields, move methods, update references); and tell the difference between a real extraction and the anti-pattern it most often collapses into — a lifeless data bag whose behaviour you left behind.
The data clump and the method cluster tell you where the seam is — you don’t invent it, you read it. Extract Class is Single Responsibility Principle applied retroactively, and the signal that two responsibilities have fused is structural, not aesthetic. Look for a data clump: a set of fields that always appear together (areaCode + number, or street + city + zip). Then look at which methods touch only that subset of fields and ignore the rest of the class. That overlap — fields that cluster, methods that serve only the cluster — is the seam.
class Person {
name: string;
// ── the data clump: these three always move together ──
areaCode: string;
number: string;
// ── the method cluster: it only ever touches the clump above ──
phoneAsString(): string { return `(${this.areaCode}) ${this.number}`; }
}If a candidate “responsibility” has data but no methods that exclusively serve it, or methods but no shared data, you don’t have a class waiting to come out — you have something smaller (a moved method, a renamed field). The seam has to show up in both dimensions before extraction pays.
Create the new class empty and link it — the first move changes nothing observable. Begin with a no-op step: introduce TelephoneNumber and give Person an instance of it. At this point the old fields still exist; nothing has moved. Run the tests — they must stay green, because you have added a back-reference and changed no behaviour. This empty shell is your safety scaffold: every subsequent move is now a delegation, swappable one field and one method at a time.
class TelephoneNumber {} // empty on purpose
class Person {
name: string;
areaCode: string;
number: string;
private telephoneNumber = new TelephoneNumber(); // linked, unused — tests still green
phoneAsString(): string { return `(${this.areaCode}) ${this.number}`; }
}The discipline here is that “Extract Class” is not one edit; it is a named refactoring made of smaller named refactorings (Move Field, Move Method). Doing it atomically — cut everything, paste into the new file, fix the compiler errors — is how a behaviour-preserving move turns into an accidental behaviour change you discover in production.
Move fields one at a time, replacing each with a delegating accessor, tests green after every move. Take areaCode. Add it to TelephoneNumber with a getter/setter, then make Person.areaCode delegate to this.telephoneNumber. The field’s storage has moved; its public surface on Person is unchanged, so callers and tests don’t notice. Repeat for number.
class TelephoneNumber {
private _areaCode = "";
get areaCode() { return this._areaCode; }
set areaCode(v: string) { this._areaCode = v; }
}
class Person {
// storage now lives in TelephoneNumber; Person just forwards
get areaCode() { return this.telephoneNumber.areaCode; }
set areaCode(v: string) { this.telephoneNumber.areaCode = v; }
phoneAsString(): string { return `(${this.areaCode}) ${this.number}`; }
}The reason to delegate rather than rip the field out is reference safety. You may not know every caller of person.areaCode. The delegating accessor keeps them all working while the data relocates, so you can move references in a later, separate pass instead of all at once under compiler pressure.
Move the methods to the data, then move callers off Person’s forwarders and delete them. Now phoneAsString() reads only data that lives in TelephoneNumber, so it belongs there. Move it. Person.phoneAsString() becomes a one-line delegate, then — once callers are updated to ask the telephone number directly — you delete the delegate. This is the payoff move: behaviour has migrated to its data, which is the whole point. A class is cohesive when its methods and the data they use sit together.
class TelephoneNumber {
areaCode = "";
number = "";
toString(): string { return `(${this.areaCode}) ${this.number}`; }
}
class Person {
name = "";
telephoneNumber = new TelephoneNumber();
// delegate kept only until callers move; then deleted
phoneAsString(): string { return this.telephoneNumber.toString(); }
}When the last caller of a forwarder is gone, the forwarder goes too. The end state is two classes that each have a single reason to change: change the phone format and you touch TelephoneNumber; change a person rule and you touch Person. Crucially, TelephoneNumber carries behaviour (formatting, and now validation can live there too) — it is not a struct.
Before — one class, two responsibilities. Order has grown a customer-contact clump and the behaviour around it, tangled with order logic:
class Order {
items: LineItem[] = [];
customerName = "";
customerStreet = "";
customerCity = "";
customerZip = "";
total(): Money { /* order concern */ }
// a cohesive cluster that only touches the customer-address clump:
shippingLabel(): string {
return `${this.customerName}\n${this.customerStreet}\n${this.customerCity} ${this.customerZip}`;
}
isAddressComplete(): boolean {
return !!(this.customerStreet && this.customerCity && this.customerZip);
}
}shippingLabel() and isAddressComplete() never look at items or total(). They are an address responsibility hiding inside Order.
After — extract Address, behaviour and all. The fields moved, and so did the methods that serve them:
class Address {
constructor(
public name: string,
public street: string,
public city: string,
public zip: string,
) {}
label(): string { return `${this.name}\n${this.street}\n${this.city} ${this.zip}`; }
isComplete(): boolean { return !!(this.street && this.city && this.zip); }
}
class Order {
items: LineItem[] = [];
constructor(public shipTo: Address) {}
total(): Money { /* order concern, unchanged */ }
// callers now ask the address directly: order.shipTo.label()
}Done incrementally this was: introduce Address empty and link it; move the four fields through delegating accessors (tests green each time); move shippingLabel/isAddressComplete onto Address as label/isComplete; repoint callers to order.shipTo; delete the forwarders. Address is now reusable (a warehouse has one too) and Order has one reason to change. Notice the new class is not a data bag — it carries the formatting and validation that used to be smeared across Order.
▸Why this works
Why move incrementally with delegating accessors instead of one clean cut? Because Extract Class is behaviour-preserving by construction only if each step is small enough to verify. The whole value of the refactoring is that nothing observable changes — but a big-bang cut (delete fields, paste a new file, chase compiler errors until it builds again) can silently change behaviour: an initialisation order shifts, a setter side-effect is lost, a caller you didn’t compile against breaks at runtime. The intermediate state — fields physically in the new class, but still reachable through Person’s old accessors — looks redundant, and it is, temporarily. That redundancy is precisely what lets the tests stay green at every step, so when one finally goes red you know it was the last move, not a needle in a 200-line diff.
▸Common mistake
The failure mode: you extract a class and it comes out as a pure data bag — fields, getters, setters, and no behaviour — while all the methods that use those fields stayed behind. That is an anemic extraction. You haven’t separated two responsibilities; you’ve just split one responsibility’s data away from its logic, which raises coupling (every change now spans two files) and buys nothing. Two checks before you ship the extraction: (1) Did behaviour move with the data? If the new class has no methods, you extracted the wrong thing. (2) Was a whole class even the right tool? If the clump is a small immutable concept — money, a date range, a coordinate — the real fix is a value object (equality by value, no identity), not a mutable entity. And if there was exactly one misplaced method and no real clump, the fix was even smaller: just Move Method to the data it envies. Extract Class is the answer only when a genuine cluster of data and behaviour wants its own name.
You extract a TelephoneNumber class out of Person. When you're done, TelephoneNumber holds areaCode and number with getters/setters and nothing else, while phoneAsString() and the phone validation still live on Person. Is the extraction good, and why?
Extract Class is the Single Responsibility Principle applied through refactoring: when a class has quietly grown a second responsibility, the data clump (fields that travel together) and the cohesive method cluster that touches only those fields tell you where the seam is. Perform it as small behaviour-preserving moves — create the new class empty and linked, move fields through delegating accessors, move the methods to their data, then repoint references and delete the forwarders — keeping the tests green after every step so a red is always the last move you made. The result is two classes with one reason to change each, the new one carrying real behaviour. The failure mode to guard against is the anemic extraction: if the new class ends up a pure data bag while its methods stayed behind, you split data from logic and raised coupling for nothing — and the real fix may have been a smaller move (a single Move Method) or a value object rather than a whole class.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.