Software design · For beginners
Designing for Change: The SOLID Principles, Explained and Practised
Every programmer has opened a file where changing one small thing breaks three others. SOLID is a set of five habits that make that happen less often. Each habit is simple on its own. This post gives you one picture and one small example for each, in Kotlin, though the ideas work in any language.
- S: A class should have one reason to change.
- O: Add new behaviour by adding code, not by editing old code.
- L: A child class must work anywhere its parent works.
- I: Do not force a class to depend on methods it does not use.
- D: Depend on a small contract, not on a concrete tool.
Contents
Every principle ends with a Try it box: a small Kotlin program with tests that print PASS or FAIL. Each has a button that opens it in the free Kotlin Playground in a new tab, where you can edit and run it. There is also a hint and a solution if you get stuck. Nothing is sent anywhere until you click the button.
1. Why design matters
Code is read and changed far more often than it is written. Bad structure makes every change risky, because you cannot tell what else will move. SOLID is not a law, it is a set of warning signs and cures. Think of a kitchen: a good one has separate stations, tools you can swap, and nothing that only works with one brand of oven.
Running example. We will use a tiny online shop. A customer places an order, we apply a discount, take a payment, save the order and send a receipt. Simple, and it goes wrong in all five ways.
2. S: Single Responsibility Principle
A class should have only one reason to change. If the tax rules change and the email wording changes and the database changes, and all three force you to edit the same class, that class has too many jobs.
The problem
class OrderService {
fun placeOrder(order: Order) {
if (order.items.isEmpty()) error("Empty order") // validation
val total = order.items.sumOf { it.price } // pricing
database.save(order, total) // storage
mailer.send(order.email, "Thanks! You paid $total") // notification
}
}
This one function validates, prices, stores and emails. Four different people could ask you to change it for four different reasons.
The fix: one job per class
class OrderValidator { fun check(order: Order) { if (order.items.isEmpty()) error("Empty order") } }
class PriceCalculator { fun total(order: Order) = order.items.sumOf { it.price } }
class OrderRepository { fun save(order: Order, total: Double) { /* database */ } }
class ReceiptSender { fun send(order: Order, total: Double) { /* email */ } }
class OrderService(
private val validator: OrderValidator,
private val pricing: PriceCalculator,
private val repository: OrderRepository,
private val receipts: ReceiptSender,
) {
fun placeOrder(order: Order) {
validator.check(order)
val total = pricing.total(order)
repository.save(order, total)
receipts.send(order, total)
}
}
OrderService now only coordinates. A new email design touches ReceiptSender and nothing else.
Quick test: describe the class in one sentence without using the word “and”. If you cannot, it probably does too much.
Try it: split the monolith
The tests fail at first because the validator and calculator are empty. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
class OrderValidator {
fun check(order: Order) { TODO("move the validation here") }
}
class PriceCalculator {
fun total(order: Order): Double { TODO("move the pricing here") }
}
class OrderService {
fun placeOrder(order: Order): Double {
if (order.items.isEmpty()) error("Empty order") // validation
val total = order.items.sumOf { it.price } // pricing
println("saved order, total = $total") // storage
return total
}
}
Open in Kotlin Playground ↗
Hint
Copy each commented line into the class named for it. Make OrderService call them. The tests only check behaviour, so the real goal is the structure.
See the tests
fun main() {
val order = Order(listOf(Item("pen", 10.0), Item("book", 20.0)))
test("validator rejects an empty order") {
val problem = runCatching { OrderValidator().check(Order(emptyList())) }.exceptionOrNull()
check(problem is IllegalStateException) { "expected an 'Empty order' error, got $problem" }
}
test("price calculator adds the items") {
check(same(PriceCalculator().total(order), 30.0)) { "wrong total" }
}
test("service still places the order") {
check(same(OrderService().placeOrder(order), 30.0)) { "wrong total" }
}
}
Show a solution
class OrderValidator {
fun check(order: Order) { if (order.items.isEmpty()) error("Empty order") }
}
class PriceCalculator {
fun total(order: Order): Double = order.items.sumOf { it.price }
}
class OrderService(
private val validator: OrderValidator = OrderValidator(),
private val pricing: PriceCalculator = PriceCalculator(),
) {
fun placeOrder(order: Order): Double {
validator.check(order)
val total = pricing.total(order)
println("saved order, total = $total")
return total
}
}
3. O: Open/Closed Principle
Code should be open for extension but closed for modification. When a new requirement arrives, you should add a new piece, not reopen working code and risk breaking it.
The problem
fun discount(customerType: String, total: Double): Double =
when (customerType) {
"regular" -> 0.0
"member" -> total * 0.10
"student" -> total * 0.15
else -> 0.0
}
Marketing wants a “black friday” discount. You must edit this function, and every new type means another edit and another chance to break the old ones.
The fix: one small class per rule
interface Discount { fun apply(total: Double): Double }
class NoDiscount : Discount { override fun apply(total: Double) = 0.0 }
class MemberDiscount : Discount { override fun apply(total: Double) = total * 0.10 }
class StudentDiscount : Discount { override fun apply(total: Double) = total * 0.15 }
// New requirement: add a class. Nothing above changes.
class BlackFridayDiscount : Discount { override fun apply(total: Double) = total * 0.30 }
class PriceCalculator {
fun total(order: Order, discount: Discount): Double {
val gross = order.items.sumOf { it.price }
return gross - discount.apply(gross)
}
}
PriceCalculator never learns about specific discounts. It is closed to change, yet open because you can plug in any new Discount.
Try it: add a rule without editing the calculator
Fill in BlackFridayDiscount. Do not touch PriceCalculator. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
interface Discount { fun apply(total: Double): Double }
class MemberDiscount : Discount {
override fun apply(total: Double) = total * 0.10
}
// Do NOT edit this class. New discounts must plug in from outside.
class PriceCalculator {
fun total(gross: Double, discount: Discount) = gross - discount.apply(gross)
}
// Your job: Black Friday takes 30% off.
class BlackFridayDiscount : Discount {
override fun apply(total: Double): Double = TODO("return 30% of total")
}
Open in Kotlin Playground ↗
Hint
Take the same shape as MemberDiscount, with a different number.
See the tests
fun main() {
val calc = PriceCalculator()
test("member discount takes 10% off") {
check(same(calc.total(100.0, MemberDiscount()), 90.0)) { "expected 90.0" }
}
test("Black Friday takes 30% off") {
check(same(calc.total(100.0, BlackFridayDiscount()), 70.0)) { "expected 70.0" }
}
test("a brand-new rule plugs in without touching the calculator") {
val fiveOff = object : Discount { override fun apply(total: Double) = 5.0 }
check(same(calc.total(100.0, fiveOff), 95.0)) { "expected 95.0" }
}
}
Show a solution
class BlackFridayDiscount : Discount {
override fun apply(total: Double) = total * 0.30
}
4. L: Liskov Substitution Principle
If code works with a parent type, it must keep working when you hand it any child type. A child that quietly breaks the parent’s promises is a trap for whoever uses it.
The problem
open class PaymentMethod {
open fun charge(amount: Double) { /* take money */ }
open fun refund(amount: Double) { /* give money back */ }
}
class GiftCard : PaymentMethod() {
override fun refund(amount: Double) {
throw UnsupportedOperationException("Gift cards cannot be refunded")
}
}
fun cancelOrder(method: PaymentMethod, amount: Double) {
method.refund(amount) // crashes if it is a GiftCard
}
GiftCard is-a PaymentMethod on paper, but it cannot be used everywhere one can. That surprise is the violation.
The fix: model what is really true
interface Payable { fun charge(amount: Double) }
interface Refundable : Payable { fun refund(amount: Double) }
class CreditCard : Refundable { /* charge and refund */ }
class GiftCard : Payable { /* charge only */ }
fun cancelOrder(method: Refundable, amount: Double) = method.refund(amount)
Now the compiler stops you from passing a gift card to cancelOrder. The bug becomes impossible instead of a runtime crash.
Warning signs: an override that throws “not supported”, does nothing silently, or needs an if (x is Child) check by the caller.
Try it: stop a child from breaking its promise
The third test fails because GiftCard claims it can refund. Change the design so that promise is never made. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
interface Payable { fun charge(amount: Double): String }
interface Refundable : Payable { fun refund(amount: Double): String }
class CreditCard : Refundable {
override fun charge(amount: Double) = "charged $amount"
override fun refund(amount: Double) = "refunded $amount"
}
// A gift card promises refunds, then refuses to give them. Fix the promise.
class GiftCard : Refundable {
override fun charge(amount: Double) = "gift card charged $amount"
override fun refund(amount: Double): String =
throw UnsupportedOperationException("Gift cards cannot be refunded")
}
fun cancelOrder(method: Refundable, amount: Double) = method.refund(amount)
Open in Kotlin Playground ↗
Hint
A gift card is Payable but not Refundable. Remove the method that throws.
See the tests
fun main() {
test("a credit card order can be cancelled") {
check(cancelOrder(CreditCard(), 5.0) == "refunded 5.0") { "refund failed" }
}
test("a gift card can still be charged") {
check(GiftCard().charge(5.0).startsWith("gift card")) { "charge failed" }
}
test("a gift card does not promise refunds") {
val card: Any = GiftCard()
check(card !is Refundable) { "GiftCard still claims to be Refundable" }
}
}
Show a solution
class GiftCard : Payable {
override fun charge(amount: Double) = "gift card charged $amount"
}
5. I: Interface Segregation Principle
No class should be forced to depend on methods it does not use. Many small, focused interfaces beat one giant one.
The problem
interface Notifier {
fun sendEmail(to: String, text: String)
fun sendSms(to: String, text: String)
fun sendPush(token: String, text: String)
}
class EmailOnlyNotifier : Notifier {
override fun sendEmail(to: String, text: String) { /* ok */ }
override fun sendSms(to: String, text: String) { /* not my job */ }
override fun sendPush(token: String, text: String) { /* not my job */ }
}
Two of the three methods are empty noise. If the SMS signature changes, EmailOnlyNotifier must be recompiled and reviewed for no reason.
The fix: small interfaces
interface EmailSender { fun sendEmail(to: String, text: String) }
interface SmsSender { fun sendSms(to: String, text: String) }
interface PushSender { fun sendPush(token: String, text: String) }
class EmailNotifier : EmailSender { /* only what it needs */ }
class MultiChannelNotifier : EmailSender, SmsSender, PushSender { /* all three */ }
Each client picks exactly the abilities it needs. Think of a TV remote: a remote with 80 buttons you never press is harder to use than one with five.
Try it: give each class only what it uses
The last test fails because EmailNotifier carries SMS and push methods it does not use. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
interface EmailSender { fun sendEmail(to: String, text: String): String }
interface SmsSender { fun sendSms(to: String, text: String): String }
interface PushSender { fun sendPush(token: String, text: String): String }
// The "fat" contract: everything in one.
interface Notifier : EmailSender, SmsSender, PushSender
// This class only sends email, but is forced to carry SMS and push too.
class EmailNotifier : Notifier {
override fun sendEmail(to: String, text: String) = "email to $to: $text"
override fun sendSms(to: String, text: String): String =
throw UnsupportedOperationException("not my job")
override fun sendPush(token: String, text: String): String =
throw UnsupportedOperationException("not my job")
}
Open in Kotlin Playground ↗
Hint
Implement EmailSender instead of Notifier, then delete the two methods that throw.
See the tests
fun main() {
test("email still works") {
check(EmailNotifier().sendEmail("a@b.c", "hi") == "email to a@b.c: hi") { "wrong email text" }
}
test("EmailNotifier is an EmailSender") {
val n: Any = EmailNotifier()
check(n is EmailSender) { "it should send email" }
}
test("EmailNotifier is not forced to do SMS or push") {
val n: Any = EmailNotifier()
check(n !is SmsSender && n !is PushSender) { "it still carries methods it does not use" }
}
}
Show a solution
class EmailNotifier : EmailSender {
override fun sendEmail(to: String, text: String) = "email to $to: $text"
}
6. D: Dependency Inversion Principle
High-level code should not depend on low-level details. Both should depend on a small contract (an interface). Your business rules should not know whether data lives in MySQL, a file or memory.
The problem
class OrderService {
private val database = MySqlDatabase() // hard-wired
fun placeOrder(order: Order) {
database.insert(order)
}
}
To test placeOrder you need a real MySQL server. To switch to another database you rewrite the service.
The fix: depend on a contract and pass it in
interface OrderStore { fun save(order: Order) }
class MySqlOrderStore : OrderStore { override fun save(order: Order) { /* SQL */ } }
class InMemoryOrderStore : OrderStore {
val saved = mutableListOf<Order>()
override fun save(order: Order) { saved += order }
}
class OrderService(private val store: OrderStore) { // injected
fun placeOrder(order: Order) = store.save(order)
}
// In a test, no database needed:
val store = InMemoryOrderStore()
OrderService(store).placeOrder(order)
check(store.saved.contains(order))
The arrow of dependency flipped. Before, the service pointed at MySQL. Now both the service and MySQL point at OrderStore, which the business side owns. Passing the dependency in through the constructor is called dependency injection, and it is the everyday tool for this principle.
Try it: inject the dependency
This one fails to compile at first, which is the point: the service gives you no way to pass a store in. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
interface OrderStore { fun save(order: Order) }
class MySqlOrderStore : OrderStore {
override fun save(order: Order) { error("There is no MySQL server in the browser") }
}
class InMemoryOrderStore : OrderStore {
val saved = mutableListOf<Order>()
override fun save(order: Order) { saved += order }
}
// OrderService creates its own database, so it cannot be tested here.
// The test below calls OrderService(store). Make that compile and pass.
class OrderService {
private val store: OrderStore = MySqlOrderStore()
fun placeOrder(order: Order) { store.save(order) }
}
Open in Kotlin Playground ↗
Hint
Move the store into the constructor as a parameter. Delete the line that creates MySqlOrderStore().
See the tests
fun main() {
val order = Order(listOf(Item("pen", 10.0)))
val store = InMemoryOrderStore()
test("the service saves through the store it is given") {
OrderService(store).placeOrder(order)
check(store.saved == listOf(order)) { "the order was not saved in the fake store" }
}
}
Show a solution
class OrderService(private val store: OrderStore) {
fun placeOrder(order: Order) { store.save(order) }
}
7. All five working together
Look at what our shop became without us trying to “use SOLID”:
| Principle | Where you can see it |
|---|---|
| S | Validator, PriceCalculator, OrderStore, ReceiptSender each do one job. |
| O | New discounts are new classes; PriceCalculator never changes. |
| L | Only Refundable methods are accepted by cancelOrder. |
| I | Notifiers expose just the channels they support. |
| D | OrderService receives an OrderStore; tests use an in-memory one. |
val service = OrderService(
validator = OrderValidator(),
pricing = PriceCalculator(),
store = MySqlOrderStore(), // swap for InMemoryOrderStore() in tests
receipts = ReceiptSender(EmailNotifier()),
)
The principles reinforce each other: small classes (S) hidden behind interfaces (D, I) are easy to extend (O) and replace safely (L).
8. When not to apply SOLID
SOLID is a cure for pain you actually feel. Applied everywhere in advance, it makes code harder to read.
- Do not make an interface for everything. If a class has one implementation and no tests need a fake, a plain class is fine. Add the interface the day you need a second one.
- Do not split a 20-line class into five. Small scripts and prototypes are fine as they are.
- Wait for the second time. The first
whenover customer types is fine. When the third case arrives, that is the signal to apply Open/Closed. - Judge by change. Ask “what is likely to change here?” and protect that spot only.
9. Takeaways and glossary
- SOLID is five small habits that keep change cheap and safe.
- Use the pain as a trigger: a god class (S), a growing
when(O), a throwing override (L), empty methods (I), or untestable code (D). - Prefer small interfaces and constructor injection; they solve most of the five at once.
- Do not pre-build abstractions. Add them when the second need appears.
| Term | In plain words |
|---|---|
| Responsibility | A reason a piece of code might need to change. |
| Interface | A contract listing what something can do, without saying how. |
| Abstraction | Hiding details behind a simple, stable contract. |
| Substitution | Using a child type wherever the parent is expected. |
| Dependency | Something your code needs in order to work. |
| Dependency injection | Handing a class what it needs, instead of letting it create it. |
| Coupling | How much one piece of code knows about, and relies on, another. |