Design Principles
SOLID principles, composition vs inheritance and clean code
Design Principles
SOLID principles, composition vs inheritance, and clean code â how a senior developer thinks about software design.
1. Definition
What is it?
SOLID is a set of five object-oriented design principles popularized by Robert C. Martin ("Uncle Bob"). They guide developers toward writing code that is maintainable, extensible, and testable.
| Letter | Principle | Short form |
|---|---|---|
| S | Single Responsibility Principle | One reason to change |
| O | Open/Closed Principle | Open for extension, closed for modification |
| L | Liskov Substitution Principle | Subtypes must be substitutable |
| I | Interface Segregation Principle | Many small interfaces |
| D | Dependency Inversion Principle | Depend on abstractions |
Why do they exist?
In large codebases, poor design creates compounding pain:
- A single change causes a cascade of modifications across the codebase
- Writing unit tests is hard because classes are tightly coupled
- New developers struggle to understand where to make changes safely
SOLID principles prevent these problems from accumulating.
Where do they fit?
Design principles fit into a broader design stack:
- SOLID principles â class and module level guidance â this topic
- Design patterns â reusable solution templates such as Strategy or Factory
- Architecture patterns â system-level structures such as Hexagonal or Clean Architecture
- OOP fundamentals â foundational concepts; â See: OOP Principles
2. Core Concepts
2.1 Single Responsibility Principle (SRP)
"A class should have only one reason to change."
A class should be responsible for one thing. Business logic, database access, and formatting changes should not affect each other.
Bad example â one class, three responsibilities:
// â Bad: UserService handles business logic, database AND email
public class UserService {
public void registerUser(String email, String password) {
// 1. Validation (business logic)
if (!email.contains("@")) throw new IllegalArgumentException("Invalid email");
// 2. Persistence (database concern)
database.save(new User(email, hashPassword(password)));
// 3. Notification (email concern)
emailClient.send(email, "Welcome!", "Registration successful!");
}
}
Good example â responsibilities separated:
// â
Good: each class has one responsibility
public class UserRegistrationService {
private final UserRepository userRepository;
private final EmailNotificationService emailService;
private final PasswordEncoder passwordEncoder;
public void register(String email, String password) {
validateEmail(email);
User user = new User(email, passwordEncoder.encode(password));
userRepository.save(user);
emailService.sendWelcomeEmail(email);
}
private void validateEmail(String email) {
if (!email.contains("@")) throw new IllegalArgumentException("Invalid email");
}
}
2.2 Open/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification."
Add new functionality by adding new code, not by modifying existing code. Typically achieved through interfaces and polymorphism.
Bad example â adding a new type requires modifying existing code:
// â Bad: every new payment type requires an additional if-branch
public class PaymentProcessor {
public void process(Payment payment) {
if (payment.getType() == PaymentType.CREDIT_CARD) {
// credit card logic...
} else if (payment.getType() == PaymentType.PAYPAL) {
// PayPal logic...
} else if (payment.getType() == PaymentType.CRYPTO) {
// crypto logic... (had to modify this class!)
}
}
}
Good example â extend without modifying:
// â
Good: new payment type â new class, existing code untouched
public interface PaymentStrategy {
void process(Payment payment);
}
public class CreditCardPayment implements PaymentStrategy {
@Override public void process(Payment payment) { /* credit card */ }
}
public class PayPalPayment implements PaymentStrategy {
@Override public void process(Payment payment) { /* PayPal */ }
}
public class CryptoPayment implements PaymentStrategy {
@Override public void process(Payment payment) { /* crypto â new class, nothing else changed */ }
}
public class PaymentProcessor {
public void process(PaymentStrategy strategy, Payment payment) {
strategy.process(payment); // never needs modification
}
}
2.3 Liskov Substitution Principle (LSP)
"If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering the correctness of the program."
In practice: a subclass must be usable everywhere the parent class is used, without surprising behavior.
Classic LSP violation â the Rectangle/Square problem:
// â LSP violation: Square extends Rectangle
public class Rectangle {
protected int width, height;
public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
public int area() { return width * height; }
}
public class Square extends Rectangle {
@Override public void setWidth(int width) { this.width = this.height = width; }
@Override public void setHeight(int height){ this.width = this.height = height; }
}
// This code breaks when a Square is substituted for a Rectangle:
void testArea(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // â With Square: area() = 16, not 20!
}
Correct approach:
// â
Common interface, not inheritance
public interface Shape {
int area();
}
public class Rectangle implements Shape {
private final int width, height;
public Rectangle(int width, int height) { this.width = width; this.height = height; }
@Override public int area() { return width * height; }
}
public class Square implements Shape {
private final int side;
public Square(int side) { this.side = side; }
@Override public int area() { return side * side; }
}
2.4 Interface Segregation Principle (ISP)
"Clients should not be forced to depend on interfaces they do not use."
Prefer small, focused interfaces over large "fat" ones.
Bad example â "fat" interface:
// â Bad: not every Worker can do everything
public interface Worker {
void work();
void eat();
void sleep();
}
public class RobotWorker implements Worker {
@Override public void work() { /* OK */ }
@Override public void eat() { throw new UnsupportedOperationException("Robots don't eat!"); }
@Override public void sleep() { throw new UnsupportedOperationException("Robots don't sleep!"); }
}
Good example â segregated interfaces:
// â
Good: small, focused interfaces
public interface Workable { void work(); }
public interface Eatable { void eat(); }
public interface Sleepable { void sleep(); }
public class HumanWorker implements Workable, Eatable, Sleepable {
@Override public void work() { /* works */ }
@Override public void eat() { /* eats */ }
@Override public void sleep() { /* sleeps */ }
}
public class RobotWorker implements Workable {
@Override public void work() { /* works */ }
// No need to implement Eatable or Sleepable
}
2.5 Dependency Inversion Principle (DIP)
"High-level modules should not depend on low-level modules. Both should depend on abstractions."
This principle is the foundation of Dependency Injection (DI) and IoC containers like Spring.
Bad example â direct dependency:
// â Bad: OrderService knows exactly which class it uses
public class OrderService {
private final MySQLOrderRepository repository = new MySQLOrderRepository(); // tight coupling!
public void placeOrder(Order order) {
repository.save(order);
}
}
Good example â invert the dependency through an interface:
// â
Good: OrderService only depends on the abstraction
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
}
public class MySQLOrderRepository implements OrderRepository { /* MySQL implementation */ }
public class InMemoryOrderRepository implements OrderRepository { /* for testing */ }
public class OrderService {
private final OrderRepository repository; // depends on interface, not implementation
public OrderService(OrderRepository repository) { // constructor DI
this.repository = repository;
}
public void placeOrder(Order order) {
repository.save(order);
}
}
2.6 Composition vs Inheritance
"Favor composition over inheritance" â Gang of Four
| Aspect | Inheritance | Composition |
|---|---|---|
| Coupling | Tight (parent/child coupled) | Loose (delegates to interface) |
| Flexibility | Rigid, fixed at compile time | Swappable at runtime |
| Code reuse | Via IS-A relationship | Via HAS-A relationship |
| Testability | Harder (parent runs too) | Easier (dependencies are mockable) |
| Best for | True IS-A relationships | Almost everything else |
3. Practical Usage
When to apply each principle?
- SRP: if your test setup is 50 lines and mocks many objects â that's an SRP signal
- OCP: if adding a new type requires editing an existing
if/switchchain â OCP violation - LSP: before using inheritance, ask: "Is it always true that the subclass IS-A parent?"
- ISP: if you implement an interface and throw
UnsupportedOperationExceptionâ ISP violation - DIP: if you can't mock a dependency in unit tests â DIP violation
When NOT to over-engineer?
- Small codebases and POCs â strict adherence creates unnecessary complexity
- Utility classes like
StringUtils,DateUtilsâ SRP doesn't apply the same way - Prototypes â pragmatism over purity
- The principles are guidelines, not laws â apply them with judgment
4. Code Examples
Full Example: Refactoring a Notification System
Starting point (violates all principles):
// â God class: knows everything, does everything
public class NotificationManager {
public void notify(User user, String type) {
if (type.equals("EMAIL")) {
// Direct SMTP setup
Properties props = new Properties();
props.put("mail.smtp.host", "smtp.gmail.com");
Session session = Session.getDefaultInstance(props);
// ... 30 lines of email sending ...
// ... logging ...
// ... save to database ...
} else if (type.equals("SMS")) {
// SMS API direct call
// ... 20 lines of SMS sending ...
}
// â SRP: 3+ responsibilities in one place
// â OCP: must modify to add a new type
// â DIP: depends on concrete implementations
}
}
Refactored, SOLID-compliant version:
// â
SOLID: complies with all five principles
// DIP: abstraction for the dependency
public interface NotificationChannel {
void send(User user, String message);
boolean supports(NotificationType type);
}
// OCP: new channel â new class, existing code unchanged
public class EmailNotificationChannel implements NotificationChannel {
private final SmtpClient smtpClient; // DIP: injected via constructor
public EmailNotificationChannel(SmtpClient smtpClient) {
this.smtpClient = smtpClient;
}
@Override
public void send(User user, String message) {
smtpClient.sendEmail(user.getEmail(), message);
}
@Override
public boolean supports(NotificationType type) {
return type == NotificationType.EMAIL;
}
}
public class SmsNotificationChannel implements NotificationChannel {
private final SmsGateway smsGateway;
public SmsNotificationChannel(SmsGateway smsGateway) {
this.smsGateway = smsGateway;
}
@Override
public void send(User user, String message) {
smsGateway.sendSms(user.getPhoneNumber(), message);
}
@Override
public boolean supports(NotificationType type) {
return type == NotificationType.SMS;
}
}
// SRP: NotificationService only coordinates
public class NotificationService {
private final List<NotificationChannel> channels;
private final NotificationAuditRepository auditRepository; // SRP: audit is separate
public NotificationService(List<NotificationChannel> channels,
NotificationAuditRepository auditRepository) {
this.channels = channels;
this.auditRepository = auditRepository;
}
public void notify(User user, NotificationType type, String message) {
channels.stream()
.filter(channel -> channel.supports(type))
.findFirst()
.orElseThrow(() -> new UnsupportedOperationException("Unsupported: " + type))
.send(user, message);
auditRepository.record(user.getId(), type, message);
}
}
Composition Example â Stack implementation
// â Bad: Stack extends ArrayList â LSP violation!
// Is a Stack IS-A ArrayList? Not really â add(index, element) has wrong semantics.
public class BadStack<T> extends ArrayList<T> {
public void push(T element) { add(element); }
public T pop() { return remove(size() - 1); }
// Inherited: add(int index, T element), set(), remove(int index) â not needed!
}
// â
Good: Stack HAS-A Deque â composition
public class GoodStack<T> {
private final Deque<T> storage = new ArrayDeque<>(); // implementation hidden
public void push(T element) { storage.push(element); }
public T pop() { return storage.pop(); }
public T peek() { return storage.peek(); }
public boolean isEmpty() { return storage.isEmpty(); }
public int size() { return storage.size(); }
// Only stack-relevant operations exposed â ISP!
}
5. Trade-offs
| Aspect | Naive code (no SOLID) | SOLID-compliant code |
|---|---|---|
| ⥠Performance | Minimal overhead | Tiny overhead (interface dispatch, DI) â rarely measurable |
| đŸ Memory | Fewer classes/objects | More small classes â negligible in practice |
| đ§ Maintainability | Degrades quickly, hard to extend | Well-structured, straightforward to extend |
| đ Flexibility | Rigid, tightly coupled | Loosely coupled, easily swappable parts |
| đ§Ș Testability | Hard to mock | Interfaces â simple mocking/stubbing |
| đ Learning curve | None â anyone can write | Requires understanding the principles |
| đïž Boilerplate | Few files/classes | More files and interfaces â but intentionally |
Key takeaway: SOLID principles cost more effort upfront but dramatically reduce long-term maintenance and extension costs.
6. Common Mistakes
1. SRP: God Class
What's wrong? One class absorbs all responsibilities â database, business logic, validation, formatting.
// â UserService is 500 lines with 15+ dependencies
public class UserService {
private UserRepository userRepo;
private OrderRepository orderRepo;
private EmailService emailService;
private SmsService smsService;
private PaymentService paymentService;
private AuditLogger auditLogger;
private CacheManager cacheManager;
// Signal: if the constructor has 7+ parameters, SRP is likely violated!
}
Fix: Split into smaller, focused services. If mock setup in a test is longer than the actual test logic â that's your SRP alarm.
2. OCP: Type-based if/switch chains
What's wrong? Every new type requires opening and modifying existing code.
// â Must be modified for every new shape
public double calculateArea(Shape shape) {
if (shape instanceof Circle c) return Math.PI * c.getRadius() * c.getRadius();
if (shape instanceof Rectangle r) return r.getWidth() * r.getHeight();
if (shape instanceof Triangle t) return 0.5 * t.getBase() * t.getHeight();
// â new shape means modifying this method!
throw new UnsupportedOperationException("Unknown shape");
}
// â
Fix: polymorphism
public interface Shape { double area(); }
// Each implementation knows its own area calculation
3. LSP: Throwing exceptions in inherited methods
What's wrong? The subclass cannot fulfill the parent's contract.
// â ReadOnlyList extends ArrayList â what do we do with add()?
public class ReadOnlyList<T> extends ArrayList<T> {
@Override
public boolean add(T element) {
throw new UnsupportedOperationException("Read-only!"); // LSP violation!
}
}
// â
Fix: implement the List interface and delegate,
// or use Collections.unmodifiableList()
4. Ignoring ISP for "convenience"
What's wrong? Creating a large interface because "someone might need all the methods eventually."
// â Fat interface
public interface UserRepository {
User findById(Long id);
List<User> findAll();
void save(User user);
void delete(Long id);
List<User> findByEmailDomain(String domain); // rarely needed
Map<String, Long> getUserCountByCountry(); // rarely needed
List<User> findInactiveUsersOlderThan(int days); // rarely needed
}
// â
Segregated interfaces
public interface UserReadRepository { User findById(Long id); List<User> findAll(); }
public interface UserWriteRepository { void save(User user); void delete(Long id); }
public interface UserReportRepository {
Map<String, Long> getUserCountByCountry();
List<User> findInactiveUsersOlderThan(int days);
}
5. DIP: `new` operator inside business logic
What's wrong? When a class creates its own dependencies with new, it becomes untestable and rigid.
// â new in the constructor â cannot be mocked in tests
public class ReportService {
private final DatabaseReporter reporter = new DatabaseReporter(); // tight coupling!
}
// â
Inject via constructor
public class ReportService {
private final Reporter reporter;
public ReportService(Reporter reporter) { this.reporter = reporter; }
}
7. Senior-level Insights
"When do you intentionally violate SOLID?"
A classic senior interview question. The correct answer demonstrates judgment, not rule-worship:
- Utility classes:
StringUtils,DateUtilsâ strict SRP would be over-engineering - Value objects: a
MoneyorAddressrecord doesn't need an interface - Performance-critical paths: DI overhead may be measurable in real-time systems
- Test code: test code can be more pragmatic; production code should adhere to the principles
Composition vs inheritance decision tree
Composition vs inheritance checklist:
- If there is a genuine IS-A relationship in all circumstances, inheritance may be OK â but still verify LSP.
- If there is no real IS-A relationship, prefer composition.
- If the child forces you to change the parent class, inheritance is a bad fit.
- If you override methods only to throw
UnsupportedOperationException, refactor to composition immediately.
Dependency Injection styles
- Constructor injection â recommended (immutable, testable, required dependencies)
- Setter injection â for optional dependencies
- Field injection (
@Autowireddirectly on field) â avoid! (not testable without DI container)
// â Field injection â avoid
@Service
public class OrderService {
@Autowired private OrderRepository repository; // hard to test without Spring context
}
// â
Constructor injection â preferred
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
Related Clean Code principles
- DRY (Don't Repeat Yourself) â but don't over-abstract; wrong abstractions are worse than duplication
- YAGNI (You Aren't Gonna Need It) â don't build for requirements you don't have today
- KISS (Keep It Simple, Stupid) â simple code is more valuable than "clever" code
- Law of Demeter â
order.getCustomer().getAddress().getCity()is bad;order.getCustomerCity()is better
How to communicate this in an interview
Don't just define the principles â show you know when and why you apply them:
"I use SRP as a testablity check: if the mock setup in a test is complex, the class likely has too many responsibilities. That's my signal to split it up."
8. Glossary
| Term | Definition |
|---|---|
| SOLID | 5 design principles: SRP, OCP, LSP, ISP, DIP â coined by Robert C. Martin |
| Tight coupling | A class knows exactly which concrete class it uses |
| Loose coupling | Classes communicate through interfaces or abstractions |
| Dependency Injection (DI) | Providing dependencies from outside (constructor, setter, etc.) |
| IoC | Inversion of Control â control flow delegated to a container (e.g. Spring) |
| Composition | HAS-A relationship: a class holds other objects and delegates behavior |
| God Class | Anti-pattern: one class that does too much |
| Strategy Pattern | Design pattern â a common implementation of OCP |
| DRY | Don't Repeat Yourself â eliminate redundancy |
| YAGNI | You Aren't Gonna Need It â don't build speculative features |
9. Cheatsheet
- đ ą SRP â one class = one responsibility; complex test setup â SRP violation
- đ OCP â new feature = new class, not modification; use polymorphism/strategy
- đ
LSP â subtype is always substitutable for parent;
UnsupportedOperationException= LSP violation - đ ISP â small interfaces > fat interfaces; give clients only what they need
- đ
DIP â depend on abstractions, not concretes;
newin business logic = DIP violation - đ Composition > Inheritance â HAS-A is usually more flexible than IS-A
- đ Constructor injection is the gold standard for DI â immutable and testable
- â God Class â if 7+ constructor params â SRP violated, split it up
- đ§Ș Testability is the best SOLID metric â if it's hard to test, principles are being violated
- đŻ SOLID = guidelines, not dogma â understand when to apply and when not to
đź Games
8 questions