Software design · For beginners

Designing for Change: The SOLID Principles, Explained and Practised

Khairul Alam·October 2026·~30 min with exercises
SOLIDDesign principlesKotlinClean code

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.

The whole post in 60 seconds
  • 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
  1. Why design matters
  2. S: Single Responsibility
  3. O: Open/Closed
  4. L: Liskov Substitution
  5. I: Interface Segregation
  6. D: Dependency Inversion
  7. All five working together
  8. When not to apply SOLID
  9. Takeaways and glossary
Practice is built in

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.

OrderService validatepricesaveemail Before: four reasons to change OrderService Validator PriceCalculator Repository ReceiptSender After: one job each
Split by reason to change, not by size. Small classes are a result, not the goal.

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.

PriceCalculator «Discount» NoDiscount MemberDiscount StudentDiscount BlackFriday
The dashed box is the new feature. It plugs in without touching the calculator.

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.

PaymentMethod charge, refund GiftCard refund() throws! Before: broken promise Payable Refundable GiftCard CreditCard After: only honest promises
Do not make a child pretend to do what it cannot. Split the contract instead.

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.

Notifier sendEmailsendSmssendPush Before: one fat contract EmailSender SmsSender PushSender Email only Multi-channel After: pick what you need
Split interfaces by who uses them, not by who implements them.

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.

OrderService MySQL Before: tied to one tool OrderService «OrderStore» MySqlOrderStore InMemoryOrderStore After: both depend on the contract
The business code names what it needs. Details plug in from outside.

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”:

PrincipleWhere you can see it
SValidator, PriceCalculator, OrderStore, ReceiptSender each do one job.
ONew discounts are new classes; PriceCalculator never changes.
LOnly Refundable methods are accepted by cancelOrder.
INotifiers expose just the channels they support.
DOrderService 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.

9. Takeaways and glossary

TermIn plain words
ResponsibilityA reason a piece of code might need to change.
InterfaceA contract listing what something can do, without saying how.
AbstractionHiding details behind a simple, stable contract.
SubstitutionUsing a child type wherever the parent is expected.
DependencySomething your code needs in order to work.
Dependency injectionHanding a class what it needs, instead of letting it create it.
CouplingHow much one piece of code knows about, and relies on, another.
← Back to all writing