Chapter 10: Classes
clean-code classes srp cohesion solid oop
Status: Notes complete
Difficulty: Medium
Time to complete: ~45 min read
Overview
We have learned to write clean lines and clean functions — now we must think at the next level: clean classes. The rules for clean classes follow naturally from the rules for clean functions: a class should do one thing, do it well, and do it only.
The central thesis of this chapter: size is measured in responsibilities, not lines of code. A 100-line class can be bloated if it has three responsibilities. A 500-line class can be focused if it has one.
This chapter covers:
- Conventional Java class organization
- Classes should be small — measured by responsibilities
- The Single Responsibility Principle (SRP)
- Cohesion — what it is and how to increase it
- How refactoring large functions into smaller ones can break cohesion (and how to fix it)
- Organizing for change — the Open/Closed Principle (OCP)
- Isolating from change — the Dependency Inversion Principle (DIP)
The Problem: What Bad Code Looks Like
A class that has too many responsibilities is the most common class-level problem in production codebases. It typically starts small and accretes functionality over time, because the path of least resistance is always “add it to the class that already has most of the relevant state.”
// BAD — a "God Class" that does authentication, profile management,
// email notifications, billing, and session management all in one
public class UserManager {
private final Database db;
private final EmailClient email;
private final BillingSystem billing;
private final SessionStore sessions;
private final PasswordHasher hasher;
public boolean login(String username, String password) { ... }
public void logout(String sessionToken) { ... }
public void updateProfile(int userId, ProfileData data) { ... }
public void changeAvatar(int userId, byte[] imageBytes) { ... }
public void sendWelcomeEmail(int userId) { ... }
public void sendPasswordResetEmail(String email) { ... }
public void chargeMonthlySub(int userId) { ... }
public void applyPromoCode(int userId, String code) { ... }
public void createSession(int userId) { ... }
public boolean validateSession(String token) { ... }
// ... 20 more methods
}Problems with UserManager:
- 5+ distinct reasons to change: authentication logic changes, profile schema changes, email template changes, billing rates change, session timeout changes
- Impossible to unit test any one responsibility without dragging in the others
- Every developer touching user-related features ends up editing the same class, causing merge conflicts
- The name “Manager” is a red flag — it’s an admission that we don’t know what the class does
Core Principles
1. Class Organization
Why this rule exists: Conventions for class layout reduce the cognitive load of reading unfamiliar code. When you know where to look for variables and methods, you spend less time scanning and more time understanding.
The standard Java convention for class layout (top to bottom):
public static finalconstantsprivate staticvariablesprivateinstance variablespublicconstructorspublicmethods (the published interface)privateutility methods near the public method that calls them
Encapsulation rule: Keep variables and utility functions private unless a test requires access. If a test needs access, prefer protected over public. Loosening encapsulation for tests is acceptable; loosening it for callers destroys the abstraction.
// GOOD — organized by convention; private utilities follow the public method that calls them
public class OrderProcessor {
// 1. Public constants
public static final int MAX_ITEMS_PER_ORDER = 50;
// 2. Private static
private static final Logger log = LoggerFactory.getLogger(OrderProcessor.class);
// 3. Private instance variables
private final PaymentGateway paymentGateway;
private final InventoryService inventory;
// 4. Public constructor
public OrderProcessor(PaymentGateway paymentGateway, InventoryService inventory) {
this.paymentGateway = paymentGateway;
this.inventory = inventory;
}
// 5. Public methods
public Order process(Cart cart) {
validateCart(cart);
reserveInventory(cart);
return chargeAndCreateOrder(cart);
}
// 6. Private utilities — placed near the public method that uses them
private void validateCart(Cart cart) {
if (cart.isEmpty()) throw new EmptyCartException();
if (cart.getItemCount() > MAX_ITEMS_PER_ORDER)
throw new CartTooLargeException(cart.getItemCount());
}
private void reserveInventory(Cart cart) {
cart.getItems().forEach(item ->
inventory.reserve(item.getSku(), item.getQuantity()));
}
private Order chargeAndCreateOrder(Cart cart) {
PaymentResult result = paymentGateway.charge(cart.getTotal(), cart.getPaymentMethod());
return Order.from(cart, result);
}
}// BAD — variables scattered throughout, utilities at the top, no coherent reading order
public class OrderProcessor {
private final PaymentGateway paymentGateway;
private void validateCart(Cart cart) { ... } // private utility at the top — confusing
public Order process(Cart cart) { ... } // public method buried in the middle
private final InventoryService inventory; // field declared mid-class
public static final int MAX_ITEMS = 50; // constant after methods
}2. Classes Should Be Small
Why this rule exists: The name of a class is the first signal of its size. If you cannot give a class a precise, concise name — or if the name uses weasel words like “Processor”, “Manager”, “Super”, or “Handler” — the class is likely doing too much. The name forces you to articulate what the class does in a single concept.
A second test: can you describe what the class does in 25 words or fewer, without using “and”, “or”, “but”, or “if”? If you need a conjunction, the class has more than one responsibility.
// BAD — SuperDashboard tries to do everything: versioning, focus tracking,
// component management, event handling
public class SuperDashboard extends JFrame implements MetaDataUser {
public Component getLastFocusedComponent() { ... }
public void setLastFocused(Component c) { ... }
public int getMajorVersionNumber() { ... }
public int getMinorVersionNumber() { ... }
public int getBuildNumber() { ... }
public String getVersionString() { ... }
public void addComponent(DashboardComponent c) { ... }
public void removeComponent(DashboardComponent c) { ... }
public List<DashboardComponent> getComponents() { ... }
// ... many more
}// GOOD — each class has one clear responsibility with a precise name
public class Version {
public int getMajorVersionNumber() { ... }
public int getMinorVersionNumber() { ... }
public int getBuildNumber() { ... }
public String asString() { ... }
}
public class FocusTracker {
public Component getLastFocused() { ... }
public void setLastFocused(Component c) { ... }
}
public class Dashboard extends JFrame {
private final Version version;
private final FocusTracker focusTracker;
// component management only
public void addComponent(DashboardComponent c) { ... }
public void removeComponent(DashboardComponent c) { ... }
}Warning signs that a class is too large:
- Name contains: Manager, Processor, Handler, Super, Controller (when it does more than route)
- Name contains “And”:
EmailAndNotificationService - The class has more than 7-10 public methods on completely different data
- You cannot write a one-sentence description without conjunctions
3. The Single Responsibility Principle (SRP)
Why this rule exists: SRP is the most important of the SOLID principles. A class with one reason to change is predictable — you know exactly what might affect it and what it might affect. A class with three reasons to change is a coupling point: changes for any of the three reasons risk breaking the other two. SRP is what makes systems maintainable as they grow.
A class or module should have one, and only one, reason to change.
SRP is often violated not out of ignorance but out of discipline failure: “it works, leave it alone.” The refactoring required to achieve SRP feels like extra work. But the extra work is paid forward every time the code changes, because each change is smaller and safer.
Java — UserService extracted into three classes:
// BAD — UserService has three reasons to change:
// authentication logic, profile management, email notifications
public class UserService {
private final Database db;
private final EmailClient email;
// Reason 1: authentication changes
public boolean authenticate(String username, String password) {
User user = db.findByUsername(username);
return user != null && hasher.check(password, user.getPasswordHash());
}
public void changePassword(int userId, String newPassword) {
db.update(userId, "password_hash", hasher.hash(newPassword));
email.send(db.findById(userId).getEmail(), "Your password was changed");
}
// Reason 2: profile schema changes
public void updateProfile(int userId, String displayName, String bio) {
db.update(userId, "display_name", displayName);
db.update(userId, "bio", bio);
}
// Reason 3: email template changes
public void sendWelcomeEmail(int userId) {
User user = db.findById(userId);
email.send(user.getEmail(), welcomeTemplate.render(user));
}
}// GOOD — three classes, each with one reason to change
public class AuthenticationService {
private final UserRepository userRepository;
private final PasswordHasher hasher;
public boolean authenticate(String username, String password) {
User user = userRepository.findByUsername(username);
return user != null && hasher.verify(password, user.getPasswordHash());
}
public void changePassword(int userId, String newPassword) {
userRepository.updatePasswordHash(userId, hasher.hash(newPassword));
}
}
public class UserProfileService {
private final UserRepository userRepository;
public void updateProfile(int userId, ProfileData data) {
userRepository.updateProfile(userId, data);
}
public User getProfile(int userId) {
return userRepository.findById(userId);
}
}
public class UserNotificationService {
private final EmailClient emailClient;
private final TemplateRenderer templateRenderer;
private final UserRepository userRepository;
public void sendWelcomeEmail(int userId) {
User user = userRepository.findById(userId);
String body = templateRenderer.render("welcome", user);
emailClient.send(user.getEmail(), "Welcome!", body);
}
public void sendPasswordChangedAlert(int userId) {
User user = userRepository.findById(userId);
emailClient.send(user.getEmail(), "Password changed", "Your password was changed.");
}
}C++ equivalent:
// GOOD — SRP applied; each class has one reason to change
class AuthenticationService {
public:
explicit AuthenticationService(UserRepository& repo, PasswordHasher& hasher)
: repo_(repo), hasher_(hasher) {}
bool authenticate(const std::string& username, const std::string& password) {
auto user = repo_.findByUsername(username);
return user && hasher_.verify(password, user->passwordHash);
}
private:
UserRepository& repo_;
PasswordHasher& hasher_;
};
class UserProfileService {
public:
explicit UserProfileService(UserRepository& repo) : repo_(repo) {}
void updateProfile(int userId, const ProfileData& data) {
repo_.updateProfile(userId, data);
}
private:
UserRepository& repo_;
};
class UserNotificationService {
public:
UserNotificationService(EmailClient& email, TemplateRenderer& tmpl,
UserRepository& repo)
: email_(email), tmpl_(tmpl), repo_(repo) {}
void sendWelcomeEmail(int userId) {
auto user = repo_.findById(userId);
email_.send(user.email, "Welcome!", tmpl_.render("welcome", user));
}
private:
EmailClient& email_;
TemplateRenderer& tmpl_;
UserRepository& repo_;
};Python equivalent:
# GOOD — SRP with type hints; each class has one reason to change
from dataclasses import dataclass
from typing import Optional
class AuthenticationService:
def __init__(self, user_repo: "UserRepository", hasher: "PasswordHasher") -> None:
self._repo = user_repo
self._hasher = hasher
def authenticate(self, username: str, password: str) -> bool:
user: Optional[User] = self._repo.find_by_username(username)
return user is not None and self._hasher.verify(password, user.password_hash)
def change_password(self, user_id: int, new_password: str) -> None:
self._repo.update_password_hash(user_id, self._hasher.hash(new_password))
class UserProfileService:
def __init__(self, user_repo: "UserRepository") -> None:
self._repo = user_repo
def update_profile(self, user_id: int, data: "ProfileData") -> None:
self._repo.update_profile(user_id, data)
class UserNotificationService:
def __init__(
self,
email_client: "EmailClient",
template_renderer: "TemplateRenderer",
user_repo: "UserRepository",
) -> None:
self._email = email_client
self._tmpl = template_renderer
self._repo = user_repo
def send_welcome_email(self, user_id: int) -> None:
user = self._repo.find_by_id(user_id)
body = self._tmpl.render("welcome", user)
self._email.send(user.email, "Welcome!", body)4. Cohesion
Why this rule exists: A cohesive class is one where all the methods work with most of the instance variables. High cohesion means the class hangs together as a meaningful unit. Low cohesion means the class is really multiple classes accidentally merged into one — some methods use variables A and B, others use C and D, and no method uses all four together.
Measuring cohesion: For each method, count how many instance variables it touches. Divide by total instance variables. Average across all methods. The higher the ratio, the more cohesive the class.
// BAD — low cohesion; Stack class has a print() method that only uses one
// of the class's variables, suggesting it doesn't belong here
public class Stack {
private int topOfStack = 0;
private List<Integer> elements = new LinkedList<>();
private PrintStream printStream; // only used by print(), not by stack operations
public boolean isEmpty() { return topOfStack == 0; }
public void push(int element) { elements.add(++topOfStack, element); }
public int pop() { return elements.remove(topOfStack--); }
// LOW COHESION: uses printStream but not topOfStack or elements in a meaningful way
public void print() {
printStream.println("Stack depth: " + topOfStack);
elements.forEach(e -> printStream.println(" " + e));
}
}// GOOD — Stack handles stack operations; printing is a concern of the caller
public class Stack {
private int topOfStack = 0;
private final List<Integer> elements = new LinkedList<>();
public boolean isEmpty() { return topOfStack == 0; }
public void push(int element) {
elements.add(++topOfStack, element);
}
public int pop() {
if (isEmpty()) throw new EmptyStackException();
return elements.remove(topOfStack--);
}
public List<Integer> asList() {
return Collections.unmodifiableList(elements);
}
}
// Caller decides how to present the stack
stackView.render(stack.asList());5. Maintaining Cohesion Results in Many Small Classes
Why this rule exists: When you refactor a large function into smaller ones, you often find you need to pass variables between the smaller functions. If you make those variables instance variables instead, you solve the argument-passing problem — but the class’s cohesion drops, because now many methods touch only a subset of the instance variables. The fix is to extract a new class containing the variables and the methods that use them.
This is how SRP and cohesion reinforce each other: the act of maintaining cohesion is the act of splitting classes. Many small, cohesive classes are always preferable to a few large, diffuse ones.
// BAD — PrintPrimes class has two cohesion clusters:
// prime generation (using primes[], ord, cc, j) and
// page formatting (using page, row, col, pageNumber)
public class PrintPrimes {
private static final int ROWS_PER_PAGE = 50;
private static final int COLUMNS_PER_PAGE = 4;
private int[] primes;
private int ord, cc, j; // prime generation variables
private int page, row, col, pageNumber; // page formatting variables
public void print() {
generatePrimes();
formatPages();
}
private void generatePrimes() {
// uses primes[], ord, cc, j
}
private void formatPages() {
// uses page, row, col, pageNumber — different variable set entirely
}
}// GOOD — two classes; each is cohesive with its own variables
public class PrimeGenerator {
private final int count;
private int[] primes;
private boolean[] crossedOut;
public PrimeGenerator(int count) { this.count = count; }
public int[] generate() {
initializeSieve();
crossOutMultiples();
return extractPrimes();
}
// all private methods touch the same 3 instance variables
private void initializeSieve() { ... }
private void crossOutMultiples() { ... }
private int[] extractPrimes() { ... }
}
public class PrimePrinter {
private final int rowsPerPage;
private final int columnsPerPage;
public PrimePrinter(int rowsPerPage, int columnsPerPage) { ... }
public void print(int[] primes, PrintStream out) {
// formatting logic only; receives primes as input
int page = 1, row = 1, col = 1;
for (int prime : primes) {
formatCell(prime, row, col, page, out);
col = nextColumn(col);
row = nextRow(row, col);
}
}
}6. Organizing for Change — The Open/Closed Principle (OCP)
Why this rule exists: Every time you modify an existing class, you risk breaking working behavior. OCP (Open for extension, Closed for modification) is the discipline of writing code so that new behaviors are added by adding new code rather than changing existing code. This is most commonly achieved through polymorphism: a base interface or abstract class + concrete implementations.
“Classes should be open for extension but closed for modification.”
Java — Sql class refactored into a strategy hierarchy:
// BAD — adding UPDATE support requires modifying the existing Sql class,
// risking breakage of SELECT and INSERT
public class Sql {
public String select(String table, Criteria criteria) {
return "SELECT * FROM " + table + " WHERE " + criteria.toSql();
}
public String insert(String table, Map<String, Object> values) {
// build INSERT SQL
}
// Adding UPDATE requires modifying this class — violates OCP
public String update(String table, Map<String, Object> values, Criteria where) {
// build UPDATE SQL
}
}// GOOD — abstract Sql base; adding UPDATE means adding UpdateSql, not modifying SelectSql
public abstract class Sql {
protected final String table;
public Sql(String table) { this.table = table; }
public abstract String generate();
}
public class SelectSql extends Sql {
private final Criteria criteria;
public SelectSql(String table, Criteria criteria) {
super(table);
this.criteria = criteria;
}
@Override
public String generate() {
return "SELECT * FROM " + table + " WHERE " + criteria.toSql();
}
}
public class InsertSql extends Sql {
private final Map<String, Object> values;
public InsertSql(String table, Map<String, Object> values) {
super(table);
this.values = values;
}
@Override
public String generate() {
String cols = String.join(", ", values.keySet());
String vals = values.values().stream()
.map(v -> "'" + v + "'")
.collect(Collectors.joining(", "));
return "INSERT INTO " + table + " (" + cols + ") VALUES (" + vals + ")";
}
}
// Adding UPDATE: zero modifications to existing classes
public class UpdateSql extends Sql {
private final Map<String, Object> values;
private final Criteria where;
public UpdateSql(String table, Map<String, Object> values, Criteria where) {
super(table);
this.values = values;
this.where = where;
}
@Override
public String generate() {
String sets = values.entrySet().stream()
.map(e -> e.getKey() + " = '" + e.getValue() + "'")
.collect(Collectors.joining(", "));
return "UPDATE " + table + " SET " + sets + " WHERE " + where.toSql();
}
}C++ equivalent:
// GOOD — OCP with abstract class hierarchy
class Sql {
public:
explicit Sql(std::string table) : table_(std::move(table)) {}
virtual ~Sql() = default;
virtual std::string generate() const = 0;
protected:
std::string table_;
};
class SelectSql : public Sql {
public:
SelectSql(std::string table, Criteria criteria)
: Sql(std::move(table)), criteria_(std::move(criteria)) {}
std::string generate() const override {
return "SELECT * FROM " + table_ + " WHERE " + criteria_.toSql();
}
private:
Criteria criteria_;
};
// Adding UpdateSql: no changes to SelectSql or InsertSql
class UpdateSql : public Sql {
public:
UpdateSql(std::string table, ColumnMap values, Criteria where)
: Sql(std::move(table)), values_(std::move(values)), where_(std::move(where)) {}
std::string generate() const override {
return buildUpdateQuery(table_, values_, where_);
}
private:
ColumnMap values_;
Criteria where_;
};Python equivalent:
# GOOD — OCP with ABC; new SQL types added without touching existing classes
from abc import ABC, abstractmethod
class Sql(ABC):
def __init__(self, table: str) -> None:
self._table = table
@abstractmethod
def generate(self) -> str: ...
class SelectSql(Sql):
def __init__(self, table: str, criteria: "Criteria") -> None:
super().__init__(table)
self._criteria = criteria
def generate(self) -> str:
return f"SELECT * FROM {self._table} WHERE {self._criteria.to_sql()}"
class InsertSql(Sql):
def __init__(self, table: str, values: dict[str, object]) -> None:
super().__init__(table)
self._values = values
def generate(self) -> str:
cols = ", ".join(self._values.keys())
vals = ", ".join(f"'{v}'" for v in self._values.values())
return f"INSERT INTO {self._table} ({cols}) VALUES ({vals})"
# Adding UPDATE: zero modifications to SelectSql or InsertSql
class UpdateSql(Sql):
def __init__(self, table: str, values: dict[str, object], where: "Criteria") -> None:
super().__init__(table)
self._values = values
self._where = where
def generate(self) -> str:
sets = ", ".join(f"{k} = '{v}'" for k, v in self._values.items())
return f"UPDATE {self._table} SET {sets} WHERE {self._where.to_sql()}"7. Isolating from Change — The Dependency Inversion Principle (DIP)
Why this rule exists: High-level business logic should not depend on low-level infrastructure details. If Portfolio directly instantiates TokyoStockExchange, you cannot test Portfolio without making real market calls. You also cannot reuse Portfolio with a different exchange. DIP solves this by introducing an abstraction (interface) that both the high-level class and the low-level class depend on.
“High-level modules should not depend on low-level modules. Both should depend on abstractions.”
The test consequence is powerful: when Portfolio depends on a StockExchange interface, tests can inject a FixedStockExchange that returns predictable prices — making tests Fast, Independent, and Repeatable.
Java — Portfolio with injected StockExchange interface:
// BAD — Portfolio directly depends on a concrete class; untestable; not portable
public class Portfolio {
private final TokyoStockExchange exchange = new TokyoStockExchange(); // hardcoded
public BigDecimal totalValue(List<String> tickers) {
return tickers.stream()
.map(ticker -> exchange.getPrice(ticker)) // real market call
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}// GOOD — Portfolio depends on the StockExchange interface; testable and portable
public interface StockExchange {
BigDecimal getPrice(String ticker);
}
public class TokyoStockExchange implements StockExchange {
@Override
public BigDecimal getPrice(String ticker) {
// real market data call
return realTimeMarket.lookup(ticker);
}
}
public class Portfolio {
private final StockExchange exchange;
// Dependency injection via constructor — decoupled from implementation
public Portfolio(StockExchange exchange) {
this.exchange = exchange;
}
public BigDecimal totalValue(List<String> tickers) {
return tickers.stream()
.map(exchange::getPrice)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
// In tests: inject a fixed fake; no market calls; Fast + Repeatable
public class FixedStockExchange implements StockExchange {
private final Map<String, BigDecimal> prices;
public FixedStockExchange(Map<String, BigDecimal> prices) {
this.prices = prices;
}
@Override
public BigDecimal getPrice(String ticker) {
return prices.getOrDefault(ticker, BigDecimal.ZERO);
}
}
@Test
public void portfolioValueIsSumOfStockPrices() {
StockExchange exchange = new FixedStockExchange(Map.of(
"AAPL", new BigDecimal("150.00"),
"GOOG", new BigDecimal("2800.00")
));
Portfolio portfolio = new Portfolio(exchange);
BigDecimal total = portfolio.totalValue(List.of("AAPL", "GOOG"));
assertEquals(new BigDecimal("2950.00"), total);
}With Spring @Inject (Java):
// In a Spring application: DIP is enforced by the IoC container
@Service
public class Portfolio {
private final StockExchange exchange;
@Inject // Spring injects TokyoStockExchange at runtime
public Portfolio(StockExchange exchange) {
this.exchange = exchange;
}
}
@Configuration
public class AppConfig {
@Bean
public StockExchange stockExchange() {
return new TokyoStockExchange(); // swapped without touching Portfolio
}
}C++ equivalent:
// GOOD — DIP via pure virtual interface; Portfolio owns a unique_ptr to the interface
class StockExchange {
public:
virtual ~StockExchange() = default;
virtual double getPrice(const std::string& ticker) const = 0;
};
class TokyoStockExchange : public StockExchange {
public:
double getPrice(const std::string& ticker) const override {
return realTimeMarket_.lookup(ticker);
}
private:
RealTimeMarket realTimeMarket_;
};
class Portfolio {
public:
explicit Portfolio(std::unique_ptr<StockExchange> exchange)
: exchange_(std::move(exchange)) {}
double totalValue(const std::vector<std::string>& tickers) const {
double total = 0.0;
for (const auto& ticker : tickers)
total += exchange_->getPrice(ticker);
return total;
}
private:
std::unique_ptr<StockExchange> exchange_;
};
// In test: inject a fake
class FixedStockExchange : public StockExchange {
public:
explicit FixedStockExchange(std::unordered_map<std::string, double> prices)
: prices_(std::move(prices)) {}
double getPrice(const std::string& ticker) const override {
auto it = prices_.find(ticker);
return it != prices_.end() ? it->second : 0.0;
}
private:
std::unordered_map<std::string, double> prices_;
};
TEST(PortfolioTest, TotalValueIsSumOfPrices) {
auto exchange = std::make_unique<FixedStockExchange>(
std::unordered_map<std::string, double>{{"AAPL", 150.0}, {"GOOG", 2800.0}});
Portfolio portfolio{std::move(exchange)};
EXPECT_DOUBLE_EQ(2950.0, portfolio.totalValue({"AAPL", "GOOG"}));
}Python equivalent:
# GOOD — DIP with Protocol (structural typing; no explicit inheritance needed)
from typing import Protocol
from decimal import Decimal
class StockExchange(Protocol):
def get_price(self, ticker: str) -> Decimal: ...
class TokyoStockExchange:
def get_price(self, ticker: str) -> Decimal:
return real_time_market.lookup(ticker)
class Portfolio:
def __init__(self, exchange: StockExchange) -> None:
self._exchange = exchange
def total_value(self, tickers: list[str]) -> Decimal:
return sum(self._exchange.get_price(t) for t in tickers)
# In tests: inject a fake; TokyoStockExchange is never used
class FixedStockExchange:
def __init__(self, prices: dict[str, Decimal]) -> None:
self._prices = prices
def get_price(self, ticker: str) -> Decimal:
return self._prices.get(ticker, Decimal("0.00"))
def test_portfolio_value_is_sum_of_prices():
exchange = FixedStockExchange({"AAPL": Decimal("150.00"), "GOOG": Decimal("2800.00")})
portfolio = Portfolio(exchange)
assert portfolio.total_value(["AAPL", "GOOG"]) == Decimal("2950.00")Comparison / Summary Table
SOLID Principles Quick Reference
| Principle | Letter | Rule | Violation Sign | Fix |
|---|---|---|---|---|
| Single Responsibility | S | One reason to change | ”Manager”, “Processor”, “Super” in name | Extract classes by responsibility |
| Open/Closed | O | Open for extension, closed for modification | Switches on type; growing if-elseif chains | Polymorphism; strategy/template method pattern |
| Liskov Substitution | L | Subtypes usable where supertypes are expected | Override that throws or weakens preconditions | Ensure subtype preserves contract of base type |
| Interface Segregation | I | Clients not forced to depend on unused methods | Fat interface with methods that some clients never call | Split into smaller, role-specific interfaces |
| Dependency Inversion | D | Depend on abstractions, not concretions | new ConcreteClass() inside constructor or method | Constructor injection + interface |
Class Size Indicators
| Signal | Likely Problem | Action |
|---|---|---|
| Name uses “Manager”, “Processor”, “Handler” | Too many responsibilities | Apply SRP — extract by responsibility |
| Description needs “and” / “or” | Multiple responsibilities | Split into focused classes |
| Methods fall into two unrelated variable groups | Low cohesion | Extract the second group into a new class |
| Changing feature X requires modifying this class even for unrelated reasons | SRP violation | Extract the reason for change into its own class |
| Can’t test class without setting up an unrelated subsystem | Tight coupling / DIP violation | Introduce interface; inject dependency |
When to Apply / Common Exceptions
Apply SRP when:
- A class has methods that belong to clearly distinct business concepts
- Making a change for one feature requires modifying a class that “owns” another feature
- You cannot write a clean unit test for one responsibility without instantiating another
Apply OCP when:
- You add new variants of an operation regularly (new SQL types, new payment methods, new report formats)
- The existing implementations are stable and well-tested
- You want to add behavior without risking existing behavior
Apply DIP when:
- A class depends on I/O, the network, a database, the clock, or any external system
- You need to test a class in isolation
- You want to be able to swap implementations (e.g., for different environments)
Common exceptions and nuances:
- SRP and pragmatism: Not every class with two methods has a SRP violation. The question is “reason to change,” not “number of methods.” A
Pointclass withgetX()andgetY()has one responsibility. - OCP and premature abstraction: Don’t create abstract hierarchies until you have at least two concrete implementations and evidence you’ll need more. Premature OCP adds complexity without benefit.
- Cohesion vs. small classes: Very small classes (1-2 methods, 1 field) may indicate over-fragmentation. Cohesion is the goal; tiny classes are a byproduct, not the target.
- DIP with value objects: You don’t need to inject a
Moneyinterface — simple value objects without side effects don’t need DIP.
Checklist
When writing or reviewing a class, verify:
- Class name is precise and concise — no “Manager”, “Processor”, or “Super”
- Class description in 25 words or fewer, no conjunctions required
- Only one reason the class would need to change (SRP)
- Variables and utility functions are
privateunless a compelling reason exists - Private utility methods are placed near the public method that calls them
- All methods use most of the instance variables (high cohesion)
- If a subset of methods only uses a subset of variables, consider extracting a class
- No
new ConcreteClass()for anything with side effects in constructors (DIP) - External dependencies are injected — not constructed inside methods
- Adding a new variant of behavior requires adding code, not modifying existing code (OCP)
- The class can be unit tested without instantiating unrelated subsystems
Key Takeaways
- Size is measured in responsibilities, not lines of code. A class with one responsibility can be any size; a class with three responsibilities is too large no matter how small it looks.
- The name is the first signal: if you can’t give a class a precise single-concept name, it has too many responsibilities.
- Single Responsibility Principle is the most important of the SOLID principles. One reason to change means predictable, safe modifications.
- Cohesion measures how much the methods and variables of a class belong together. When cohesion drops, the class wants to be split.
- Refactoring large functions often requires moving shared variables to instance scope — when this kills cohesion, it’s a signal to extract a new class.
- Open/Closed Principle: add new behavior by adding new code (subclass, strategy), not by modifying existing code. The existing classes stay stable and safe.
- Dependency Inversion Principle: high-level classes depend on interfaces, not implementations. Tests inject fakes through those interfaces.
- Constructor injection is the cleanest form of DIP — dependencies are explicit, documented in the constructor signature, and easily swapped.
- Many small, cohesive classes are always preferable to a few large, diffuse ones. Don’t be afraid of having many files.
- Class organization (constants → fields → constructor → public methods → private utilities) reduces the cognitive load for any reader of your code.
Related Resources
- ch09-unit-tests — DIP and constructor injection are what make production code testable (Timely principle)
- ch11-systems — The construction of objects (DI frameworks, factories) is a system-level concern
- ch03-functions — SRP at the function level mirrors SRP at the class level
- ch06-objects-and-data-structures — The data/object anti-symmetry explains when to use classes vs. plain data
- ch17-smells-and-heuristics — G10 (Vertical Separation), G36 (Avoid Transitive Navigation), and C4 through C7 cover class-level smells
Last Updated: 2026-04-14