Real life Kotlin example of Delegation and Dependency Injection

Showcasing a simplified version of a real-life Kotlin application for order processing where we need to enhance functionality without touching the production code.

Functional requirements

Supposing the system already has the functionality for order creation, now the requirements are to implement:

  • the possibility of updating an order
  • if the total order amount is greater than 100,000, the operation should fail
  • the operation should be audited
  • the operation should publish an event when the order is updated

For simplicity, we will only log a message when saving to the database, auditing and publishing.

Rudimentary implementation

Many engineers will implement the requirements as follows:

data class Order(val totalAmount: Int)
class OrderRepository {
    fun update(order: Order) {
        println("Saving order to database: $order")
    }
}
class AuditService {
    fun audit(data: Any) {
        println("Auditing: $data")
    }
}
class EventPublisherService {
    fun publish(topic: String, data: Any) {
        println("Publishing event to '$topic'. Data: $data")
    }
}
class OrderService(
    private val orderRepository: OrderRepository,
    private val auditService: AuditService,
    private val eventPublisherService: EventPublisherService,
) {
    fun updateOrder(order: Order) {
        if (order.totalAmount > 100_000) {
            throw RuntimeException("Operation forbidden. Orders with a total amount over 100,000 are not allowed.")
        }
        orderRepository.update(order)
        auditService.audit(order)
        eventPublisherService.publish("order", order)
    }
}
val orderRepository = OrderRepository()
val auditService = AuditService()
val eventPublisherService = EventPublisherService()
val orderService = OrderService(orderRepository, auditService, eventPublisherService)
orderService.updateOrder(Order(10))

New Requirements

After running the code in production for some time, some users tried placing orders over 100,000. This was a valid scenario for those customers. The frequency of such cases was shallow compared to regular usage. For those cases, the system threw exceptions.

orderService.updateOrder(Order(100_001)) // will throw

The team decided that the existing code should remain unchanged and we should allow our customer support team to update orders regardless of the amount. The solution needed to be live fast.

What is wrong with the rudimentary implementation?

From a functional perspective – nothing. The easiest solution would be to add a check for the user to be of type customer support before the amount validation:

if (!authenticatedUser.isCustomerSupportUser() && order.totalAmount > 100_000) {
    throw RuntimeException("Operation forbidden. Orders with a total amount over 100,000 are not allowed.")
}

This approach was not possible in our case because of other constraints.

From a code perspective, this implementation doesn’t get along well with some best practices. Some of the most important ones are breaking the Single Responsibility Principle, see here why, and breaking the Open/Close Principle as we need to enhance the existing functionality, not change it.

Refactoring the rudimentary implementation

This is where delegation and dependency injection magic come into play.

The first step is to extract an interface from OrderService:

interface IOrderService {
    fun updateOrder(order: Order)
}
class OrderService(
    private val orderRepository: OrderRepository,
    private val auditService: AuditService,
    private val eventPublisherService: EventPublisherService,
) : IOrderService {
    override fun updateOrder(order: Order) {
        if (order.totalAmount > 100_000) {
            throw RuntimeException("Operation forbidden. Orders with a total amount over 100,000 are not allowed.")
        }
        orderRepository.update(order)
        auditService.audit(order)
        eventPublisherService.publish("order", order)
    }
}

The OrderService class has three main functionalities: updating an order, auditing, and publishing an event. We will split this into three separate classes using delegation:

class OrderService(
    private val orderRepository: OrderRepository,
) : IOrderService {
    override fun updateOrder(order: Order) {
        if (order.totalAmount > 100_000) {
            throw RuntimeException("Operation forbidden. Orders with a total amount over 100,000 are not allowed.")
        }
        orderRepository.update(order)
    }
}
class OrderServiceWithAuditing(
    private val orderService: IOrderService,
    private val auditService: AuditService,
) : IOrderService {
    override fun updateOrder(order: Order) {
        orderService.updateOrder(order)
        auditService.audit(order)
    }
}
class OrderServiceWithEventPublishing(
    private val orderService: IOrderService,
    private val eventPublisherService: EventPublisherService,
) : IOrderService {
    override fun updateOrder(order: Order) {
        orderService.updateOrder(order)
        eventPublisherService.publish("order", order)
    }
}
val orderService: IOrderService =
    OrderServiceWithEventPublishing(
        OrderServiceWithAuditing(
            OrderService(orderRepository),
            auditService
        ),
        eventPublisherService
    )

Now we have the same functionality, decoupled and better in terms of SOLID best practices.

Implementing the new requirements

As a final step, we need to add the check for authenticated user to be a customer support user and we should skip the amount validation. We will achieve this by creating another class OrderServiceUpdaterWithoutAmountCheck which overrides the update functionality of OrderService and, using the dependency injection, will swap the previous implementation of OrderService with the new one.

class OrderServiceUpdaterWithoutAmountCheck(
    private val orderRepository: OrderRepository,
) : OrderService(orderRepository) { 
    override fun updateOrder(order: Order) {
        // check if authenticated user is a customer support user
        println("Skipping order amount check")
        orderRepository.update(order)
    }
}
val orderService: IOrderService =
    OrderServiceWithEventPublishing(
        OrderServiceWithAuditing(
            OrderServiceUpdaterWithoutAmountCheck(orderRepository), // swapping
            auditService
        ),
        eventPublisherService
    )
orderService.updateOrder(Order(100_001)) // not throwing anymore

Conclusion

Such scenarios frequently occur in production systems. Other similar examples include a super-admin executing an operation or skipping a piece of code.

The code was built using dependency injection and delegation. These are some of the most well-known and frequently encountered design patterns.

Using the dependency injection together with delegation, we swapped one implementation with another and introduced the new functionality without affecting any of the existing functionality. For this to work, the primary condition is for the code to be well split based on responsibility. In well-designed systems, such change would affect only the line where we swap the implementations.

The fact that we didn’t touch any of the existing code is crucial because we usually want to avoid touching current production code as long as there is no need to. In our case, the existing code worked perfectly before and we only needed to enhance it.

About the downsides of this solution, there’s definitely more complexity to handle and more written code. Juniors struggle with it and some veterans or people who worked longer on the project will often be against it, primarily because they are used to the old ways of doing things.

I suggest always designing your code with SOLID in mind and using those tactics whenever you have the chance. Once you get used to it and see the benefits, it will become your daily tool.