Encapsulate Collection: Stop Handing Out the Live List
Encapsulate Collection explained simply — why returning a live array or list lets any caller corrupt your object, and how read-only views plus add/remove methods restore control.
📒 The Attendance Register Left Open on the Desk
In a school in Lucknow, Class 7-B has an attendance register. In a careless world, the register lies open on the front desk all day, with a pen helpfully kept beside it. Any student who walks past can pick up that pen. Ramesh adds his absent friend's name with a neat tick. Someone tears out Tuesday's page for a paper plane. A prankster writes "Virat Kohli" in the admissions column, and three days later writes "MS Dhoni" underneath, because why stop at one legend. By month end, the register says the class has 67 students; the classroom has 42 chairs. When the inspector visits, nobody — not even the students who never touched it — can say which entries are real.
The class teacher, Mrs. Verma, runs things differently. The register stays with her, in her drawer. If a new student joins, the office sends an admission slip, she verifies it against the rules — name filled in properly, not already enrolled, class not beyond its 42-seat strength — and she writes the name. If a student leaves, she strikes the name off after checking the transfer certificate. Every change passes through one careful pair of hands, and every change follows the rules.
And when Mr. Pillai, the sports teacher, needs the list of students for team selection? Mrs. Verma does not hand over the register — she gives him a photocopy. He can read every name, count, sort, circle his favourites in red ink — and nothing he does to the photocopy can change the real register by even one letter. Reading power: full. Writing power: zero. That is exactly the division Mr. Pillai needs and exactly the division he gets.
Now the register is always correct, because only one person changes it, and that person follows the rules.
Software objects own registers too — arrays and lists of orders, courses, players, marks. And the most common mistake in object-oriented code is exactly the open register: a getter that hands out the live collection for anyone to scribble on. The refactoring that puts Mrs. Verma in charge is called Encapsulate Collection.
🧠 What is Encapsulate Collection?
Encapsulate Collection is the collection-flavoured version of Encapsulate Field. It says: when a class owns a collection, do not let outsiders touch the real one. Instead:
- The getter returns a read-only view or a copy — the photocopy, not the register.
- The class provides explicit add and remove methods — the admission slip and the transfer certificate — and all membership changes pass through them.
- The wholesale setter is removed — nobody gets to swap the entire register for a different notebook. (This is Remove Setting Method joining the team.)
Why is a special refactoring needed when we already learnt Encapsulate Field? Because collections have a sneaky property: making the field private is not enough. Suppose courses is private and there is only a getter:
class Student {
private courses: Course[] = [];
getCourses(): Course[] {
return this.courses; // private field... but the LIVE array escapes!
}
}The field is private, the getter looks innocent — and yet any caller can write student.getCourses().push(anything) or student.getCourses().length = 0 and mutate the student's insides without the student ever knowing. In Fowler's 2nd-edition catalog this is precisely the trap described: if a getter returns the collection itself, the collection can be modified behind the owning object's back, and the benefit of encapsulation quietly evaporates. With ordinary values, returning the value gives the caller a copy of information; with collections, returning the reference gives the caller a key to your house.
College corner — defensive copies and read-only views: the academic name for Mrs. Verma's photocopy is a defensive copy, and the cheap alternative is a read-only view; they solve the same aliasing problem differently. Aliasing means two names for one object: when your getter returns the live list, the caller's variable and your private field are aliases — every mutation through one is visible through the other, with no event, no log, no warning. A defensive copy breaks the alias completely: the caller gets a fresh list, paying O(n) time and memory per call, and sees a frozen snapshot that will not change even if the team changes a second later. A read-only view keeps the alias for reading but amputates the mutating operations: it is O(1) to create, always shows the current contents, and rejects writes — at compile time if the type system helps (ReadonlyArray in TypeScript, IReadOnlyList in C#), or at runtime (Collections.unmodifiableList in Java throws UnsupportedOperationException). Know the trade-off table by heart: copy = isolated but costly and stale; view = cheap and current but still aliased for reads, so a caller iterating a view while the owner mutates can see the change mid-loop (Java will even throw a ConcurrentModificationException). And neither protects the elements — that needs immutable element types, the previous lesson's topic.
| Strategy | Cost per call | Sees later changes? | Mutation blocked | Element protection |
|---|---|---|---|---|
| Live reference (the bug) | Free | Yes | Nothing blocked | None |
| Defensive copy | O(n) each call | No — frozen snapshot | Fully — caller owns a separate list | None — same element objects |
| Read-only view | O(1) | Yes — always current | At compile time or runtime | None — same element objects |
| Copy of immutable elements | O(n) | No | Fully | Full — deep safety |
A reference to a mutable collection is not data — it is power. When your getter returns the live list, you are not sharing information, you are sharing the pen that writes the register. Share photocopies (copies or read-only views); keep the pen.
🔍 When do we need it?
Watch for these signs:
- A getter returning the internal collection directly.
return this.items;is the open register. Almost every codebase has a few of these. - Callers mutating through the getter. Searches like
.getCourses().add(,.getItems().push(,.getOrders().clear()finding hits is a confirmed emergency, not just a smell. - A bulk setter for the collection.
setCourses(list)lets a caller install a list your class never validated — and the caller may keep its own reference and mutate it later, after your "validation". - Rules about the collection that keep breaking. "A student takes at most six courses." "No duplicate items in the cart." "An order must have at least one line." If such rules exist but mutation happens outside the class, the rules are decoration.
- Derived data going stale. The class caches
totalMarksbut the marks list changes behind its back, so the cache lies. When the class sees every change, it can keep derived data honest.
When the full ceremony is not needed: a small private helper class used in one file, or a value that is genuinely just a transparent bundle of data passed between two functions. Encapsulation is a guard you hire for things with rules; do not post guards in an empty corridor.
When one team grepped their codebase for every access to a leaked getPlayers()-style getter, the breakdown was sobering:
Seventy percent of callers only ever wanted the photocopy — they lose nothing in the refactoring. But nearly a third were writing, half of those without realising it (an in-place sort() "just for display" is still a write). Those are the entries in Class 7-B's register that nobody remembers making.
A quick chart for prioritising which collections to encapsulate first — by how many rules the collection carries and how many outside writers it has:
Reading it: the team's players list — strict rules (max 11, no duplicates) and many careless writers — is the "encapsulate today" emergency. A log buffer has many writers but no real invariants; an append-only helper API is enough. A temporary local array needs nothing at all.
🪄 Before and after at a glance
Before — the open register, in code:
// BEFORE: the live array escapes
class ClassRegister {
private students: string[] = [];
getStudents(): string[] {
return this.students; // hands out the real register
}
setStudents(students: string[]): void {
this.students = students; // swaps the whole register!
}
}
const register = new ClassRegister();
// Anywhere in the program — none of this asks the teacher:
register.getStudents().push("Virat Kohli"); // prank admission
register.getStudents().length = 0; // entire class erased
register.setStudents(["Ramesh"]); // register swapped wholesaleAfter — Mrs. Verma takes charge:
// AFTER: read-only photocopies out, controlled changes in
class ClassRegister {
private readonly students: string[] = [];
private static readonly MAX_STRENGTH = 42;
// The photocopy: callers may read, never write
getStudents(): ReadonlyArray<string> {
return [...this.students]; // copy + readonly type
}
addStudent(name: string): void {
if (!name.trim()) throw new Error("Name required");
if (this.students.includes(name)) throw new Error("Already enrolled");
if (this.students.length >= ClassRegister.MAX_STRENGTH) {
throw new Error("Class is full (42 students)");
}
this.students.push(name);
}
removeStudent(name: string): void {
const i = this.students.indexOf(name);
if (i === -1) throw new Error(`${name} is not in this class`);
this.students.splice(i, 1);
}
}
const register = new ClassRegister();
register.addStudent("Asha"); // through the teacher: ok
// register.getStudents().push("Virat"); // compile error: readonly
// register.setStudents([...]) // gone: no bulk swap existsThree changes, one for each hole: the getter now returns a copy typed ReadonlyArray, membership changes go through addStudent/removeStudent where the rules live, and the bulk setter is deleted.
A month in the life of Class 7-B, in both worlds:
🪜 Step-by-step, the safe way
The order of steps matters here more than usual. We add the new doors before we lock the old ones, so the building always has an entrance.
Step 1: Add the add and remove methods, with whatever validation belongs to the domain. Nothing else changes yet; old and new access coexist.
addStudent(name: string): void { /* rules + this.students.push(name) */ }
removeStudent(name: string): void { /* rules + splice */ }Step 2: Hunt down every caller that mutates through the getter. Search the codebase for patterns like .getStudents().push, .getStudents().splice, assignments like getStudents()[0] = .... Redirect each one to the new methods, compiling and testing after every change.
// before: register.getStudents().push(newName);
// after: register.addStudent(newName);Step 3: Remove the bulk setter. Find callers of setStudents(...). Usually they are initialisation code — move that into the constructor, which copies the data into its own private array:
constructor(initialStudents: string[] = []) {
for (const name of initialStudents) this.addStudent(name); // rules apply even at birth
}Note the trick: the constructor feeds initial data through addStudent, so even day-one data obeys the rules.
Step 4: Change the getter to return a read-only view or a copy. This is the lock on the door. With TypeScript, also tighten the return type to ReadonlyArray<string> so mutation attempts fail at compile time, not at runtime.
Step 5: Make the backing field non-reassignable (private readonly in TypeScript, private final in Java, private readonly in C#) so even code inside the class cannot accidentally swap the array.
Step 6: Compile, run all tests, and search once more for any survivor still poking the collection. Zero hits means the register now lives safely in the teacher's drawer.
The collection passes through clear states on the way — and the safe path always adds doors before locking the old one:
And the payoff, measured: one team tracked "mystery corruption" bugs — vanished items, impossible counts, stale caches — across the four migration milestones:
The line touches zero only at the last step — because as long as the live reference escapes anywhere, one careless sort() somewhere can still undo everything.
Do steps in this order — methods first, locking last. If you flip the getter to read-only while callers still mutate through it, you break the program in many places at once and must fix everything under pressure. Adding addStudent/removeStudent first means every migration step is small, optional-feeling, and individually testable. Also beware the silent version of this bug: in JavaScript without TypeScript's readonly types, returning a copy means a caller's push onto the copy fails silently — the program does not crash, the data just never arrives. Grep for old mutation patterns after the migration; do not rely on crashes to find them.
🏏 A bigger real-life example
A cricket academy app manages team selection. Here is the "before", including the classic mistake in action — watch how a completely innocent-looking helper function corrupts the team:
// BEFORE
class Team {
private players: string[] = [];
public maxSize = 11;
getPlayers(): string[] {
return this.players; // THE CLASSIC MISTAKE: live array out
}
}
// Somewhere far away, a helper that "just sorts for display":
function showTeamSorted(team: Team): void {
const list = team.getPlayers();
list.sort(); // OOPS: sort() mutates IN PLACE...
console.log(list.join(", ")); // ...the TEAM's real order is now changed
}
// And a careless selector:
function pickOpeners(team: Team): string[] {
return team.getPlayers().splice(0, 2); // splice REMOVES them from the team!
}
const team = new Team();
team.getPlayers().push("Rohit", "Ishan", "Surya", "Hardik");
pickOpeners(team); // meant to read 2 names...
console.log(team.getPlayers()); // ...["Surya", "Hardik"] — two players VANISHEDNobody here was malicious. The sort() developer wanted a sorted display. The splice developer thought it just "takes the first two". But because the getter handed out the live array, every small misunderstanding became corruption of the team itself. This — not evil hackers — is why Encapsulate Collection exists: it protects teammates from honest mistakes. Ramesh with the pen was at least trying to cheat; the sort() developer corrupted the register while genuinely trying to help.
After the refactoring:
// AFTER
class Team {
private readonly players: string[] = [];
private static readonly MAX_SIZE = 11;
constructor(initialPlayers: string[] = []) {
for (const p of initialPlayers) this.addPlayer(p);
}
// Photocopy out: a fresh copy, typed read-only
getPlayers(): ReadonlyArray<string> {
return [...this.players];
}
get size(): number {
return this.players.length;
}
addPlayer(name: string): void {
if (!name.trim()) throw new Error("Player name required");
if (this.players.includes(name)) throw new Error(`${name} already in team`);
if (this.players.length >= Team.MAX_SIZE) {
throw new Error("Team is full: 11 players");
}
this.players.push(name);
}
removePlayer(name: string): void {
const i = this.players.indexOf(name);
if (i === -1) throw new Error(`${name} is not in the team`);
this.players.splice(i, 1);
}
}
// The helpers now CANNOT do damage:
function showTeamSorted(team: Team): void {
const sorted = [...team.getPlayers()].sort(); // sorts its OWN copy
console.log(sorted.join(", "));
}
function pickOpeners(team: Team): ReadonlyArray<string> {
return team.getPlayers().slice(0, 2); // slice reads; splice would not compile
}
const team = new Team(["Rohit", "Ishan", "Surya", "Hardik"]);
pickOpeners(team);
console.log(team.size); // 4 — nobody vanishedThe ReadonlyArray<string> return type deserves special praise: TypeScript simply removes push, splice, sort, and pop from the type. The careless splice is no longer a runtime disaster — it is a red squiggle before the code is even saved.
The finished design, drawn as a class diagram — note that no public member exposes the raw mutable list:
⚙️ The same refactoring in C#
C# gives first-class tools for this, and they are worth learning by name. The "before" mistake looks like this:
// BEFORE: both holes wide open
public class Team
{
public List<string> Players { get; set; } = new(); // live list AND bulk setter
}
// anywhere:
team.Players.Clear(); // whole team gone
team.Players = someoneElsesList; // backing store swappedThe encapsulated version — note the IReadOnlyList<string> return type, the .NET idiom for the photocopy:
// AFTER
public class Team
{
private readonly List<string> _players = new();
private const int MaxSize = 11;
// Read-only view: cheap, always current, no mutation API at all
public IReadOnlyList<string> Players => _players.AsReadOnly();
public int Size => _players.Count;
public void AddPlayer(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Player name required", nameof(name));
if (_players.Contains(name))
throw new InvalidOperationException($"{name} already in team");
if (_players.Count >= MaxSize)
throw new InvalidOperationException("Team is full: 11 players");
_players.Add(name);
}
public void RemovePlayer(string name)
{
if (!_players.Remove(name))
throw new InvalidOperationException($"{name} is not in the team");
}
}
// Callers can read and iterate freely:
foreach (var p in team.Players) Console.WriteLine(p);
var openers = team.Players.Take(2).ToList();
// But mutation does not even compile:
// team.Players.Add("X"); // error: IReadOnlyList has no Add
// team.Players.Clear(); // error: no Clear
// team.Players = newList; // error: no setterThree C# details worth underlining:
_players.AsReadOnly()wraps the list in aReadOnlyCollection<string>— a view, not a copy. It is cheap, stays current as the team changes, and throws if anyone somehow reaches a mutating member.- Why not just type the property as
IEnumerable<string>and return_players? Because a determined (or confused) caller can cast it back:((List<string>)team.Players).Clear(). TheAsReadOnly()wrapper survives the cast attempt; the naked list does not. - This pattern is the standard way to model collections in domain entities. Jimmy Bogard's "Domain-Driven Refactoring" series uses exactly this shape for DDD aggregates, and EF Core supports it directly: the ORM can populate the private
_playersbacking field while the public surface staysIReadOnlyList.
In Java, the same role is played by Collections.unmodifiableList(players) — a read-only wrapper that throws UnsupportedOperationException on mutation attempts; since Java 10, List.copyOf(players) gives an immutable snapshot.
Python has no compiler to lock the drawer, but it has a neat trick: return a tuple, which simply has no mutating methods:
# Python: photocopy as a tuple — there is no append on a tuple
class Team:
MAX_SIZE = 11
def __init__(self, initial: list[str] | None = None) -> None:
self._players: list[str] = []
for name in (initial or []):
self.add_player(name) # rules apply even at birth
@property
def players(self) -> tuple[str, ...]:
return tuple(self._players) # snapshot with zero mutators
def add_player(self, name: str) -> None:
if not name.strip():
raise ValueError("Player name required")
if name in self._players:
raise ValueError(f"{name} already in team")
if len(self._players) >= Team.MAX_SIZE:
raise ValueError("Team is full: 11 players")
self._players.append(name)
def remove_player(self, name: str) -> None:
try:
self._players.remove(name)
except ValueError:
raise ValueError(f"{name} is not in the team")
team = Team(["Rohit", "Ishan"])
# team.players.append("X") # AttributeError: tuple has no appendThe tuple return is both a copy and mutation-proof — Python's photocopy machine and locked drawer in one move.
🧰 IDE support
Encapsulate Collection has no single dedicated button in most IDEs — it is a composite refactoring — but the pieces are well supported:
- JetBrains Rider / ReSharper: the Encapsulate Field refactoring (
Ctrl+Shift+Ron the field) creates the property over a backing field as a first step; Find Usages then lists every mutation site to migrate to your newAdd/Removemethods. Changing the property type toIReadOnlyList<T>makes the compiler flag every remaining illegal caller. - IntelliJ IDEA (Java): Refactor → Encapsulate Fields hides the field; inspections such as "Return of collection field" (under Encapsulation issues) actively detect getters that expose internal collections and suggest wrapping them in
Collections.unmodifiable...— turn this inspection on and the IDE finds your open registers for you. - Visual Studio:
Ctrl+R, Ctrl+Eencapsulates the field; afterwards, change the property type toIReadOnlyList<T>and let the error list drive the caller migration. Code analyzers (for example CA2227, "Collection properties should be read only") warn about settable collection properties out of the box. - All IDEs: plain text search is your secret weapon. Searching for
.getPlayers().or.Players.Add(reveals every mutation-through-getter in seconds.
The compiler-driven trick is the same in every language: tighten the type first, then fix what turns red. The error list becomes a complete, trustworthy to-do list.
⚖️ Benefits and risks
| Benefits | Risks / Costs |
|---|---|
| Rules (capacity, uniqueness, validity) run on every add and remove — the class can finally keep its promises | A read-only view stops membership changes but not mutation of the elements; deep safety needs immutable elements or deep copies |
Honest mistakes by teammates (sort() in place, splice instead of slice) can no longer corrupt internal state | Defensive copies on every read cost memory and time for very large or hot collections |
Membership changes become named, searchable operations (addStudent) instead of anonymous list edits | More methods to write; small internal helper classes may not repay the ceremony |
| Internal collection type can change (array to Set, add an index) without breaking any caller | Migration must follow the safe order (new methods first, lock last) or many call sites break at once |
| Natural hooks appear for events, caching, audit logging on every change | In plain JavaScript (no TS), mutating a returned copy fails silently — leftover callers need hunting by search, not by crash |
🩺 Which smells does it cure?
| Smell | How Encapsulate Collection helps |
|---|---|
| Data Class | The collection-owning class gains real behaviour — validated add/remove with domain rules — instead of being a bag with a leaky list |
| Inappropriate Intimacy | Outsiders can no longer reach into another object's internal collection and rearrange it |
| Feature Envy | Logic that callers performed on the raw list (checking limits, avoiding duplicates) moves home into the owning class |
| Shotgun Surgery | A new collection rule ("max 11 players") is added in one method, not at every mutation site across the codebase |
The whole guarding strategy on one map — alongside its sibling refactorings Remove Setting Method and Hide Method:
📝 Quick revision box
+================================================================+
| ENCAPSULATE COLLECTION — REVISION CARD |
+================================================================+
| SMELL SIGN : getter returns the LIVE list / bulk setter exists |
| PICTURE : attendance register open on the desk |
| GOLDEN RULE: photocopies out, admission slips in |
+----------------------------------------------------------------+
| THE MOVE : 1. Add add()/remove() methods WITH the rules |
| 2. Redirect mutating callers to them (test each) |
| 3. Delete the bulk setter (constructor copies in) |
| 4. Getter -> read-only view or copy |
| 5. Backing field -> private readonly/final |
| 6. Search for survivors: .getX().push / .Add( |
+----------------------------------------------------------------+
| LANGUAGE : TS -> ReadonlyArray<T> + spread copy |
| TOOLBOX C# -> IReadOnlyList<T> + _list.AsReadOnly() |
| Java -> Collections.unmodifiableList / copyOf |
| Py -> return tuple(self._items) |
| REMEMBER : read-only view guards MEMBERSHIP, not elements |
+================================================================+🏋️ Practice exercise
A school quiz club app manages its members like this:
class QuizClub {
private members: string[] = [];
getMembers(): string[] {
return this.members; // live array escapes
}
setMembers(members: string[]): void {
this.members = members; // bulk swap allowed
}
}
// Found in the codebase:
club.getMembers().push(""); // blank name added
club.getMembers().push("Asha"); // bypasses any limit
club.getMembers().sort(); // "just for display"...
const seniors = club.getMembers().splice(0, 3); // meant to READ three names
club.setMembers(["OnlyMyFriends"]); // coup d'étatYour tasks:
- Apply Encapsulate Collection in the safe order: first write
addMember(name)andremoveMember(name)with rules — no blank names, no duplicates, maximum 15 members. (Check Figure 6: which state are you moving the class into?) - Migrate each of the five bad call sites above to legal alternatives. (Hint: the
sortcaller needs its own copy; thesplicecaller actually wantedslice.) Classify each call site using Figure 2: innocent read, accidental mutation, or deliberate mutation? - Delete
setMembersand add a constructor that accepts initial members and feeds them throughaddMember, so even initial data obeys the rules. - Change
getMembers()to returnReadonlyArray<string>backed by a fresh copy, and make the backing fieldprivate readonly. - Bonus (C#): write the same class with a
private readonly List<string> _members, a propertypublic IReadOnlyList<string> Members => _members.AsReadOnly();, andAddMember/RemoveMembermethods. Confirm thatclub.Members.Add("X")does not compile. Bonus (Python): write the class returningtuple(self._members)and show theAttributeErrora mutating caller would hit. - College-corner question: you chose a defensive copy in task 4. Using the strategy table from the college corner, name one concrete situation in this quiz-club app where a read-only view would behave differently from your copy — and one where the difference would matter.
- Reflection question: after your refactoring, a caller does
club.getMembers()[0].toUpperCase()and gets a new string — but suppose members wereStudentobjects and a caller didclub.getMembers()[0].name = "Hacked". Would your read-only copy stop that? What does this teach about views versus deep immutability — and which refactoring from this series fixes the elements themselves?
Frequently asked questions
- My getter returns the list so callers can read it. What is actually wrong with that?
- Reading is fine — the danger is that a live list allows much more than reading. The same reference that lets a caller loop over items also lets it call clear(), push(), or splice() and silently rewrite your object's insides. Your class then cannot enforce any rule about its own collection. Return a read-only view or a copy: callers keep their reading power and lose only the power they should never have had.
- Should I return a copy or a read-only view? Which is better?
- A copy is simple and fully safe but costs memory and time on every call, and callers see a frozen snapshot. A read-only view (Collections.unmodifiableList in Java, ReadonlyArray typing or Object.freeze in TypeScript, AsReadOnly in C#) is cheap and always current, but in some languages it throws only at runtime if someone tries to mutate. For small collections, either works; pick one style and use it consistently across the codebase.
- Does a read-only view protect the elements inside the collection too?
- No — and this trips up many developers. A read-only view stops membership changes (add, remove, clear) but the objects inside are still the same objects. A caller who cannot remove a Student from the list can still change that student's name. If element mutation matters, make the elements immutable themselves, or return deep copies.
- Why should I also remove the collection setter like setCourses(list)?
- Because a bulk setter is the biggest hole of all: it swaps your entire backing store for a list someone else built — with duplicates, nulls, or over-limit contents your class never checked, and that the caller may still hold a reference to and mutate later. Fowler pairs Encapsulate Collection with Remove Setting Method for exactly this reason. If initial contents are needed, accept them in the constructor and copy them into your own private list.
- In C#, is exposing IEnumerable<T> enough to be safe?
- Mostly, but with a catch: if the property simply returns the private List<T> typed as IEnumerable<T>, a caller can cast it back to List<T> and mutate it. Safer options are returning _items.AsReadOnly(), exposing IReadOnlyList<T> backed by a ReadOnlyCollection wrapper, or returning a copy. EF Core works happily with private backing fields behind IReadOnlyList properties, so even entities can be encapsulated this way.
Further reading
- Encapsulate Collection — refactoring.com catalog (Fowler, 2nd ed.) article
- Encapsulate Collection — Refactoring Guru article
- Domain-Driven Refactoring: Encapsulating Collections — Jimmy Bogard article
- Collections.unmodifiableList in Java — GeeksforGeeks article
- Refactoring (2nd Edition) by Martin Fowler book
Related Lessons
Encapsulate Field: Let the Object Guard Its Own Data
Encapsulate Field explained simply — why public fields let any code corrupt an object's data, and how private fields with getters and setters put the object back in charge.
Data Class: The Register With No Rules — Anyone Can Scribble Anything
Learn the Data Class smell with a society register story. See why data without behavior breaks encapsulation, and when DTOs and records are perfectly fine.
Inappropriate Intimacy: Two Classes That Walk Into Each Other's Kitchens
Learn the Inappropriate Intimacy code smell with a story of two neighbours who rearrange each other's kitchens. When two classes poke each other's private parts, neither can change alone. Learn the Law of Demeter and the refactorings that restore privacy.
Primitive Obsession: When Everything Is Just a String or a Number
Primitive Obsession explained simply — why plain strings and numbers hide bugs, and how value objects like Money and Address make code safe and clear.