Every developer wants code that’s simple to understand, easy to update, and built to last. But achieving this isn’t always easy! Thankfully, a few core principles—SOLID, DRY, KISS, and YAGNI—can help guide the way. Think of these as your trusty shortcuts to writing code that’s not only easy on the eyes but also a breeze to maintain and scale.
In Kotlin, these principles fit naturally, thanks to the language’s clean and expressive style. In this blog, we’ll explore each one with examples to show how they can make your code better without making things complicated. Ready to make coding a little easier? Let’s get started!
Introduction to Design Principles
Design principles serve as guidelines to help developers create code that’s flexible, reusable, and robust. They are essential in reducing technical debt, maintaining code quality, and ensuring ease of collaboration within teams.
Let’s dive into each principle in detail.
SOLID Principles
The SOLID principles are a collection of five design principles introduced by Robert C. Martin (Uncle Bob) that make software design more understandable, flexible, and maintainable.
S – Single Responsibility Principle (SRP)
Definition: A class should have only one reason to change. Each class should focus on a single responsibility or task.
This principle prevents classes from becoming too complex and difficult to manage. Each class should focus on a single task, making it easy to understand and maintain.
Let’s say we have a class that both processes user data and saves it to a database.
// Violates SRP: Does multiple things
class UserProcessor {
fun processUser(user: User) {
// Logic to process user
}
fun saveUser(user: User) {
// Logic to save user to database
}
}
Solution: Split responsibilities by creating two separate classes.
class UserProcessor {
fun process(user: User) {
// Logic to process user
}
}
class UserRepository {
fun save(user: User) {
// Logic to save user to database
}
}
Now, UserProcessor
only processes users, while UserRepository
only saves them, adhering to SRP.
Let’s consider one more example: suppose we have an Invoice class. If we mix saving the invoice and sending it by email, this class will have more than one responsibility, violating the Single Responsibility Principle (SRP). Here’s how we can fix it:
class Invoice {
fun calculateTotal() {
// Logic to calculate total
}
}
class InvoiceSaver {
fun save(invoice: Invoice) {
// Logic to save invoice to database
}
}
class EmailSender {
fun sendInvoice(invoice: Invoice) {
// Logic to send invoice via email
}
}
Here, the Invoice
class focuses solely on managing invoice data. The InvoiceSaver
class takes care of saving invoices, while EmailSender
handles sending them via email. This separation makes the code easier to modify and test, as each class has a single responsibility.
O – Open/Closed Principle (OCP)
Definition: Classes should be open for extension but closed for modification.
This means that you should be able to add new functionality without changing existing code. In Kotlin, this can often be achieved using inheritance or interfaces.
Imagine we have a Notification
class that sends email notifications. Later, we may need to add SMS notifications.
// Violates OCP: Modifying the class each time a new notification type is needed
class Notification {
fun sendEmail(user: User) {
// Email notification logic
}
}
Solution: Use inheritance to allow extending the notification types without modifying existing code.
interface Notifier {
fun notify(user: User)
}
class EmailNotifier : Notifier {
override fun notify(user: User) {
// Email notification logic
}
}
class SmsNotifier : Notifier {
override fun notify(user: User) {
// SMS notification logic
}
}
In this scenario, the Notifier can be easily extended without changing any existing classes, which perfectly aligns with the Open/Closed Principle (OCP). To illustrate this further, imagine we have a PaymentProcessor class. If we want to introduce new payment types without altering the current code, using inheritance or interfaces would be a smart approach.
interface Payment {
fun pay()
}
class CreditCardPayment : Payment {
override fun pay() {
println("Paid with credit card.")
}
}
class PayPalPayment : Payment {
override fun pay() {
println("Paid with PayPal.")
}
}
class PaymentProcessor {
fun processPayment(payment: Payment) {
payment.pay()
}
}
With this setup, adding a new payment type, such as CryptoPayment, is straightforward. We simply create a new class that implements the Payment interface, and there’s no need to modify the existing PaymentProcessor class. This approach perfectly adheres to the Open/Closed Principle (OCP).
L – Liskov Substitution Principle (LSP)
Note: Many of us misunderstand this concept or do not fully grasp it. Many developers believe that LSP is similar to dynamic polymorphism, but this is not entirely true, as they often overlook the key part of the LSP definition: ‘without altering the correctness of the program.’
Definition: Subtypes must be substitutable for their base types without altering the correctness of the program. This means that if a program uses a base type, it should be able to work with any of its subtypes without unexpected behavior or errors.
The Liskov Substitution Principle (LSP) ensures that subclasses can replace their parent classes while maintaining the expected behavior of the program. Violating LSP can lead to unexpected bugs and issues, as subclasses may not conform to the behaviors defined by their parent classes.
Let’s understand this with an example: Consider a Vehicle
class with a drive
function. If we create a Bicycle
subclass, it might violate LSP because bicycles don’t “drive” in the same way cars do.
// Violates LSP: Bicycle shouldn't be a subclass of Vehicle
open class Vehicle {
open fun drive() {
// Default drive logic
}
}
class Car : Vehicle() {
override fun drive() {
// Car-specific drive logic
}
}
class Bicycle : Vehicle() {
override fun drive() {
throw UnsupportedOperationException("Bicycles cannot drive like cars")
}
fun pedal() {
// Pedal logic
}
}
In this example, Bicycle
violates LSP because it cannot fulfill the contract of the drive
method defined in Vehicle
, leading to an exception when invoked.
Solution: To respect LSP, we can separate the hierarchy into interfaces that accurately represent the behavior of each type. Here’s how we can implement this:
interface Drivable {
fun drive()
}
class Car : Drivable {
override fun drive() {
// Car-specific drive logic
}
}
class Bicycle {
fun pedal() {
// Pedal logic
}
}
Now, Car
implements the Drivable
interface, providing a proper implementation for drive()
. The Bicycle
class does not implement Drivable
, as it doesn’t need to drive. Each class behaves correctly according to its nature, adhering to the Liskov Substitution Principle.
One more thing I want to add: suppose we have an Animal
class and a Bird
subclass.
open class Animal {
open fun move() {
println("Animal moves")
}
}
class Bird : Animal() {
override fun move() {
println("Bird flies")
}
}
In this example, Bird
can replace Animal
without any issues because it properly fulfills the expected behavior of the move
function. When move
is called on a Bird
object, it produces the output “Bird flies,” which is a valid extension of the behavior defined by Animal
.
This illustrates the Liskov Substitution Principle: any class inheriting from Animal
should be able to act like an Animal
, maintaining the expected interface and behavior.
Additional Consideration: To ensure adherence to LSP, all subclasses must conform to the expectations set by the superclass. For example, if another subclass, such as Fish
, is created but its implementation of move
behaves in a way that contradicts the Animal
contract, it would violate LSP.
I — Interface Segregation Principle (ISP)
Definition: Clients should not be forced to depend on methods they do not use. In other words, a class should not be required to implement interfaces it doesn’t need.
ISP suggests creating specific interfaces that are relevant to the intended functionality, rather than forcing a class to implement unnecessary methods.
Think about a real-world software development scenario: if there’s a Worker
interface that requires both code
and test
methods, then a Manager
class would have to implement both—even if all it really does is manage the team.
// Violates ISP: Manager doesn't need to code
interface Worker {
fun code()
fun test()
}
class Developer : Worker {
override fun code() {
// Coding logic
}
override fun test() {
// Testing logic
}
}
class Manager : Worker {
override fun code() {
// Not applicable
}
override fun test() {
// Not applicable
}
}
Solution: Split Worker
into separate interfaces.
interface Coder {
fun code()
}
interface Tester {
fun test()
}
class Developer : Coder, Tester {
override fun code() { /* Coding logic */ }
override fun test() { /* Testing logic */ }
}
class Manager {
// Manager specific logic
}
This way, each class only implements what it actually needs, staying true to the Interface Segregation Principle (ISP).
Here’s another example: let’s say we have a Worker
interface with methods for both daytime and nighttime work.
interface Worker {
fun work()
fun nightWork()
}
class DayWorker : Worker {
override fun work() {
println("Day worker works")
}
override fun nightWork() {
throw UnsupportedOperationException("Day worker doesn't work at night")
}
}
Refactoring to Follow the Interface Segregation Principle (ISP):
interface DayShift {
fun work()
}
interface NightShift {
fun nightWork()
}
class DayWorker : DayShift {
override fun work() {
println("Day worker works")
}
}
By splitting up the interfaces, we make sure that DayWorker
only has the methods it actually needs, keeping the code simpler and reducing the chances of errors.
D — Dependency Inversion Principle (DIP)
Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.
DIP encourages loose coupling by focusing on dependencies being based on abstractions, not on concrete implementations.
Let’s break this down: if OrderService
directly depends on EmailService
, any change in the email logic will also impact OrderService
.
// Violates DIP: Tight coupling between OrderService and EmailService
class EmailService {
fun sendEmail(order: Order) { /* Email logic */ }
}
class OrderService {
private val emailService = EmailService()
fun processOrder(order: Order) {
emailService.sendEmail(order)
}
}
Solution: Use an abstraction for dependency injection to keep things flexible.
interface NotificationService {
fun notify(order: Order)
}
class EmailService : NotificationService {
override fun notify(order: Order) { /* Email logic */ }
}
class OrderService(private val notificationService: NotificationService) {
fun processOrder(order: Order) {
notificationService.notify(order)
}
}
Now, OrderService
depends on NotificationService
, an abstraction, instead of directly depending on the concrete EmailService
.
Here’s another use case: let’s take a look at a simple data fetching mechanism in mobile applications.
interface DataRepository {
fun fetchData(): String
}
class RemoteRepository : DataRepository {
override fun fetchData() = "Data from remote"
}
class LocalRepository : DataRepository {
override fun fetchData() = "Data from local storage"
}
class DataService(private val repository: DataRepository) {
fun getData(): String {
return repository.fetchData()
}
}
By injecting DataRepository
, DataService
depends on an abstraction. We can now easily switch between RemoteRepository
and LocalRepository
.
Other Core Principles
Beyond SOLID, let’s look at three more principles often used to simplify and improve code.
DRY Principle – Don’t Repeat Yourself
Definition: Avoid code duplication. Each piece of logic should exist in only one place. Instead of repeating code, reuse functionality through methods, classes, or functions.
Let’s say we want to calculate discounts in different parts of the application.
fun calculateDiscount(price: Double, discountPercentage: Double): Double {
return price - (price * discountPercentage / 100)
}
// Reuse in other parts
val discountedPrice = calculateDiscount(100.0, 10.0)
Instead of duplicating the discount calculation logic, we use a reusable function. This makes the code cleaner and easier to maintain.
We can see another example where we extract reusable logic in one place and reuse it wherever needed.
// Violates DRY: Repeated logic in each function
fun addUser(name: String, email: String) {
if (name.isNotBlank() && email.contains("@")) {
// Add user logic
}
}
fun updateUser(name: String, email: String) {
if (name.isNotBlank() && email.contains("@")) {
// Update user logic
}
}
Solution: Extract repeated logic into a helper function.
fun validateUser(name: String, email: String): Boolean {
return name.isNotBlank() && email.contains("@")
}
fun addUser(name: String, email: String) {
if (validateUser(name, email)) {
// Add user logic
}
}
fun updateUser(name: String, email: String) {
if (validateUser(name, email)) {
// Update user logic
}
}
Now, the validation logic is centralized, following DRY.
KISS Principle – Keep It Simple, Stupid
Definition: Keep things simple. Avoid overcomplicating things and make the code as easy to understand as possible.
Let’s say we have a function to check if a number is even.
// Overcomplicated
fun isEven(number: Int): Boolean {
return if (number % 2 == 0) true else false
}
// Simplified
fun isEven(number: Int): Boolean = number % 2 == 0
By removing unnecessary logic, we keep the function short and easier to understand.
Let’s look at one more example that violates the KISS principle.
// Violates KISS: Overly complicated logic
fun calculateDiscount(price: Double): Double {
return if (price > 100) {
price * 0.1
} else if (price > 50) {
price * 0.05
} else {
0.0
}
}
Solution: Simplify with clear logic.
fun calculateDiscount(price: Double): Double = when {
price > 100 -> price * 0.1
price > 50 -> price * 0.05
else -> 0.0
}
Here, the when
expression simplifies the code while achieving the same result.
YAGNI Principle – You Aren’t Gonna Need It
Definition: Don’t add functionality until you really need it. Only add features when they’re actually required, not just because you think you might need them later.
Imagine we’re building a calculator and think about adding a sin
function, even though the requirements only need addition and subtraction.
class Calculator {
fun add(a: Int, b: Int): Int = a + b
fun subtract(a: Int, b: Int): Int = a - b
// Avoid adding unnecessary functions like sin() unless required
}
Here, we adhere to YAGNI by only implementing what’s needed. Extra functionality can lead to complex maintenance and a bloated codebase.
Another example of violating YAGNI is when we add functionality to the user manager that we don’t actually need.
// Violates YAGNI: Adding unused functionality
class UserManager {
fun getUser(id: Int) { /* Logic */ }
fun updateUser(id: Int) { /* Logic */ }
fun deleteUser(id: Int) { /* Logic */ }
fun archiveUser(id: Int) { /* Logic */ } // Not required yet
}
Solution: Only implement what is required now.
class UserManager {
fun getUser(id: Int) { /* Logic */ }
fun updateUser(id: Int) { /* Logic */ }
fun deleteUser(id: Int) { /* Logic */ }
}
The archiveUser
method can be added later if needed, that way we’re following the YAGNI principle.
Conclusion
To wrap it up, following design principles like SOLID, DRY, KISS, and YAGNI can make a huge difference in the quality of your code. They help you write cleaner, more maintainable, and less error-prone code, making life easier for you and anyone else who works with it. Kotlin’s clear and expressive syntax is a great fit for applying these principles, so you can keep your code simple, efficient, and easy to understand. Stick to these principles, and your code will be in great shape for the long haul!