Class Design
Core Idea
A class often starts from a concrete pain in a codebase. Teams need one place to protect state rules. Teams need one place to expose safe operations. Class design gives that shape.
A class defines a boundary for one problem slice. That boundary contains state, rules, and invariants. Encapsulation keeps internal detail behind that boundary. Abstraction exposes a small interface for client use. Single Responsibility gives one reason for class change.
All examples below use the shared handbook application context: Hypothetical Scenario.
Responsibility and Boundaries
Good class design starts before code is written. Start by naming the duty in plain language. That sentence becomes the anchor for the boundary.
Class design starts with responsibility naming. Good names map to one business duty. A broad name often marks mixed concerns. Mixed concerns blur boundaries and raise coupling.
A clear class boundary answers four questions.
- What state does this class own
- What invariants does this class protect
- What messages can clients send
- What detail stays hidden from clients
Class boundary model. Public methods form the contract. Private internals stay hidden.
Visibility and Access Levels
Access level choices are design choices, not syntax trivia. Each visibility decision changes coupling pressure for future work.
Visibility defines boundary strength. Public members define the class contract. Private members hold internal state and helper behavior. Protected members support controlled extension in subtype trees.
Public surface should stay small. A small surface lowers accidental coupling. A large surface leaks internal model details. Leaked details force client rewrites after internal refactors.
Visibility level map. Public, protected, and private define access boundaries.
Encapsulation, Abstraction, and Message Passing
Object oriented design is often taught with four pillars. In practice, encapsulation and abstraction carry the core load. Inheritance and polymorphism add power after boundaries are stable.
Encapsulation and abstraction are core OOP ideas. Inheritance and polymorphism support focused class variants. Those techniques depend on strong base contracts.
Message passing is the interaction model in OOP. A client sends a message through a method call. The receiver decides how to satisfy that message. This model keeps callers decoupled from internal algorithms.
Message passing model. Classes collaborate through clear messages and stable contracts.
Demeter's Law and Encapsulation
Demeter's Law becomes clear in maintenance work. Call chains feel convenient during first implementation. Months later those chains lock clients to private structure choices.
Demeter's Law limits who an object may talk to. A method should call:
- The object itself
- Its direct fields
- Its method parameters
- Objects it creates
Violation often appears as call chains.
Example chain form: a.b().c().d().
This pattern exposes deep internal structure.
That exposure breaks encapsulation.
Common failure modes after Demeter violations:
- Cascading changes across many classes
- Fragile tests tied to object graph shape
- High mock count in unit tests
- Hidden knowledge of internal navigation paths
Demeter law visual. Short call paths protect encapsulation.
Python Example
class CarCatalog:
def __init__(self) -> None:
self._cars: dict[str, str] = {}
def register(self, vin: str, model: str) -> None:
self._cars[vin] = model
def model_of(self, vin: str) -> str | None:
return self._cars.get(vin)
class MarketplaceCatalog:
def __init__(self, catalog: CarCatalog) -> None:
self._catalog = catalog
def current_model(self, vin: str) -> str | None:
return self._catalog.model_of(vin)
Demeter break:
Encapsulated call:
C++ Example
Herb Sutter stresses small interfaces and strong invariants in class design (Sutter, 2005). He promotes explicit ownership and tight boundary control in class APIs.
Quote:
"Where possible, prefer making functions nonmember nonfriends."
Source: Herb Sutter and Andrei Alexandrescu, 2004
#include <optional>
#include <string>
#include <unordered_map>
class CarCatalog {
public:
void register_car(const std::string& vin, const std::string& model) {
models_[vin] = model;
}
std::optional<std::string> model_of(const std::string& vin) const {
auto it = models_.find(vin);
if (it == models_.end()) {
return std::nullopt;
}
return it->second;
}
private:
std::unordered_map<std::string, std::string> models_;
};
// Non-member function uses public contract only.
bool has_model(const CarCatalog& catalog, const std::string& vin) {
return catalog.model_of(vin).has_value();
}
This structure keeps data private. Clients pass messages through stable public functions. The non member helper avoids privileged access.
TypeScript Example
class CarCatalog {
#models: Map<string, string> = new Map();
register(vin: string, model: string): void {
this.#models.set(vin, model);
}
modelOf(vin: string): string | undefined {
return this.#models.get(vin);
}
}
class MarketplaceCatalog {
constructor(private readonly catalog: CarCatalog) {}
currentModel(vin: string): string | undefined {
return this.catalog.modelOf(vin);
}
}
Demeter break:
Encapsulated call:
Practice Checklist
- Name one class responsibility in one sentence
- Keep public methods minimal and cohesive
- Hide mutable state with private fields
- Use protected only for true subtype extension points
- Reject deep call chains in code review
- Keep invariants enforced inside class methods
- Compose classes through clear message based contracts
Written by: Pedro Guzmán
See References for complete APA-style bibliographic entries used on this page.