Table of contents
- SOLID — A Design Philosophy in Five Letters
- SRP — A Class Should Have Only One Reason to Change
- OCP — Open for Extension, Closed for Modification
- The Relationship Between SRP and OCP
- Key Takeaways
SOLID — A Design Philosophy in Five Letters
When learning object-oriented programming, syntax like classes, inheritance, and polymorphism comes relatively quickly. The real challenge comes next. Most developers hit a wall when faced with questions like “how should I divide this?” and “where should I put what?”
The SOLID principles, organized by Robert C. Martin (a.k.a. Uncle Bob) in the early 2000s, are five guidelines that address these questions.
- S — Single Responsibility Principle
- O — Open-Closed Principle
- L — Liskov Substitution Principle
- I — Interface Segregation Principle
- D — Dependency Inversion Principle
All five principles point in one direction: code that is resilient to change. Requirements will inevitably change. The goal is to design systems so that those changes don’t shake the entire codebase.
flowchart LR
SOLID([SOLID Principles]) --> SRP[SRP<br/>Single reason to change]
SOLID --> OCP[OCP<br/>Open for extension]
SOLID --> LSP[LSP<br/>Substitutability]
SOLID --> ISP[ISP<br/>Interface segregation]
SOLID --> DIP[DIP<br/>Dependency inversion]
SRP --> Goal([Code resilient to change])
OCP --> Goal
LSP --> Goal
ISP --> Goal
DIP --> Goal
In this series, we’ll dig into each of the five principles with code examples. Part 1 covers SRP and OCP.
SRP — A Class Should Have Only One Reason to Change
The definition of SRP (Single Responsibility Principle) is straightforward.
A class should have only one reason to change. — Robert C. Martin
The key lies in how we interpret the word “responsibility.” In SRP, responsibility doesn’t mean “what this class does” — it means “why this class changes.” It doesn’t matter how many methods it has. If there’s only one axis of change, SRP is upheld. If there are two or more axes of change, it’s a violation.
Violation Example — The God Class
Let’s look at a class that handles orders.
public class OrderService {
public Order createOrder(Cart cart) {
// Order creation logic
Order order = new Order(cart.getItems(), calculateTotal(cart));
saveToDatabase(order);
sendConfirmationEmail(order);
return order;
}
private BigDecimal calculateTotal(Cart cart) {
// Price calculation — discount rate, tax, shipping
BigDecimal subtotal = cart.getItems().stream()
.map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal tax = subtotal.multiply(new BigDecimal("0.1"));
return subtotal.add(tax);
}
private void saveToDatabase(Order order) {
// Save directly via JDBC
String sql = "INSERT INTO orders (id, total, status) VALUES (?, ?, ?)";
// ... DB connection, PreparedStatement, execution
}
private void sendConfirmationEmail(Order order) {
// Send email via SMTP
String subject = "Order confirmation: " + order.getId();
// ... mail server connection, send
}
}
At first glance, this class seems to have a single responsibility: “order processing.” But when you count the reasons for change, the picture looks different.
flowchart TB
OrderService([OrderService]) --> BizLogic["Business logic<br/>Price calculation, discount rates, tax"]
OrderService --> Persistence["Data storage<br/>JDBC, SQL, table structure"]
OrderService --> Notification["Notification<br/>SMTP, email templates"]
BizLogic -->|"Tax policy change"| Change1([Reason to change 1])
Persistence -->|"Switch DB to MongoDB"| Change2([Reason to change 2])
Notification -->|"Email → KakaoTalk"| Change3([Reason to change 3])
style Change1 fill:#ff6b6b,color:#fff
style Change2 fill:#ff6b6b,color:#fff
style Change3 fill:#ff6b6b,color:#fff
If the tax policy changes, this class must be modified. If the DB switches from MySQL to MongoDB, this class must be modified. If the notification method changes from email to KakaoTalk, this class must be modified. Three reasons for change. SRP violation.
Why This Is a Problem
Having multiple reasons for change means different stakeholders touch the same code. The person modifying tax policy and the person doing the DB migration both need to open the same file. Conflicts arise, and one change can break the other.
Testing is also problematic. You just want to test the pricing logic, but you need a DB connection and a mail server. You have to create a bunch of mock objects, and tests become slow.
Separation — Splitting Along the Axis of Change
The solution is intuitive. If the reasons for change differ, separate them into different classes.
First, extract the class responsible for price calculation.
public class PriceCalculator {
public BigDecimal calculate(Cart cart) {
BigDecimal subtotal = cart.getItems().stream()
.map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal tax = subtotal.multiply(new BigDecimal("0.1"));
return subtotal.add(tax);
}
}
Separate the class responsible for order persistence.
public class OrderRepository {
public void save(Order order) {
String sql = "INSERT INTO orders (id, total, status) VALUES (?, ?, ?)";
// ... DB save logic
}
}
Separate the class responsible for notification.
public class OrderNotifier {
public void notify(Order order) {
String subject = "Order confirmation: " + order.getId();
// ... email sending logic
}
}
Now OrderService only composes them.
public class OrderService {
private final PriceCalculator priceCalculator;
private final OrderRepository orderRepository;
private final OrderNotifier orderNotifier;
public OrderService(PriceCalculator priceCalculator,
OrderRepository orderRepository,
OrderNotifier orderNotifier) {
this.priceCalculator = priceCalculator;
this.orderRepository = orderRepository;
this.orderNotifier = orderNotifier;
}
public Order createOrder(Cart cart) {
BigDecimal total = priceCalculator.calculate(cart);
Order order = new Order(cart.getItems(), total);
orderRepository.save(order);
orderNotifier.notify(order);
return order;
}
}
If the tax policy changes, only PriceCalculator needs modification. If the DB is replaced, only OrderRepository changes. If the notification method changes, only OrderNotifier is modified. Each class now has only one reason to change.
SRP Misconception — It Doesn’t Mean “One Method”
A common misconception when first encountering SRP is: “Should a class only have one method?” Not at all. PriceCalculator can have multiple methods like calculateSubtotal(), calculateTax(), and applyDiscount(). All these methods lie on the same axis of change: “price calculation.”
Conversely, even a class with exactly one method can violate SRP if that method can change for two different reasons. It’s not about the number — it’s about the direction of change.
OCP — Open for Extension, Closed for Modification
OCP (Open-Closed Principle) was first proposed by Bertrand Meyer in 1988 and later included in SOLID by Robert C. Martin.
Software entities should be open for extension, but closed for modification. — Bertrand Meyer, Object-Oriented Software Construction
When adding new functionality, you should be able to extend the system without modifying existing code. The moment you touch existing code, there’s a risk of breaking already-tested behavior.
Violation Example — The if-else Swamp
Let’s look at code where the processing logic varies by payment method.
public class PaymentProcessor {
public PaymentResult process(Payment payment) {
if (payment.getType().equals("CREDIT_CARD")) {
// Credit card payment processing
return processCreditCard(payment);
} else if (payment.getType().equals("BANK_TRANSFER")) {
// Bank transfer processing
return processBankTransfer(payment);
} else if (payment.getType().equals("KAKAO_PAY")) {
// KakaoPay processing
return processKakaoPay(payment);
}
throw new IllegalArgumentException("Unsupported payment method: " + payment.getType());
}
private PaymentResult processCreditCard(Payment payment) { /* ... */ }
private PaymentResult processBankTransfer(Payment payment) { /* ... */ }
private PaymentResult processKakaoPay(Payment payment) { /* ... */ }
}
This code works fine. The problem starts when a request comes in to “add NaverPay.” You need to open the process() method and add another else if. When TossPay is added, you open it again. Every time a new payment method is introduced, you must modify existing code — an OCP violation.
flowchart TB
subgraph before["OCP Violation — Structure requiring modification"]
PP[PaymentProcessor] --> IF{"if-else branching"}
IF -->|CREDIT_CARD| CC[Credit card processing]
IF -->|BANK_TRANSFER| BT[Bank transfer processing]
IF -->|KAKAO_PAY| KP[KakaoPay processing]
IF -->|"??? New method"| NEW["Existing code must change!"]
style NEW fill:#ff6b6b,color:#fff
end
Solving It with Polymorphism
The key tool for OCP is polymorphism. Define an interface and separate each payment method into its own implementation.
Declare a payment strategy interface.
public interface PaymentStrategy {
boolean supports(String paymentType);
PaymentResult process(Payment payment);
}
Each payment method implements this interface.
public class CreditCardPayment implements PaymentStrategy {
@Override
public boolean supports(String paymentType) {
return "CREDIT_CARD".equals(paymentType);
}
@Override
public PaymentResult process(Payment payment) {
// Credit card payment processing
return new PaymentResult(true, "Credit card payment completed");
}
}
public class BankTransferPayment implements PaymentStrategy {
@Override
public boolean supports(String paymentType) {
return "BANK_TRANSFER".equals(paymentType);
}
@Override
public PaymentResult process(Payment payment) {
// Bank transfer processing
return new PaymentResult(true, "Bank transfer completed");
}
}
public class KakaoPayPayment implements PaymentStrategy {
@Override
public boolean supports(String paymentType) {
return "KAKAO_PAY".equals(paymentType);
}
@Override
public PaymentResult process(Payment payment) {
// KakaoPay processing
return new PaymentResult(true, "KakaoPay payment completed");
}
}
Now PaymentProcessor simply receives a list of strategies and delegates.
public class PaymentProcessor {
private final List<PaymentStrategy> strategies;
public PaymentProcessor(List<PaymentStrategy> strategies) {
this.strategies = strategies;
}
public PaymentResult process(Payment payment) {
return strategies.stream()
.filter(strategy -> strategy.supports(payment.getType()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"Unsupported payment method: " + payment.getType()))
.process(payment);
}
}
Suppose NaverPay needs to be added. Just create a NaverPayPayment class that implements PaymentStrategy. Not a single line of PaymentProcessor needs to change.
flowchart TB
subgraph after["OCP Compliant — Adding through extension only"]
PP2[PaymentProcessor] --> PS["List<PaymentStrategy>"]
PS --> CC2[CreditCardPayment]
PS --> BT2[BankTransferPayment]
PS --> KP2[KakaoPayPayment]
PS -.->|"Add new class"| NP[NaverPayPayment]
style NP fill:#51cf66,color:#fff
end
Strategy Pattern — The Quintessential OCP Implementation
The structure we just saw is the Strategy pattern. One of the GoF (Gang of Four) Design Patterns, it defines a family of algorithms, encapsulates each one, and makes them interchangeable.
Here’s the structure of the Strategy pattern.
classDiagram
class Context {
-strategy: Strategy
+execute()
}
class Strategy {
<<interface>>
+algorithm()
}
class ConcreteStrategyA {
+algorithm()
}
class ConcreteStrategyB {
+algorithm()
}
class ConcreteStrategyC {
+algorithm()
}
Context --> Strategy
Strategy <|.. ConcreteStrategyA
Strategy <|.. ConcreteStrategyB
Strategy <|.. ConcreteStrategyC
- Context: The side that uses the strategy. In our example,
PaymentProcessor - Strategy: The strategy interface.
PaymentStrategy - ConcreteStrategy: Each implementation.
CreditCardPayment,BankTransferPayment, etc.
The Context depends only on the Strategy interface. It doesn’t know — and doesn’t need to know — what the concrete strategy is. Even when new strategies are added, the Context remains unchanged.
OCP in Kotlin — Using sealed classes
If you’re using Kotlin, you can apply OCP by combining sealed classes with when expressions.
sealed interface PaymentStrategy {
fun process(payment: Payment): PaymentResult
}
class CreditCardPayment : PaymentStrategy {
override fun process(payment: Payment): PaymentResult {
return PaymentResult(success = true, message = "Credit card payment completed")
}
}
class BankTransferPayment : PaymentStrategy {
override fun process(payment: Payment): PaymentResult {
return PaymentResult(success = true, message = "Bank transfer completed")
}
}
class KakaoPayPayment : PaymentStrategy {
override fun process(payment: Payment): PaymentResult {
return PaymentResult(success = true, message = "KakaoPay payment completed")
}
}
With a sealed interface, the compiler can verify at compile time that all implementations are handled in when expressions. When a new implementation is added, the compiler warns you about unhandled cases. You get type safety while still following OCP.
fun describe(strategy: PaymentStrategy): String = when (strategy) {
is CreditCardPayment -> "Credit Card"
is BankTransferPayment -> "Bank Transfer"
is KakaoPayPayment -> "KakaoPay"
// Compiler warns to add a case when new implementations are introduced
}
However, the sealed class approach requires all implementations to be in the same module. If strategies need to be added from external modules, a regular interface is more appropriate.
The Boundaries of OCP — You Don’t Need to Pre-Open Everything
When first learning OCP, you might think “Should I create interfaces everywhere?” No. You only need to create extension points where change is anticipated.
Payment methods are likely to keep growing based on business needs. Applying the Strategy pattern here is reasonable. On the other hand, if order statuses are fixed as Created -> Paid -> Shipped -> Completed and rarely change, there’s no need to wrap them in an interface.
Deciding what to open and what to close is the designer’s role. It’s impossible to predict every future change, and attempting to do so only makes the code more complex. Introducing extension points when the first change occurs is a more practical strategy in real-world development.
The Relationship Between SRP and OCP
The two principles seem to talk about different things, but they actually point in the same direction.
While SRP says “limit the reasons for change to one,” OCP says “even when that change happens, don’t touch existing code.” When responsibilities are well-separated through SRP, it becomes easier to apply OCP on top of those separated units.
Recall the OrderService example from earlier. After separating PriceCalculator via SRP, we can apply OCP. If discount policies become diverse, we can create a DiscountStrategy interface and have PriceCalculator use it. Without SRP laid down first, this kind of extension would have been far more difficult.
flowchart LR
SRP["SRP<br/>Responsibility separation"] -->|"On top of separated units"| OCP["OCP<br/>Extension point design"]
OCP -->|Result| Goal["Code resilient to change"]
Key Takeaways
Here’s a one-line summary of SRP and OCP.
| Principle | Key Question | How to Apply |
|---|---|---|
| SRP | ”How many reasons does this class have to change?” | If the axes of change differ, separate the class |
| OCP | ”Do I need to open existing code to add a new feature?” | Create extension points using interfaces and polymorphism |
Both principles ultimately aim to minimize the blast radius of change. It’s hard to feel this when first writing code, but the value of these principles becomes clear once maintenance begins. There’s a big difference between a codebase where you only need to change one place when requirements shift, and one where you must modify multiple files simultaneously.
In the next part, we’ll cover the remaining two SOLID principles: LSP and ISP. We’ll examine through code whether subclasses can truly substitute their parents, and how small an interface should be.




Loading comments...