Object-Oriented Design Principles (SOLID Advanced)
See references/solid-examples.md for detailed code examples of all SOLID principles (SRP, OCP, LSP, ISP, DIP).
1. SRP (Single Responsibility Principle)
SRP Core Concept
- A class should have exactly one reason to change
- "Reason to change" means one actor or stakeholder whose requirements drive modifications
- SRP is about people — separate code that different stakeholders depend on
Violation Indicators
| Indicator |
Description |
| Class name includes "And" or "Manager" |
Multiple responsibilities bundled |
| Class changes for unrelated feature requests |
Different stakeholders drive changes |
| Many import statements from different domains |
Cross-cutting concerns mixed |
| Test class requires mocking 5+ dependencies |
Too many collaborators |
| Methods cluster into groups with no interaction |
Separate responsibilities coexist |
SRP Rules
- If you cannot describe a class's purpose without using "and", split it
- Prefer multiple small classes over one large class
- SRP does not mean one method per class — it means one cohesive responsibility
- Apply SRP at method, class, and module level consistently
2. OCP (Open-Closed Principle)
OCP Core Concept
- Software entities should be open for extension but closed for modification
- Add new behavior by writing new code, not by changing existing code
- Achieved through abstraction: depend on stable interfaces, vary implementations
OCP Rules
- Identify the axis of change first — then introduce abstraction at that point
- Do not over-abstract prematurely — apply OCP when a second variation actually appears
- Sealed types provide OCP with compile-time exhaustiveness for known, bounded variations
- Open interfaces provide OCP for unbounded, pluggable variations
3. LSP (Liskov Substitution Principle)
LSP Core Concept
- Subtypes must be substitutable for their base types without altering program correctness
- If S is a subtype of T, then objects of type T can be replaced with objects of type S without breaking expectations
- A subtype must honor the behavioral contract of its supertype
Subtype Rules
| Rule |
Description |
| Precondition rule |
Subtypes must not strengthen preconditions |
| Postcondition rule |
Subtypes must not weaken postconditions |
| Invariant rule |
Subtypes must preserve supertype invariants |
| History rule |
Subtypes must not introduce state changes the supertype does not allow |
LSP Rules
- Prefer composition over inheritance when behavioral substitution is not guaranteed
- Use interfaces to define contracts — verify all implementations satisfy the contract
- Throw-on-method implementations (e.g.,
UnsupportedOperationException) signal LSP violations
- If a subtype needs to disable supertype behavior, the inheritance hierarchy is wrong
4. ISP (Interface Segregation Principle)
ISP Core Concept
- Clients should not be forced to depend on methods they do not use
- Split large interfaces into smaller, focused ones
- Each interface represents a role that a client cares about
ISP Rules
- Design interfaces from the client's perspective, not the implementor's
- A class implementing many interfaces is acceptable — a client depending on a fat interface is not
- Prefer many small interfaces (3-5 methods) over few large ones
- When a single method interface suffices, consider
fun interface for SAM conversion
5. DIP (Dependency Inversion Principle)
DIP Core Concept
- High-level modules should not depend on low-level modules — both should depend on abstractions
- Abstractions should not depend on details — details should depend on abstractions
- The direction of source code dependency is inverted relative to the flow of control
DIP Rules
- Abstractions (interfaces) belong in the same module as the high-level code that uses them
- Low-level modules depend on and implement the high-level module's abstractions
- Do not create interfaces for every class — apply DIP at architectural boundaries
- Stable abstractions change less frequently than volatile implementations
6. Additional Design Principles
Tell, Don't Ask
// Bad: asking for state, then making decisions externally
if (account.balance >= amount) {
account.balance -= amount
}
// Good: tell the object to perform the operation
account.withdraw(amount) // Object decides internally
- Objects should encapsulate behavior, not just expose state
- Move decisions close to the data they depend on
- Getters are acceptable for display/reporting — but avoid using them for branching logic
Law of Demeter (Principle of Least Knowledge)
// Bad: navigating through object chains
val city = order.customer.address.city
// Good: ask the immediate collaborator
val city = order.deliveryCity()
- A method should only call methods on:
- Its own object (
this)
- Its parameters
- Objects it creates
- Its direct collaborators (fields)
- Chained calls like
a.b().c().d() indicate missing encapsulation
- Exception: fluent APIs and builder patterns are acceptable chains
Composition over Inheritance
| Aspect |
Inheritance |
Composition |
| Coupling |
Tight (white-box) |
Loose (black-box) |
| Flexibility |
Static (compile-time) |
Dynamic (runtime) |
| Reuse granularity |
Entire class |
Individual behavior |
| Fragility |
Fragile base class problem |
Isolated changes |
// Prefer composition
class OrderValidator(
private val rules: List<ValidationRule<Order>>
) {
fun validate(order: Order): ValidationResult =
rules.map { it.validate(order) }.merge()
}
// Over inheritance
abstract class BaseOrderValidator {
abstract fun additionalRules(): List<ValidationRule<Order>>
// Subclasses are coupled to base class internals
}
- Use inheritance only for genuine "is-a" relationships with shared behavior
- Prefer interfaces + delegation over abstract class hierarchies
- Kotlin's
by keyword enables clean delegation without boilerplate
Favor Immutability
// Immutable value object
data class Money(val amount: BigDecimal, val currency: Currency) {
operator fun plus(other: Money): Money {
require(currency == other.currency) { "Currency mismatch" }
return Money(amount + other.amount, currency)
}
}
// Immutable entity state transitions return new instances
data class Order private constructor(
val id: OrderId,
val status: OrderStatus,
val items: List<OrderItem>
) {
fun confirm(): Order = copy(status = OrderStatus.CONFIRMED)
fun cancel(): Order = copy(status = OrderStatus.CANCELLED)
}
- Immutable objects are thread-safe, easier to reason about, and prevent accidental mutation
- Use
val by default — var only when mutation is essential
- State transitions return new instances rather than modifying in place
7. Responsibility Assignment Patterns
Information Expert
- Assign responsibility to the class that has the information needed to fulfill it
// Order has the items — it should calculate the total
class Order(val items: List<OrderItem>) {
fun totalAmount(): Money = items.sumOf { it.subtotal() }
}
// OrderItem has quantity and price — it calculates subtotal
class OrderItem(val product: Product, val quantity: Int) {
fun subtotal(): Money = product.price * quantity
}
Creator
- Assign object creation to the class that has the initialization data
// Order creates OrderItem because it has the context
class Order {
private val items = mutableListOf<OrderItem>()
fun addItem(product: Product, quantity: Int): OrderItem {
val item = OrderItem(product, quantity)
items.add(item)
return item
}
}
Low Coupling
- Minimize dependencies between classes to reduce the impact of change
- Prefer depending on stable abstractions over volatile implementations
- Reduce fan-out: each class should collaborate with a small number of others
High Cohesion
- Keep related behavior together — a class's methods should operate on the same data
- If a subset of methods operates on a subset of fields, consider splitting the class
- High cohesion and SRP reinforce each other
8. Design Quality Metrics
Coupling
| Type |
Description |
Strength |
| Content coupling |
One module modifies another's internals |
Strongest (worst) |
| Common coupling |
Modules share global mutable state |
Strong |
| Control coupling |
One module controls another's flow via flags |
Moderate |
| Stamp coupling |
Modules share a data structure but use different parts |
Moderate |
| Data coupling |
Modules share only necessary data via parameters |
Weak (best) |
Cohesion
| Type |
Description |
Strength |
| Functional |
All elements contribute to a single task |
Strongest (best) |
| Sequential |
Output of one element is input to the next |
Strong |
| Communicational |
Elements operate on the same data |
Moderate |
| Temporal |
Elements are related by timing |
Weak |
| Coincidental |
No meaningful relationship |
Weakest (worst) |
Fan-In and Fan-Out
| Metric |
Definition |
Guideline |
| Fan-in |
Number of classes that depend on this class |
High fan-in is acceptable for stable utilities |
| Fan-out |
Number of classes this class depends on |
High fan-out (>7) indicates excessive responsibility |
- High fan-in + low fan-out = stable, reusable module
- Low fan-in + high fan-out = orchestrator or mediator (acceptable for facades)
- High fan-in + high fan-out = risky bottleneck — refactor to reduce responsibilities
9. Anti-Patterns
God Class
- One class that knows too much and does too much
- Symptoms: 1000+ lines, 20+ methods, 10+ fields, many unrelated responsibilities
- Fix: decompose into focused classes using SRP
Anemic Domain Model
- Domain objects contain only data (getters/setters) with no behavior
- Business logic lives entirely in service classes
- Fix: move behavior into domain objects following Information Expert
// Anemic: all logic in service
class OrderService {
fun cancel(order: Order) {
if (order.status == OrderStatus.SHIPPED) throw IllegalStateException()
order.status = OrderStatus.CANCELLED
}
}
// Rich: behavior in domain object
class Order(var status: OrderStatus) {
fun cancel() {
require(status != OrderStatus.SHIPPED) { "Cannot cancel shipped order" }
status = OrderStatus.CANCELLED
}
}
Inappropriate Intimacy
- Two classes excessively access each other's internal details
- Symptoms: frequent access to private/internal members, bidirectional dependencies
- Fix: extract shared logic into a new class, or merge if truly one responsibility
Refused Bequest
- Subclass inherits methods or properties it does not need or cannot support
- Symptoms: overriding methods to throw
UnsupportedOperationException, empty implementations
- Fix: replace inheritance with composition, or redesign the hierarchy
Feature Envy
- A method that uses more data from another class than its own
- Symptoms: long chains of getter calls on a single external object
- Fix: move the method to the class whose data it primarily uses
Primitive Obsession
- Using primitive types (String, Int, Long) for domain concepts
- Symptoms: validation logic scattered across multiple locations
// Bad: email as String everywhere
fun sendEmail(to: String) { ... } // No validation guarantee
// Good: value class with validation
@JvmInline
value class Email(val value: String) {
init { require(value.matches(EMAIL_REGEX)) { "Invalid email: $value" } }
}
fun sendEmail(to: Email) { ... } // Always valid
10. Related Rules
- Code quality fundamentals:
code-quality skill
- Java conventions and patterns:
java-convention skill
- Kotlin conventions and patterns:
kotlin-convention skill
- Spring Framework patterns:
spring-framework skill
- Error handling design:
error-handling skill
- BDD test rules (testing behavior, not implementation):
testing-unit skill
11. Further Reading
- Robert C. Martin — "Clean Architecture" (SOLID principles in context)
- Craig Larman — "Applying UML and Patterns" (GRASP patterns)
- Martin Fowler — "Refactoring" (identifying and fixing design smells)
- Eric Evans — "Domain-Driven Design" (domain modeling and responsibility)
- Joshua Bloch — "Effective Java" (immutability, composition, API design)
- Bertrand Meyer — "Object-Oriented Software Construction" (Design by Contract)
1---2name: object-oriented-design3description: Object-oriented design principles with practical examples covering all five SOLID principles: Single Responsibility (SRP), Open/Closed (OCP), Liskov Substitution (LSP), Interface Segregation (ISP), and Dependency Inversion (DIP). Includes design patterns, class hierarchy design, and refactoring guidance. Use when designing class hierarchies, applying SOLID principles, refactoring toward better abstractions, or reviewing object-oriented design for violations.4license: MIT5---67# Object-Oriented Design Principles (SOLID Advanced)89> See [references/solid-examples.md](references/solid-examples.md) for detailed code examples of all SOLID principles (SRP, OCP, LSP, ISP, DIP).1011## 1. SRP (Single Responsibility Principle)1213### SRP Core Concept1415- A class should have exactly **one reason to change**16- "Reason to change" means one actor or stakeholder whose requirements drive modifications17- SRP is about **people** — separate code that different stakeholders depend on1819### Violation Indicators2021| Indicator | Description |22| ----------------------------------------------- | ------------------------------------ |23| Class name includes "And" or "Manager" | Multiple responsibilities bundled |24| Class changes for unrelated feature requests | Different stakeholders drive changes |25| Many import statements from different domains | Cross-cutting concerns mixed |26| Test class requires mocking 5+ dependencies | Too many collaborators |27| Methods cluster into groups with no interaction | Separate responsibilities coexist |2829### SRP Rules3031- If you cannot describe a class's purpose without using "and", split it32- Prefer multiple small classes over one large class33- SRP does not mean one method per class — it means one cohesive responsibility34- Apply SRP at method, class, and module level consistently3536---3738## 2. OCP (Open-Closed Principle)3940### OCP Core Concept4142- Software entities should be **open for extension** but **closed for modification**43- Add new behavior by writing new code, not by changing existing code44- Achieved through abstraction: depend on stable interfaces, vary implementations4546### OCP Rules4748- Identify the axis of change first — then introduce abstraction at that point49- Do not over-abstract prematurely — apply OCP when a second variation actually appears50- Sealed types provide OCP with compile-time exhaustiveness for known, bounded variations51- Open interfaces provide OCP for unbounded, pluggable variations5253---5455## 3. LSP (Liskov Substitution Principle)5657### LSP Core Concept5859- Subtypes must be substitutable for their base types without altering program correctness60- If S is a subtype of T, then objects of type T can be replaced with objects of type S without breaking expectations61- A subtype must honor the **behavioral contract** of its supertype6263### Subtype Rules6465| Rule | Description |66| ------------------ | ---------------------------------------------------------------------- |67| Precondition rule | Subtypes must not strengthen preconditions |68| Postcondition rule | Subtypes must not weaken postconditions |69| Invariant rule | Subtypes must preserve supertype invariants |70| History rule | Subtypes must not introduce state changes the supertype does not allow |7172### LSP Rules7374- Prefer composition over inheritance when behavioral substitution is not guaranteed75- Use interfaces to define contracts — verify all implementations satisfy the contract76- Throw-on-method implementations (e.g., `UnsupportedOperationException`) signal LSP violations77- If a subtype needs to disable supertype behavior, the inheritance hierarchy is wrong7879---8081## 4. ISP (Interface Segregation Principle)8283### ISP Core Concept8485- Clients should not be forced to depend on methods they do not use86- Split large interfaces into smaller, focused ones87- Each interface represents a **role** that a client cares about8889### ISP Rules9091- Design interfaces from the client's perspective, not the implementor's92- A class implementing many interfaces is acceptable — a client depending on a fat interface is not93- Prefer many small interfaces (3-5 methods) over few large ones94- When a single method interface suffices, consider `fun interface` for SAM conversion9596---9798## 5. DIP (Dependency Inversion Principle)99100### DIP Core Concept101102- High-level modules should not depend on low-level modules — both should depend on abstractions103- Abstractions should not depend on details — details should depend on abstractions104- The direction of source code dependency is **inverted** relative to the flow of control105106### DIP Rules107108- Abstractions (interfaces) belong in the **same module** as the high-level code that uses them109- Low-level modules depend on and implement the high-level module's abstractions110- Do not create interfaces for every class — apply DIP at architectural boundaries111- Stable abstractions change less frequently than volatile implementations112113---114115## 6. Additional Design Principles116117### Tell, Don't Ask118119```kotlin120// Bad: asking for state, then making decisions externally121if (account.balance >= amount) {122 account.balance -= amount123}124125// Good: tell the object to perform the operation126account.withdraw(amount) // Object decides internally127```128129- Objects should encapsulate behavior, not just expose state130- Move decisions close to the data they depend on131- Getters are acceptable for display/reporting — but avoid using them for branching logic132133### Law of Demeter (Principle of Least Knowledge)134135```kotlin136// Bad: navigating through object chains137val city = order.customer.address.city138139// Good: ask the immediate collaborator140val city = order.deliveryCity()141```142143- A method should only call methods on:144 - Its own object (`this`)145 - Its parameters146 - Objects it creates147 - Its direct collaborators (fields)148- Chained calls like `a.b().c().d()` indicate missing encapsulation149- Exception: fluent APIs and builder patterns are acceptable chains150151### Composition over Inheritance152153| Aspect | Inheritance | Composition |154| ----------------- | -------------------------- | ------------------- |155| Coupling | Tight (white-box) | Loose (black-box) |156| Flexibility | Static (compile-time) | Dynamic (runtime) |157| Reuse granularity | Entire class | Individual behavior |158| Fragility | Fragile base class problem | Isolated changes |159160```kotlin161// Prefer composition162class OrderValidator(163 private val rules: List<ValidationRule<Order>>164) {165 fun validate(order: Order): ValidationResult =166 rules.map { it.validate(order) }.merge()167}168169// Over inheritance170abstract class BaseOrderValidator {171 abstract fun additionalRules(): List<ValidationRule<Order>>172 // Subclasses are coupled to base class internals173}174```175176- Use inheritance only for genuine "is-a" relationships with shared behavior177- Prefer interfaces + delegation over abstract class hierarchies178- Kotlin's `by` keyword enables clean delegation without boilerplate179180### Favor Immutability181182```kotlin183// Immutable value object184data class Money(val amount: BigDecimal, val currency: Currency) {185 operator fun plus(other: Money): Money {186 require(currency == other.currency) { "Currency mismatch" }187 return Money(amount + other.amount, currency)188 }189}190191// Immutable entity state transitions return new instances192data class Order private constructor(193 val id: OrderId,194 val status: OrderStatus,195 val items: List<OrderItem>196) {197 fun confirm(): Order = copy(status = OrderStatus.CONFIRMED)198 fun cancel(): Order = copy(status = OrderStatus.CANCELLED)199}200```201202- Immutable objects are thread-safe, easier to reason about, and prevent accidental mutation203- Use `val` by default — `var` only when mutation is essential204- State transitions return new instances rather than modifying in place205206---207208## 7. Responsibility Assignment Patterns209210### Information Expert211212- Assign responsibility to the class that has the information needed to fulfill it213214```kotlin215// Order has the items — it should calculate the total216class Order(val items: List<OrderItem>) {217 fun totalAmount(): Money = items.sumOf { it.subtotal() }218}219220// OrderItem has quantity and price — it calculates subtotal221class OrderItem(val product: Product, val quantity: Int) {222 fun subtotal(): Money = product.price * quantity223}224```225226### Creator227228- Assign object creation to the class that has the initialization data229230```kotlin231// Order creates OrderItem because it has the context232class Order {233 private val items = mutableListOf<OrderItem>()234235 fun addItem(product: Product, quantity: Int): OrderItem {236 val item = OrderItem(product, quantity)237 items.add(item)238 return item239 }240}241```242243### Low Coupling244245- Minimize dependencies between classes to reduce the impact of change246- Prefer depending on stable abstractions over volatile implementations247- Reduce fan-out: each class should collaborate with a small number of others248249### High Cohesion250251- Keep related behavior together — a class's methods should operate on the same data252- If a subset of methods operates on a subset of fields, consider splitting the class253- High cohesion and SRP reinforce each other254255---256257## 8. Design Quality Metrics258259### Coupling260261| Type | Description | Strength |262| ---------------- | ------------------------------------------------------ | ----------------- |263| Content coupling | One module modifies another's internals | Strongest (worst) |264| Common coupling | Modules share global mutable state | Strong |265| Control coupling | One module controls another's flow via flags | Moderate |266| Stamp coupling | Modules share a data structure but use different parts | Moderate |267| Data coupling | Modules share only necessary data via parameters | Weak (best) |268269### Cohesion270271| Type | Description | Strength |272| --------------- | ------------------------------------------ | ---------------- |273| Functional | All elements contribute to a single task | Strongest (best) |274| Sequential | Output of one element is input to the next | Strong |275| Communicational | Elements operate on the same data | Moderate |276| Temporal | Elements are related by timing | Weak |277| Coincidental | No meaningful relationship | Weakest (worst) |278279### Fan-In and Fan-Out280281| Metric | Definition | Guideline |282| ------- | ------------------------------------------- | ---------------------------------------------------- |283| Fan-in | Number of classes that depend on this class | High fan-in is acceptable for stable utilities |284| Fan-out | Number of classes this class depends on | High fan-out (>7) indicates excessive responsibility |285286- High fan-in + low fan-out = stable, reusable module287- Low fan-in + high fan-out = orchestrator or mediator (acceptable for facades)288- High fan-in + high fan-out = risky bottleneck — refactor to reduce responsibilities289290---291292## 9. Anti-Patterns293294### God Class295296- One class that knows too much and does too much297- Symptoms: 1000+ lines, 20+ methods, 10+ fields, many unrelated responsibilities298- Fix: decompose into focused classes using SRP299300### Anemic Domain Model301302- Domain objects contain only data (getters/setters) with no behavior303- Business logic lives entirely in service classes304- Fix: move behavior into domain objects following Information Expert305306```kotlin307// Anemic: all logic in service308class OrderService {309 fun cancel(order: Order) {310 if (order.status == OrderStatus.SHIPPED) throw IllegalStateException()311 order.status = OrderStatus.CANCELLED312 }313}314315// Rich: behavior in domain object316class Order(var status: OrderStatus) {317 fun cancel() {318 require(status != OrderStatus.SHIPPED) { "Cannot cancel shipped order" }319 status = OrderStatus.CANCELLED320 }321}322```323324### Inappropriate Intimacy325326- Two classes excessively access each other's internal details327- Symptoms: frequent access to private/internal members, bidirectional dependencies328- Fix: extract shared logic into a new class, or merge if truly one responsibility329330### Refused Bequest331332- Subclass inherits methods or properties it does not need or cannot support333- Symptoms: overriding methods to throw `UnsupportedOperationException`, empty implementations334- Fix: replace inheritance with composition, or redesign the hierarchy335336### Feature Envy337338- A method that uses more data from another class than its own339- Symptoms: long chains of getter calls on a single external object340- Fix: move the method to the class whose data it primarily uses341342### Primitive Obsession343344- Using primitive types (String, Int, Long) for domain concepts345- Symptoms: validation logic scattered across multiple locations346347```kotlin348// Bad: email as String everywhere349fun sendEmail(to: String) { ... } // No validation guarantee350351// Good: value class with validation352@JvmInline353value class Email(val value: String) {354 init { require(value.matches(EMAIL_REGEX)) { "Invalid email: $value" } }355}356357fun sendEmail(to: Email) { ... } // Always valid358```359360---361362## 10. Related Rules363364- **Code quality fundamentals**: `code-quality` skill365- **Java conventions and patterns**: `java-convention` skill366- **Kotlin conventions and patterns**: `kotlin-convention` skill367- **Spring Framework patterns**: `spring-framework` skill368- **Error handling design**: `error-handling` skill369- **BDD test rules (testing behavior, not implementation)**: `testing-unit` skill370371---372373## 11. Further Reading374375- Robert C. Martin — "Clean Architecture" (SOLID principles in context)376- Craig Larman — "Applying UML and Patterns" (GRASP patterns)377- Martin Fowler — "Refactoring" (identifying and fixing design smells)378- Eric Evans — "Domain-Driven Design" (domain modeling and responsibility)379- Joshua Bloch — "Effective Java" (immutability, composition, API design)380- Bertrand Meyer — "Object-Oriented Software Construction" (Design by Contract)