SOLID Design Principles in Kotlin (Part 2)

Firuze Gümüş
7 min readJun 1, 2024

--

Hello everyone,

In this article, we will pick up where we left off exploring SOLID design principles. If you would like to visit Part 1, follow this link:

We will be examining the Liskov Substitution, Interface Segregation, and Dependency Inversion principles, which correspond to the L, I, and D in SOLID Design Principles.

3. Liskov Substitution Principle

Named after Barbara Liskov, this principle states that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program. This emphasizes the importance of designing derived classes to maintain the expected behavior of the base class.

Let’s try to understand what this means with an example:

Imagine a payment processor class that requires SMS verification to ensure a secure payment process. If a subclass bypasses this verification step, it would violate the expected behavior of the base class. For example, if credit card payments require SMS verification, and a subclass handling bank card payments skips the SMS verification, it would violate the Liskov Substitution Principle by not maintaining the expected behavior of the base class.

Here is an example of code that violates the Liskov Substitution Principle (LSP):

abstract class PaymentProcessor {
abstract fun processPayment(amount: Double, smsCode: String)
abstract fun generateInvoice(amount: Double): String
abstract fun sendNotification(message: String)
}

class CreditCardPaymentProcessor : PaymentProcessor() {
override fun processPayment(amount: Double, smsCode: String) {
if (smsCode.isEmpty()) {
throw IllegalArgumentException("SMS code is required for credit card payments")
}

if (!verifySms(smsCode)) {
throw SecurityException("Invalid SMS code")
}

println("Processing credit card payment of $$amount")

val invoice = generateInvoice(amount)
sendNotification("Payment processed. Invoice: $invoice")
}

private fun verifySms(smsCode: String): Boolean {
return smsCode == "123456"
}

override fun generateInvoice(amount: Double): String {
return "Invoice for $$amount"
}

override fun sendNotification(message: String) {
println("Notification sent: $message")
}
}

class BankTransferPaymentProcessor : PaymentProcessor() {
override fun processPayment(amount: Double, smsCode: String) {
println("Processing bank transfer of $$amount without SMS verification")

val invoice = generateInvoice(amount)
sendNotification("Bank transfer processed. Invoice: $invoice")
}

override fun generateInvoice(amount: Double): String {
return "Invoice for $$amount"
}

override fun sendNotification(message: String) {
println("Notification sent: $message")
}
}

fun testPaymentProcessor(processor: PaymentProcessor) {
try {
processor.processPayment(1000.0, "123456")
} catch (e: Exception) {
println("Test failed: ${e.message}")
}
}

fun main() {
val creditCardProcessor = CreditCardPaymentProcessor()
val bankTransferProcessor = BankTransferPaymentProcessor()

// CreditCardPaymentProcessor works as expected
testPaymentProcessor(creditCardProcessor)

// Since the BankTransferPaymentProcessor does not require the SMS verification step, it would violate the LSP.
testPaymentProcessor(bankTransferProcessor)
}

We can make the code comply with LSP by organizing it as follows:

abstract class PaymentProcessor {
abstract fun processPayment(amount: Double)
abstract fun generateInvoice(amount: Double): String
abstract fun sendNotification(message: String)
}

interface SmsVerifiable {
fun verifySms(smsCode: String): Boolean {
return smsCode == "123456"
}
}

class CreditCardPaymentProcessor(private val smsCode: String) : PaymentProcessor(), SmsVerifiable {

override fun processPayment(amount: Double) {
// SMS verification
if (!verifySms(smsCode)) {
throw SecurityException("Invalid SMS code")
}

println("Processing credit card payment of $$amount")

val invoice = generateInvoice(amount)
sendNotification("Payment processed. Invoice: $invoice")
}

override fun generateInvoice(amount: Double): String {
return "Invoice for $$amount"
}

override fun sendNotification(message: String) {
println("Notification sent: $message")
}
}

class BankTransferPaymentProcessor : PaymentProcessor() {
override fun processPayment(amount: Double) {
println("Processing bank transfer of $$amount without SMS verification")

val invoice = generateInvoice(amount)
sendNotification("Bank transfer processed. Invoice: $invoice")
}

override fun generateInvoice(amount: Double): String {
return "Invoice for $$amount"
}

override fun sendNotification(message: String) {
println("Notification sent: $message")
}
}

fun testPaymentProcessor(processor: PaymentProcessor) {
try {
processor.processPayment(1000.0) // SMS doğrulama gerekmez
} catch (e: Exception) {
println("Test failed: ${e.message}")
}
}

fun main() {
val creditCardProcessor = CreditCardPaymentProcessor("123456")
val bankTransferProcessor = BankTransferPaymentProcessor()

testPaymentProcessor(creditCardProcessor) // The credit card payment processor works with SMS verification.
testPaymentProcessor(bankTransferProcessor) // The bank transfer processor does not require SMS verification.
}

Since SMS verification is only required for credit card payment processing, we remove the SMS code parameter from the processPayment method in our base class. The BankTransferPaymentProcessor, a subclass of PaymentProcessor, does not require this step. Therefore, the SMS verification process should be handled separately within the CreditCardPaymentProcessor. This ensures that our base class can be substituted with its subclasses while maintaining the expected behavior of the base class.

4. Interface Segregation Principle

This principle states that interfaces should represent fine-tuned and specific functionalities rather than being large and monolithic. This way, clients depend only on the relevant interfaces, reducing unnecessary dependencies and increasing maintainability.

Imagine you are creating an interface for a vehicle maintenance system. This interface includes many different maintenance and repair tasks. However, each type of vehicle only uses a portion of this interface. This situation doesn’t comply with the Interface Segregation Principle, as subclasses are forced to implement functions they do not use.

Let’s examine our example code:

interface VehicleMaintenance {
fun changeOil()
fun rotateTires()
fun replaceBrakes()
fun checkTransmission()
fun inspectExhaust()
fun tuneEngine()
}

class SportsCarMaintenance : VehicleMaintenance {
override fun changeOil() {
println("Changing oil for sports car")
}

override fun rotateTires() {
println("Rotating tires for sports car")
}

override fun replaceBrakes() {
println("Replacing brakes for sports car")
}

override fun checkTransmission() {
println("Checking transmission for sports car")
}

override fun inspectExhaust() {
println("Inspecting exhaust for sports car")
}

override fun tuneEngine() {
println("Tuning engine for sports car")
}
}

class BicycleMaintenance : VehicleMaintenance {
override fun changeOil() {
throw UnsupportedOperationException("Bicycles don't use oil")
}

override fun rotateTires() {
println("Rotating tires for bicycle")
}

override fun replaceBrakes() {
println("Replacing brakes for bicycle")
}

override fun checkTransmission() {
throw UnsupportedOperationException("Bicycles don't have transmissions")
}

override fun inspectExhaust() {
throw UnsupportedOperationException("Bicycles don't have exhaust systems")
}

override fun tuneEngine() {
throw UnsupportedOperationException("Bicycles don't have engines")
}
}

fun main() {
val sportsCar = SportsCarMaintenance()
sportsCar.changeOil() // This works

val bicycle = BicycleMaintenance()
bicycle.changeOil() // UnsupportedOperationException
}

In this example, the VehicleMaintenance interface includes too many functions. While sports cars may use most of these functions, many of them are unnecessary for bicycle maintenance. The BicycleMaintenance class would have to throw UnsupportedOperationException, indicating a violation of the ISP.

Now let’s rewrite the code to adhere to the Interface Segregation Principle as it should be:

interface OilChangeable {
fun changeOil()
}

interface TireRotatable {
fun rotateTires()
}

interface BrakeReplaceable {
fun replaceBrakes()
}

class SportsCarMaintenance : OilChangeable, TireRotatable, BrakeReplaceable {
override fun changeOil() {
println("Changing oil for sports car")
}

override fun rotateTires() {
println("Rotating tires for sports car")
}

override fun replaceBrakes() {
println("Replacing brakes for sports car")
}
}

class BicycleMaintenance : TireRotatable, BrakeReplaceable {
override fun rotateTires() {
println("Rotating tires for bicycle")
}

override fun replaceBrakes() {
println("Replacing brakes for bicycle")
}
}

fun main() {
val sportsCar = SportsCarMaintenance()
sportsCar.changeOil()

val bicycle = BicycleMaintenance()
try {
// bicycle.changeOil() // Now, it no longer throws UnsupportedOperationException because this function is unused and unnecessary.
} catch (e: Exception) {
println("Error: ${e.message}")
}

bicycle.rotateTires()
}

5. Dependency Inversion Principle

This principle encourages the use of abstraction to separate high-level code from low-level implementation details. By reversing the dependency structure, software systems become more modular and flexible, facilitating easier changes and testability.

Let’s consider a scenario where we can immediately exemplify this principle: In an e-commerce application, imagine a scenario where an order class is directly dependent on a concrete payment processor (for example, a credit card payment processor). This situation would require updating all the code when you want to change the payment processor, reducing testing flexibility.

Let’s examine our example code right away:

//  Low-level module: Credit Card Payment Processor
class CreditCardPaymentProcessor {
fun processCreditCardPayment(amount: Double) {
println("Processing credit card payment of $$amount")
}
}

// Low-level module: Paypal Payment Processor
class PayPalPaymentProcessor {
fun processPayPalPayment(amount: Double) {
println("Processing payment of $$amount using PayPal")
}
}

// High-level module: Order Processor
class OrderProcessor {
private val creditCardPaymentProcessor = CreditCardPaymentProcessor()
private val payPalPaymentProcessor = PayPalPaymentProcessor()

fun processOrder(order: Order, paymentType: String) {
// High-level business logic
println("Processing order #${order.id}")

// Payment Process
when (paymentType) {
"CreditCard" -> creditCardPaymentProcessor.processCreditCardPayment(order.total)
"PayPal" -> payPalPaymentProcessor.processPayPalPayment(order.total)
else -> println("Unsupported payment method")
}

// Other business logic
println("Order #${order.id} processed")
}
}

data class Order(val id: Int, val total: Double)

fun main() {
val order = Order(1, 150.0)

val orderProcessor = OrderProcessor()
orderProcessor.processOrder(order, "CreditCard")
orderProcessor.processOrder(order, "PayPal")
}

In this example, the Order class is directly dependent on the CreditCardPaymentProcessor concrete class. Due to this dependency, it becomes difficult to switch to a different payment processor or to change the processor during testing. Moreover, this dependency prevents the use of abstractions that would make the Order class more flexible or facilitate future changes.

Let’s make our code compliant with Dependency Inversion (DI) then. This means using abstract structures instead of depending on concrete classes:

//  High-level module: Order Processor
class OrderProcessor(private val paymentProcessor: PaymentProcessor) {
fun processOrder(order: Order) {
// High-level business logic
println("Processing order #${order.id}")

//Payment Process
paymentProcessor.processPayment(order.total)

// Other business logic
println("Order #${order.id} processed")
}
}

interface PaymentProcessor {
fun processPayment(amount: Double)
}

// Low-level module:Credit Card Payment Processor
class CreditCardPaymentProcessor : PaymentProcessor {
override fun processPayment(amount: Double) {
println("Processing credit card payment of $$amount")
}
}

// Low-level module:PayPal Payment Processor
class PayPalPaymentProcessor : PaymentProcessor {
override fun processPayment(amount: Double) {
println("Processing payment of $$amount using PayPal")
}
}

data class Order(val id: Int, val total: Double)

fun main() {
val order = Order(1, 150.0)

// High-level module is not dependent on low-level details, but on abstraction.
val orderProcessor1 = OrderProcessor(CreditCardPaymentProcessor())
orderProcessor1.processOrder(order)

val orderProcessor2 = OrderProcessor(PayPalPaymentProcessor())
orderProcessor2.processOrder(order)
}

In this example, an interface named PaymentProcessor is defined. Both CreditCardPaymentProcessor and PayPalPaymentProcessor implement this interface, allowing the use of the same abstraction to work with different payment methods. The Order class is no longer dependent on a concrete class but rather on the abstraction. This enables the order process to be performed using different payment processors.

Yes, we have come to the end of the SOLID Design Principles series consisting of 2 articles. I aimed to make abstract concepts more understandable by presenting concrete scenarios wherever possible. I hope it was useful.

Keep coding!

--

--