Week 3 | Lesson 9

The SOLID Principle

Object Oriented Design Patterns

The SOLID Principle

The SOLID Principle

The SOLID principle is a set of five principles that help us write better code, making it more maintainable, readable, and easier to upgrade and modify.

These principles are not specific to Kotlin, but they are applicable to any object-oriented language.

The SOLID principle is an acronym for the following:

  • Single Responsibility Principle
  • Open/Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

Single Responsibility Principle

Single Responsibility Principle

Each class should have a single responsibility or reason to change. This helps to build a system that is better defined, modular, and easier to maintain.

We have seen this principle applied when we talked about encapsulation. Also, you will find that if this principle is applied correctly, your code will be much easier to test.

Here, the UserService class has two responsibilities, so if we need to change either of the responsibilities, we introduce a change in the UserService class.

								
									class UserService(
										private val userRepository: UserRepository,
										private val emailClient: EmailClient
									) {
										fun createUser(name: String) {
											// Create user logic
										}

										fun sendEmail(email: String) {
											// Send email logic
										}
									}
								
							

It is better to separate the responsibilities into two classes, each with a single responsibility.

								
									class UserService(
										private val userRepository: UserRepository
									) {
										fun createUser(name: String) {
											// Create user logic
										}
									}
								
							
								
									class EmailService(
										private val emailClient: EmailClient
									) {
										fun sendEmail(email: String) {
											// Send email logic
										}
									}
								
							
With responsibilities separated, UserService can now be tested without needing to mock the EmailClient. Each class can be tested independently, with fewer dependencies and simpler setup.

Open/Closed Principle

Open/Closed Principle

Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This prevents issues introduced by changes to existing code.

This principle is often applied in object-oriented programming through the use of inheritance and polymorphism.

It allows us to add new functionality to a class without changing its existing code, thus preventing bugs and making the code more maintainable.

For example, if we have a class that calculates the area of a shape, we can extend it to support new shapes without modifying the existing code.

						
							abstract class Shape {
								abstract fun area(): Double
							}

							class Circle(private val radius: Double) : Shape() {
								override fun area() = Math.PI * radius * radius
							}

							class Rectangle(private val width: Double, private val height: Double) : Shape() {
								override fun area() = width * height
							}
						
					

Here, the Shape class is open for extension, as we can add new shapes by creating new classes that extend it. However, it is closed for modification, as we do not need to change the existing code to add new shapes.

Liskov Substitution Principle

Liskov Substitution Principle

It indicates that one should be able to use any derived class instead of a parent class and have it behave in the same manner without modification.

This principle is often applied in object-oriented programming through the use of inheritance and polymorphism.

It allows us to use derived classes in place of their base classes without affecting the correctness of the program.

						
							interface Shape {
								fun area(): Double
							}

							open class Rectangle(private val width: Double, private val height: Double) : Shape {
								override fun area() = width * height
							}

							open class Square(private val side: Double) : Shape {
								override fun area() = side * side
							}
						
					

Here, the printArea function accepts a Shape interface, which means it can accept any class that implements the Shape interface, or any class that extends a class that implements the Shape interface.

						

							fun printArea(shape: Shape) {
								println("Area: ${shape.area()}")
							}

							fun main() {
								val rectangle: Shape = Rectangle(4.0, 6.0)
								val square: Shape = Square(5.0)

								printArea(rectangle)
								printArea(square)
							}
						
					

Interface Segregation Principle

Interface Segregation Principle

Many specific client-specific interfaces are better than one general-purpose interface. This means that you should not impose the clients with interfaces that they don't use.

Imagine you have a class that represents a printer. You could have the class implement one interface with methods print(), scan(), etc.

						
							interface Printer {
								fun print()
								fun scan()
							}

							class SimplePrinter : Printer { /* ... */ }
							class MultiPrinter : Printer { /* ... */ }
						
					

Or you could have the class implement multiple interfaces, each with a single method.

						
							interface Printing {
								fun print()
							}

							interface Scanning {
								fun scan()
							}

							class SimplePrinter : Printing { /* ... */ }
							class MultiPrinter : Printing, Scanning { /* ... */ }
						
					

Dependency Inversion Principle

Dependency Inversion Principle

One should depend upon abstractions, rather than concrete implementations. This way, modules can remain decoupled, leading to systems that are easier to refactor, change, and deploy.

Given an interface:

								
									interface UserRepository {
										fun selectUsers(): List<User>
									}
								
							

And an implementation of that interface:

								
									interface UserRepositoryImpl {
										override fun selectUsers(): List<User> {
											// Implementation logic
										}

									}
								
							

Recommended:

								
									class UserService(
										private val userRepository: UserRepository
									) {

										fun getUsers(): List<User> {
											return userRepository.selectUsers()
										}
									}
								
							

Not recommended:

								
									class UserService(
										private val userRepository: UserRepositoryImpl
									) {

										fun getUsers(): List<User> {
											return userRepository.selectUsers()
										}
									}
								
							

By depending on abstractions (interfaces), we can inject mock implementations in unit tests. This makes it easy to isolate the class under test without relying on real services or databases.

Design for Testability

Design for Testability

  • Single Responsibility Principle (SRP)
    → Smaller classes with one job are easier to isolate and test

  • Open/Closed Principle (OCP)
    → Extend functionality without changing existing code, reducing risk of breaking functionality

  • Liskov Substitution Principle (LSP)
    → Use derived classes without breaking functionality, guards against unwanted changes in behavior and also allows for flexible test doubles

  • Interface Segregation Principle (ISP)
    → Smaller, focused interfaces make it easier to implement functionality and also to mock them in tests

  • Dependency Inversion Principle (DIP)
    → Rely on interfaces, so you can easily swap in mocks or fakes

  • Constructor Injection
    → Makes it simple to provide test doubles without frameworks

  • Fewer Dependencies = Simpler Tests
    → Cleaner test setup, fewer mocks, and better reliability

Inversion of Control

Implementation of Dependency Inversion Principle

Inversion of Control (IoC)

Inversion of Control is a principle in software engineering by which the control is transferred from higher-level components to the lower-level components
This allows the higher-level and lower-level components to focus on their functionalities, promotes better decoupling, more flexibility, and easier maintenance.

Dependency Injection

Dependency Injection is a design pattern that implements IoC. It allows us to inject dependencies into a class, rather than creating them inside the class.

There are three types of dependency injection:

  • Constructor Injection
  • Setter Injection
  • Interface Injection

Dependency Injection: Examples

No Dependency Injection example. The class is tightly coupled with the dependency.

Given the following model:

								
									interface Vehicle {
										val passengerCapacity: Int
										val engineType: EngineType
										val vehicleType: VehicleType
										val description: String
											get() = "$engineType $vehicleType "
														+ "with capacity of $passengerCapacity passengers."
									}

									interface Taxi {
										fun callTaxi()
									}

									enum class EngineType {
										ELECTRIC,
										HYBRID,
										GASOLINE,
										DIESEL
									}

									enum class VehicleType {
										CAR,
										MOTORCYCLE
									}
								
							

The Vehicle implementation:

								
									class ElectricCar(
										override val passengerCapacity: Int
									) : Vehicle {
										override val engineType = EngineType.ELECTRIC
										override val vehicleType = VehicleType.CAR
									}
								
							

NoInjectionTaxiService implements Taxi interface and uses hardcoded Vehicle implementation.

								
									class NoInjectionTaxiService : Taxi {

										private val vehicle = ElectricCar(passengerCapacity = 4)

										override fun callTaxi() {
											println("Calling ${vehicle.description}")
										}
									}
								
							

And the main function:

								
									fun main() {
										val taxiService = NoInjectionTaxiService()
										taxiService.callTaxi()
									}
								
							

Dependency Injection: Examples

Constructor Injection example. The dependency is injected into the class through its constructor.

Given the following model:

								
									interface Vehicle {
										val passengerCapacity: Int
										val engineType: EngineType
										val vehicleType: VehicleType
										val description: String
											get() = "$engineType $vehicleType "
														+ "with capacity of $passengerCapacity passengers."
									}

									interface Taxi {
										fun callTaxi()
									}

									enum class EngineType {
										ELECTRIC,
										HYBRID,
										GASOLINE,
										DIESEL
									}

									enum class VehicleType {
										CAR,
										MOTORCYCLE
									}
								
							

The Vehicle implementation:

								
									class Motorcycle: Vehicle {
										override val passengerCapacity = 2
										override val engineType = EngineType.GASOLINE
										override val vehicleType = VehicleType.MOTORCYCLE
									}
								
							

ConstructorInjectionTaxiService implements Taxi and accepts a Vehicle implementation as a constructor parameter.

								
									class ConstructorInjectionTaxiService(
										private val vehicle: Vehicle
									) : Taxi {

										override fun callTaxi() {
											println("Calling ${vehicle.description}")
										}
									}
								
							

In the main function, the dependency is injected through the constructor of ConstructorInjectionTaxiService:

								
									fun main() {
										val taxiService = ConstructorInjectionTaxiService(vehicle = Motorcycle())
										taxiService.callTaxi()
									}
								
							

Dependency Injection: Examples

Setter Injection example. The dependency is injected into the class through a setter method.

Given the following model:

								
									interface Vehicle {
										val passengerCapacity: Int
										val engineType: EngineType
										val vehicleType: VehicleType
										val description: String
											get() = "$engineType $vehicleType "
														+ "with capacity of $passengerCapacity passengers."
									}

									interface Taxi {
										fun callTaxi()
									}

									enum class EngineType {
										ELECTRIC,
										HYBRID,
										GASOLINE,
										DIESEL
									}

									enum class VehicleType {
										CAR,
										MOTORCYCLE
									}
								
							

The Vehicle implementation:

								
									class TukTuk: Vehicle {
										override val passengerCapacity = 2
										override val engineType = EngineType.GASOLINE
										override val vehicleType = VehicleType.MOTORCYCLE
									}
								
							

NoInjectionTaxiService implements Taxi interface and uses hardcoded Vehicle implementation.

								
								class SetterInjectionTaxiService : Taxi {

									private lateinit var vehicle: Vehicle

									fun setVehicle(vehicle: Vehicle) {
										this.vehicle = vehicle
									}

									override fun callTaxi() {
										if (!::vehicle.isInitialized) {
											throw IllegalStateException("Vehicle is not set")
										}
										println("Calling ${vehicle.description}")
									}
								}
								
							

The service is instantiated in the main function and its dependency is injected through a setter method.

								
									fun main() {
										val taxiService = SetterInjectionTaxiService()
										taxiService.setVehicle(TukTuk())
										taxiService.callTaxi()
									}
								
							

Dependency Injection: Examples

Interface Injection example. The dependency is injected into the class through an interface.

This usually requires an injector to provide the dependency. Application frameworks usually provide such an injector for us.

We have the basic model:

								
									interface Vehicle {
										val passengerCapacity: Int
										val engineType: EngineType
										val vehicleType: VehicleType
										val description: String
											get() = "$engineType $vehicleType "
														+ "with capacity of $passengerCapacity passengers."
									}

									interface Taxi {
										fun callTaxi()
									}

									enum class EngineType {
										ELECTRIC,
										HYBRID,
										GASOLINE,
										DIESEL
									}

									enum class VehicleType {
										CAR,
										MOTORCYCLE
									}
								
							

I have upgraded the model with PickupService interface.

								
									interface PickupService {
										fun orderPickup(
											pickupAt: ZonedDateTime,
											from: String,
											to: String
										)
									}
								
							

The Vehicle implementation also needs to implement KoinComponent interface to become injectable.

								
									class Van: Vehicle, KoinComponent {
										override val passengerCapacity = 8
										override val engineType = EngineType.DIESEL
										override val vehicleType = VehicleType.CAR
									}
								
							

KoinComponent is an interface provided by Koin that allows a class to access dependencies via the by inject() delegate. Any class that wants to retrieve dependencies this way must implement KoinComponent.

Dependency Injection: Examples

Interface Injection example. The dependency is injected into the class through an interface.

The Vehicle implementation needs to implement KoinComponent interface to become injectable.

								
									class Van: Vehicle, KoinComponent {
										override val passengerCapacity = 8
										override val engineType = EngineType.DIESEL
										override val vehicleType = VehicleType.CAR
									}
								
							

The Taxi service has a dependency on Vehicle.
Both of them also implement KoinComponent to become injectable.
Their dependencies will be injected through the by inject() property delegate.

								
									class TaxiService : Taxi, KoinComponent {

										private val vehicle: Vehicle by inject()

										override fun callTaxi() {
											println(vehicle.description)
										}
									}
								
							
								
									class TaxiPickupService: PickupService, KoinComponent {

										private val taxiService: Taxi by inject()

										override fun orderPickup(
											pickupAt: ZonedDateTime,
											from: String,
											to: String,
										) {
											println("Pickup at $pickupAt from $from to $to using taxi service:")
											taxiService.callTaxi()
										}
									}
								
							

In order for Koin to know how to inject the dependencies, we define a Koin module, declaring all the dependencies and their types.

								
									val appModule = module {

										singleOf(::Van) { bind<Vehicle>() }

										singleOf(::TaxiService) { bind<Taxi>() }

										singleOf(::TaxiPickupService) { bind<PickupService>() }
									}
								
							

And finally, we start the Koin application in the main function, and get the PickupService implementation from Koin.

								
									fun main() {

										val koinApp = startKoin {
											modules(appModule)
										}

										val pickupService = koinApp.koin.get<PickupService>()

										pickupService.orderPickup(
											pickupAt = ZonedDateTime.now(),
											from = "123 Main St",
											to = "456 Elm St"
										)
									}
								
							

IoC in Ktor

IoC in Ktor

What do we have so far?

So far, we have been using the IoC principle in our Ktor application with the constructor injection in application main.

						
						fun main() {

							embeddedServer(Netty, port = 8080, host = "0.0.0.0") {

								// ...

								val menuRepository = MenuRepositoryImpl()
								val menuService = MenuService(menuRepository = menuRepository)
								val jwtGenerator = JwtService(config = applicationConfig)
								val userRepository = UserRepositoryImpl()
								val authenticationService = AuthenticationService(
									userRepository = userRepository,
									internalCustomerService = InternalCustomerService(customerRepository = CustomerRepositoryImpl()),
									jwtService = jwtGenerator
								)

								// ...

								install(Authentication) {
									configureJWT(applicationConfig)
								}

								routing {
									loginRoutes(authenticationService, API_PATH)

									authenticate(AUTH_JWT) {
										menuRoutes(menuService, API_PATH)
										orderRoutes(orderService, API_PATH)
									}
								}
							}.start(wait = true)
						}
						
					

IoC in Ktor

Using dependency injection framework.

While dependency injection using constructor is a good start, it is not very practical for larger applications.

For larger applications, we can use a dependency injection framework, such as Koin or Dagger.

Dependency injection framework allows us to define dependencies by their type, and the framework will take care of creating and injecting them into the classes that need them.

Ktor framework has built-in support for dependency injection using Koin.

Koin

Ktor with Koin

We use the same Koin module declaration as before.

								
									fun appModule(config: ApplicationConfig) = module {
										single { config }
										singleOf(::MenuRepositoryImpl) { bind<MenuRepository>() }
										singleOf(::MenuService)
										singleOf(::JwtService)
										singleOf(::AuthenticationService)
									}
								
							

Start Koin in the main function and inject the dependencies into the Ktor application.

								
									fun main() {
										val applicationConfig = ApplicationConfig("application.yaml")

										embeddedServer(Netty, port = 8080, host = "0.0.0.0") {

											startKoin {
												modules(
													appModule(applicationConfig)
												)
											}

											// ...

											routing {
												loginRoutes(API_PATH)

												authenticate(AUTH_JWT) {
													menuRoutes(API_PATH)
												}
											}
										}.start(wait = true)
									}
								
							

All that remains is to inject the dependencies into the routes.

								
									fun Route.menuRoutes(basePath: String) {

										val menuService by inject<MenuService>()

										route(basePath) {
											// ...
										}
									}
								
							

We can use the same mechanism to inject the dependencies into the services. However, it is actually quite practical to do it through the constructor, as it makes the services easier to test (no need to instantiate Koin in tests).

								
									class MenuService(
										private val menuRepository: MenuRepository
									) {
										// ...
									}
								
							

Functional Programming

Bonus

Functional Programming

So far, we have been using mostly imperative programming style.
There is another programming paradigm called functional programming.

The imperative programing style is characterized by explicit statements that change a program's state.

Functional programming is a programming paradigm where programs are constructed by applying and composing functions. It emphasizes the use of pure functions that avoid changing state and mutable data.

Pure function is a function where the return value is only determined by its input values, without observable side effects.

Side effects are changes in the state of the program that are observable outside the called function other than the return value.

Kotlin has by design very good support for functional programming, and we have already seen some examples of it.

Principles of functional programming

  • Immutability
    • Once an object is created, it cannot be changed.
    • Instead of changing the object, a new object is created with the new value.
  • Pure functions
    • Functions that always return the same result for the same input.
    • They do not produce or rely on side effects.
  • First-class functions
    • Functions are treated as first-class citizens, meaning they can be passed as arguments to other functions, returned as values from other functions, and assigned to variables.
  • Higher-order functions
    • Functions that take other functions as arguments or return them as results.
  • Referential transparency
    • It means that a function will always return the same result for the same input.
    • This means that the function call can be replaced with its corresponding value without changing the program’s behavior.
  • There may be other principles mentioned such as Recursion or Functional composition
Programming in a functional style is not about using functions, but about using functions as the primary building blocks of your program.

Functional Programming

Example of a pure function and a function with side effects.

Function with side effects:

						
							var counter = 0

							fun unPureFunction(increment: Int): Int {
								return counter += increment
							}
						
					

Pure function:

						
							fun pureFunction(counter: Int, increment: Int): Int {
								return counter + increment
							}
						
					

Function as an argument

You can pass a function as an argument to another function.

Here is an example of a function that takes a function with a single parameter and returns an integer.

						
							fun execute(input: String, function: (String) -> Int): Int {
								return function(input)
							}
						
					
						
							val result = execute("Hello Function!") { input ->
								println("Got input: $input")
								input.length
							}

							println(result)
						
					

Here is an example of a function that takes a function with two parameters and returns a boolean.

						
							fun execute(input1: String, input2: Double, function: (String, Double) -> Boolean): Boolean {
								return function(input1, input2)
							}
						
					
						
							val result2 = execute("12.34", 12.34) { p1, p2 ->
								p1.toDouble() == p2
							}

							println(result2)
						
					

Benefits of functional programming

  • No side effects
    • Pure functions do not change the state of the program, and do not rely on external state, making them easier to reason about.
    • Side effects are source of many bugs in imperative programming.
  • Code reusability
    • Functions can be reused in different contexts.
    • Higher-order functions allow for more flexible and reusable code.
  • Improved readability
    • Code is often more concise and easier to understand.
    • Functions can be composed to create more complex behavior.
  • Easier testing
    • Pure functions are easier to test because they do not rely on external state.
    • Referential transparency allows for easier reasoning about code behavior.
  • Concurrency support
    • Immutability and pure functions make it easier to write concurrent code without worrying about shared state.
Functional programming and object-oriented programming are not mutually exclusive. They can be used together to create more powerful and flexible code.

Generics

Bonus

Generics

Generics allow us to create classes, interfaces, and methods that take types as parameters.

They are a way to make our code more reusable by allowing us to use the same code with different types.

Generics are used to create classes, interfaces, and methods that operate on a type parameter.

We mave have already seen generics in action with the List interface. For example, all of these methods use generics:

						
							val list = mutableListOf<String>()

							list.add("Hello")
							list.add("World")

							list.forEach {
								println(it)
							}
						
					

Generics

How to define a generic class

To define a generic class, need to create a type parameter in the class definition.

						
							class MyClass<T>(private val id: T) {

								fun getId(): T {
									return id
								}

							}
						
					

Classes may have multiple type parameters.

						
							class MyClassEnhanced<T1, T2> {

								fun equals(value1: T1, value2: T2 ): Boolean {
									return value1 == value2
								}

							}
						
					

Usage:

						
							val myClass1 = MyClass<String>("ABC")
							println(myClass1.getId())

							val myClass2 = MyClass<Int>(123)
							println(myClass2.getId())

							val myClassEnhanced = MyClassEnhanced<Int, Double>()
    						println(myClassEnhanced.equals(123, 123.0))
						
					

Generics

How to define a generic function

Similarly, you define generic functions by adding a type parameter to the function definition.

Functions can also have multiple type parameters and the type parameters can have constraints (types).

						
							fun <T1: Number, T2: Number> myFunction(value1: T1, value2: T2 ): Double {
								return value1.toDouble() + value2.toDouble()
							}
						
					

You can also define generic return types.

						
							interface MyInterface<T1, T2, R> {
								fun myFunction(a: T1, b: T2): R
							}
						
					

Exercises

Exercise 1: class generics

Create a generic class Box that can hold any type of item. The class should have two methods:

  • putItem(item: T) that puts an item into the box
  • getItem(): T? that returns the item from the box
  • Store the item as a private property in the class

Create an instance of the Box class with different types and test the methods.

Exercise 2: function generics

Create a generic function insertIntoBox that takes three parameters:

  • item1 of type T1
  • item2 of type T2
  • func of type (T1, T2) -> R

The function should return the result of calling the func with item1 and item2 as arguments. Call the function with two different types of items and a lambda that combines the items into a string.

Exercise 3: interface generics

Create a generic interface MyComparator hat has three type parameters: T1, T2 and R. The interface should have a single method returnGreater that takes two parameters of type T1 and T2 greater of the two as type R.

Exercises

Solutions

Exercise 1: class generics

								
									class Box<T> {
										private var item: T? = null

										fun putItem(item: T) {
											this.item = item
										}

										fun getItem(): T? {
											return item
										}
									}

									fun main() {
										val box = Box<String>()
										box.putItem("Hello")
										println(box.getItem()) // Output: Hello

										val intBox = Box<Int>()
										intBox.putItem(42)
										println(intBox.getItem()) // Output: 42
									}
								
							

Exercise 2: function generics

								
									fun <T1, T2, R> insertIntoBox(item1: T1, item2: T2, func: (T1, T2) -> R): R {
										return func(item1, item2)
									}

									fun main() {
										val result = insertIntoBox(5, " apples") { a, b -> "$a$b" }
										println(result) // Output: 5 apples
									}
								
							

Exercise 3: interface generics

								
									interface MyComparator<T1, T2, R> {
										fun returnGreater(a: T1, b: T2): R
									}

									fun main() {
										val comparator = object : MyComparator<Int, Int, Int> {
											override fun returnGreater(a: Int, b: Int): Int {
												return if (a > b) a else b
											}
										}

										val greater = comparator.returnGreater(10, 20)
										println(greater) // Output: 20
									}
								
							

Next Lesson

Next Lesson

  • Memory structure and management
  • JVM, JRE, JDK, compiler
  • Multithreading
  • Coroutines ♡