Object Calisthenics : Rule 4


Introduction
Hi Dev, have you heard about Object Calisthenics before?
Object Calisthenics is a set of nine coding rules introduced by Jeff Bay in The ThoughtWorks Anthology. The term combines "Object", referring to object-oriented programming (OOP), and "Calisthenics", which means structured exercises — in this case, applied to code. These rules are meant to help developers internalize good OOP principles by practicing them at the micro level, directly in their day-to-day coding.
You’re not expected to follow every rule 100% of the time. Instead, the idea is to use them as a guide — a structured constraint — to help shift your coding habits and improve the design of your software. As you apply these rules consistently, you’ll begin to see the benefits: better encapsulation, cleaner abstractions, and more testable, readable, and maintainable code.
Seven of the nine rules focus directly on strengthening encapsulation — one of the core principles of OOP. Another encourages replacing conditionals with polymorphism. The final rule helps improve naming clarity by avoiding cryptic abbreviations. Together, they push developers to write code that is free from duplication and easier to reason about.
At first, following these rules may feel uncomfortable or even counterproductive. But that friction is exactly the point — it forces you to break old habits and rethink how your objects interact. Over time, these small design constraints train you to write code that is simpler, more focused, and easier to evolve.
In this article, we’re diving into Rule #4 – First-Class Collections.
When filtering or updating collections spreads across your code, duplication and bugs follow. This rule helps you bring that logic back under control — with cleaner, safer, and more expressive code.
Rule Definition and Explanation
First-Class Collections states:
Any class that contains a collection should do nothing else*.*
All access to the collection must go through this class.
In practice, instead of scattering list
, dict
, or set
manipulations throughout your codebase, you wrap the collection in its own object and expose only intention-revealing methods (add_item
, total_price
, ordered_by_priority
, …).
The wrapper becomes a domain concept rather than just “some list of things”.
Why Do the Rule?
Encapsulation – Prevents external code from reaching in and mutating the list at will.
Single Responsibility – The new class focuses solely on collection behavior (sorting, validation, aggregation).
Rich Domain Language –
ShoppingCart.add(product)
speaks louder thancart_items.append(product)
.Testability – You can unit-test collection logic without dragging in unrelated state.
Evolution – You can swap
list
for, say, a priority queue without touching the rest of the system.
Example 1 – Hiding Low-Level Operations
Avoid using raw lists (or other collections like dictionaries or sets) directly in your code, especially in many different places.
Instead, create a class that wraps the list. This makes your code more organized, easier to understand, and safer to change in the future.
❌ Violates the rule
orders: list[Order] = []
# in other parts of the code...
orders.append(order)
orders.sort(key=lambda o: o.date)
In this example, the orders
list is accessed and modified directly in many places. The logic for adding and sorting is not controlled in one place.
Problems with this code
The list is used in many parts of the code: You need to remember everywhere how to add and sort orders. If the sorting rule changes later, you will have to find and fix it in many places — and that’s risky.
Sorting logic might be copied: If you write
orders.sort(key=lambda o:
o.date
)
in many places, you're repeating the same code. If the sorting rule changes (for example: sort by date and by priority), you must update all the places manually. This breaks the DRY principle (Don't Repeat Yourself) — in other words: don’t copy the same logic in many parts of the code.The code is doing too many things at once (adding a new order and deciding how to sort the list): This breaks a good object-oriented programming idea called cohesion. Cohesion means that each part of the code should have only one clear responsibility. If one part does many different things, the code becomes harder to understand and fix.
Better way: wrap the list in a class
#✅ First-Class Collection
class Orders:
def __init__(self):
self._items: list[Order] = []
def add(self, order: Order) -> None:
self._items.append(order)
def by_date(self) -> list[Order]:
return sorted(self._items, key=lambda o: o.date)
Now we have a class called Orders
that contains the list and knows how to use it.
Benefits of this approach
Only the
Orders
class knows how to add or sort orders. The rest of the code just calls the methods. This protects your business logic.If the sorting rule changes, you only need to update it in one place.
The list is no longer just a simple structure of data. It becomes a domain object, which means it represents a real concept in your system — the list of orders.
Example 2 – Enforcing Business Rules (Invariants)
In the last example, we focused on hiding low-level operations like append()
and sort()
.
This time, the focus is different:
We need to protect a rule that must always be true — this is called an invariant.
It’s not just about better structure. It’s about stopping bad data from entering the system, even by mistake.
❌ Example that breaks the rule
def place_bid(bids, bid):
if bid.amount > max(b.amount for b in bids):
bids.append(bid)
This looks fine, but the rule “only accept higher bids” is not protected.
Anyone can add a wrong bid if they forget the condition.
Problems with this code
Bad data can be saved in this case: Before, the issue was code duplication and organization.
Now, the system can accept invalid business data — which could break a real auction or process.The class must control access, not just structure: In the previous example, the class just helped organize things. Here, the class becomes a gatekeeper: it decides if something can be added.
The logic includes validation and return value: The method
place()
can refuse the bid and returnsFalse
. That means the class now includes decision-making logic, not only behavior.
#✅ Better version: encapsulate the validation logic
class Bids:
def __init__(self):
self._highest: float = 0.0
self._items: list[Bid] = []
def place(self, bid: Bid) -> bool:
if bid.amount <= self._highest:
return False
self._items.append(bid)
self._highest = bid.amount
return True
Benefits of this approach
Now, only the class can allow or reject bids.
External code doesn’t need to worry about the rule anymore.
If the rule changes, you only update one place.
It’s very clear who is responsible for enforcing the rule: the
Bids
object itself.
Example 3 – Rich Queries
In the first examples, we focused on hiding low-level actions and enforcing business rules.
Now, the focus is different again:
We want to make queries in the code more clear, reusable, and easy to read.
#❌ Example with repeated filtering
active_users = [u for u in users if u.is_active]
This works fine, but this kind of logic often gets repeated in many places.
Problems with this code
It’s not about protection — it’s about readability and reuse: In earlier examples, we were trying to protect data or enforce a rule. Here, the goal is just to query a specific subset, and we want to avoid writing the same filter everywhere.
The risk is duplicated code and unclear intent : If you filter active users in many files, you repeat the same code. Also,
if
u.is
_active
shows how you're doing it, but not what you're trying to do.
#✅ Better version: use a method with a clear name
class Users:
def __init__(self, initial: Iterable[User] = ()):
self._items = list(initial)
def active(self) -> list[User]:
return [u for u in self._items if u.is_active]
active_users = users.active()
The second version is cleaner and easier to understand — like you're saying: “give me the active users”.
Benefits specific to this case
The method makes your code more readable and says what it does, not how.
If the logic for “active users” changes later, you only update it in one place.
It helps avoid repetition and makes future changes easier.
It makes the code easier to understand, especially for new developers or team members.
Why This Rule Matters
Readability – Domain-specific methods replace generic list operations.
Robustness – You funnel all mutations through a controlled API.
Discoverability – IDE autocompletion now shows meaningful behaviors (
highest_bid
,remove_expired_sessions
, …).Reusability – Downstream code consumes behavior, not structure.
Trade-Offs
❌ Cost | ✅ Payoff |
Extra small classes | Stronger SRP & clearer intent |
Slightly more boilerplate | Centralized invariants & validation |
Might feel “over-engineered” for tiny scripts | Saves hours when requirements evolve |
Practical Tips
Start where pain exists – Wrap collections that already have duplicated logic or tricky rules.
Expose intentions, not structures – Prefer
contains_email(email)
overreturn email in self._items
.Keep it lightweight – Often a thin façade with 3–6 methods is enough.
Immutable where possible – Return new collections (or
tuple
) instead of raw internal lists.
Works Great With…
Rule #3: Wrap All Primitives and Strings – Together they move you toward ubiquitous, self-documenting domain types.
Rule #7: Keep All Entities Small – A focused collection object naturally stays tiny.
SRP & Tell-Don’t-Ask – Consumers tell the collection what to do, not ask what it has and decide outside.
Final Thoughts
Making collections first-class citizens may feel like an extra step today, but tomorrow it pays back with code that models the problem space instead of raw data structures.
Try refactoring just one list or dict into its own class this week—you’ll quickly see how much accidental complexity disappears.
Keep flexing those Object Calisthenics muscles, and stay tuned for the next rule!
Subscribe to my newsletter
Read articles from Leo Bcheche directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
