Drawing from my experience as a first-year undergraduate student and from listening to the stories shared by upperclassmen, schools and colleges focus on teaching programming and essential mathematics, such as Discrete Mathematics and Calculus. However, once you graduate and step into the professional world, there are certain concepts and principles that are crucial for a smoother transition. We will discussing about KISS, DRY and SOLID principles.
KISS Principle
Keep It Simple, Stupid!
Keep It Simple, Stupid!
It's common to end up collaborating within a team, where each developer tackles various parts of the project. Imagine you need to jump in and work on code someone else wrote. Would you prefer to deal with a tangled mess that lacks comments and clear variable names? Or would you rather see well-documented code that clearly outlines that section of the project? Obviously, the latter option is much more appealing.
Imagine you're working on a highly intricate program and then come down with an illness. After taking a week off to recuperate, you return to your project. Naturally, you might lose your coding momentum. Would you prefer to come back to a codebase that's now a mystery to you, or to one that you can at least partially grasp right away? Clearly, the latter is more appealing.
Picture yourself tackling a project solo, putting in daily efforts. Naturally, you'll come across bugs that necessitate debugging. Just think about how much more challenging it would be to debug if your code is unnecessarily complex.
All of these situations highlight one important principle—Keep It Simple, Stupid!
"Martin Fowler once said, “Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”"
"Martin Fowler once said, 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.'"
When all is said and done, the machine couldn't care less if your code is straightforward or intricate for performing a specific task. However, for humans, including yourself, who will read and interpret the code, it makes a significant difference.
But how do I KISS? (I know, it sounds a bit strange)
Take a look at a model class called Student. This class will contain two attributes and a map where both the keys and values are pairs. The initial pair consists of two strings: the name and ID of the module. The second pair is made up of two double values: the marks obtained and the maximum possible marks for that module.
data class Student(
val name: String,
val age: Int,
val moduleMarks: Map<Pair<String, String>, Pair<Int, Int>>
)
Once you've decided on this design, it's essential to document the students' names along with the modules where they achieved scores above 80%.
fun scholars(students: List<Student>): Map<String, List<String>> {
val scholars = mutableMapOf<String, List<String>>()
students.forEach { student ->
scholars[student.name] = student.moduleMarks
.filter { (_, (a, m)) -> a / m > 0.8}
.map { ((n, _), _) -> n }
}
return scholars
}
Returning to code like this, even after just a few days, could be a nightmare. While you might point out that this was a relatively straightforward example, there are still methods to simplify it. Try incorporating more abstractions and variables whenever possible.
data class Student(
val name: String,
val age: Int,
val moduleMarks: Map<Module, Mark>
)
data class Module(
val name: String,
val id: String
)
data class Mark(
val achieved: Double,
val maximum: Double
) {
fun isAbove(percentage: Double): Boolean {
return achieved / maximum * 100 > percentage
}
fun scholars(students: List<Student>): Map<String, List<String>> {
val scholars = mutableMapOf<String, List<String>>()
students.forEach { student ->
val modulesAbove80 = student.moduleMarks
.filter { (_, mark) -> mark.isAbove(80.0)}
.map { (module, _) -> module.name }
scholars[student.name] = modulesAbove80
}
return scholars
}
This introduces a considerable amount of code. However, more crucially, the code appears neater and reads much like English.
DRY Principle
Don’t Repeat Yourself
Don’t Repeat Yourself
If you discover that you're repeatedly writing the same code, consider creating a function to reuse it. During one of my university assignments, I was dealing with a collection of objects (cells). Almost all the functions I defined needed to locate and retrieve a specific object from this collection in order to perform operations on it.
public class Spreadsheet implements BasicSpreadsheet {
private final Set<Cell> cells;
@Override
public double getCellValue(CellLocation location) {
Cell cell = cells.stream()
.filter(cell -> cell.location.equals(location))
.findFirst()
.orElse(null);
return cell == null ? 0d : cell.getValue();
}
@Override
public String getCellExpression(CellLocation location) {
Cell cell = cells.stream()
.filter(cell -> cell.location.equals(location))
.findFirst()
.orElse(null);
return cell == null ? "" : cell.getExpression();
}
@Override
public void setCellExpression(CellLocation location, String input) throws InvalidSyntaxException {
Cell cell = cells.stream()
.filter(cell -> cell.location.equals(location))
.findFirst()
.orElse(null);
// ...
}
// ...
}
That's quite a chunk of code up there. As I was writing it, I noticed I kept copying and pasting the same segments in various places. So, I decided to simplify things by extracting those repetitive blocks into functions, making them reusable throughout the code.
public class Spreadsheet implements BasicSpreadsheet {
private final Set<Cell> cells;
@Override
public double getCellValue(CellLocation location) {
return getFromCell(location, Cell::getValue, 0d);
}
@Override
public String getCellExpression(CellLocation location) {
return getFromCell(location, Cell::getExpression, "");
}
@Override
public void setCellExpression(CellLocation location, String input) throws InvalidSyntaxException {
Cell cell = findCell(location);
// ...
}
// ...
private Cell findCell(CellLocation location) {
return cells.stream()
.filter(cell -> cell.location.equals(location))
.findFirst()
.orElse(null);
}
private <T> T getFromCell(CellLocation location,
Function<Cell, T> function,
T defaultValue) {
Cell cell = findCell(location);
return cell == null ? defaultValue : function.apply(cell);
}
}
This approach means that if I find a bug in my code, I don't have to modify it in multiple locations. A single change within the function is sufficient to resolve the issue across the board.
SOLID Principles
These are not just one principle, but actually five principles that are essential for software development.
S — Single Responsibility
A class ought to have a single reason to be modified and no more.
A class ought to have a single, unique reason for modification.
One of the simplest concepts to grasp is this: any class or function you create should have a single responsibility. Imagine you're developing a networking application.
class Repository(
private val api: MyRemoteDatabase,
private val local: MyLocalDatabase
) {
fun fetchRemoteData() = flow {
// Fetching API data
val response = api.getData()
// Saving data in the cache
var model = Model.parse(response.payload)
val success = local.addModel(model)
if (!success) {
emit(Error("Error caching the remote data"))
return@flow
}
// Returning data from a single source of truth
model = local.find(model.key)
emit(Success(model))
}
}
The code mentioned above breaches the Single Responsibility Principle. The function not only retrieves data from a remote source but also handles storing that data locally. These responsibilities should be separated into a different class.
class Repository(
private val api: MyRemoteDatabase,
private val cache: MyCachingService /* Notice I changed the dependency */
) {
fun fetchRemoteData() = flow {
// Fetching API data
val response = api.getData()
val model = cache.save(response.payload)
// Sending back the data
model?.let {
emit(Success(it))
} ?: emit(Error("Error caching the remote data"))
}
}
// Shifted all caching logic to another class
class MyCachingService(
private val local: MyLocalDatabase
) {
suspend fun save(payload: Payload): Model? {
var model = Model.parse(payload)
val success = local.addModel(model)
return if (success)
local.find(model.key)
else
null
}
}
Observe how MyCachingService
is solely focused on saving the incoming payload to the local database, while the repository’s role is exclusively to fetch the data and then send the model mentioned earlier. Adopting this approach is beneficial due to a concept called separation of concerns, which enhances both debugging and testability.
O — Open/Closed
Software components like classes, modules, and functions ought to be designed in a way that allows them to be extended without the need for modifying their existing code.
Software entities, such as classes, modules, and functions, need to be designed so they can accommodate extensions while avoiding the need for modifications.
This principle essentially suggests not to write software code that will, in future updates, cause issues with the client-side code. Imagine you are creating a web development API using Kotlin. You have already designed the ParagraphTag, the AnchorTag, and the ImageTag. In your code, you need to compare the heights of two elements.
class ParagraphTag(
val width: Int,
val height: Int
)
class AnchorTag(
val width: Int,
val height: Int
)
class ImageTag(
val width: Int,
val height: Int
)
// Client-code
infix fun ParagraphTag.tallerThan(anchor: AnchorTag): Boolean {
return this.height > anchor.height
}
infix fun AnchorTag.tallerThan(anchor: ParagraphTag): Boolean {
return this.height > anchor.height
}
infix fun ParagraphTag.tallerThan(anchor: ImageTag): Boolean {
return this.height > anchor.height
}
// ... more functions
Sigh! That was quite a bit of effort. Now, there's a new requirement to include a Heading tag too. This means you'll need to add six additional functions on the client-side. Not only is this a tedious task, but you are also modifying the client-side code to meet your program’s needs.
Rather, define an interface — PageTag
.
interface PageTag {
val width: Int
val height: Int
}
class ParagraphTag(
override val width: Int,
override val height: Int
) : PageTag
class AnchorTag(
override val width: Int,
override val height: Int
) : PageTag
class ImageTag(
override val width: Int,
override val height: Int
) : PageTag
// Client Code
infix fun PageTag.tallerThan(other: PageTag): Boolean {
return this.height > other.height
}
You have now closed the client code to prevent any further modifications. To extend your functionality, it's open for you to create a new class and implement PageTag
, ensuring that everything functions seamlessly.
L — Liskov Substitution
If S is a subtype of T, any characteristics that can be confirmed for T should also be confirmable for S.
If S is a subtype of T, then any characteristics that can be demonstrated for T must also be demonstrable for S.
Oh. Maths? This isn't great. On the other hand, this concept is quite simple to grasp. Let’s look at a fresh example.
open class Bird {
open fun fly() {
// ... performs code to fly
}
open fun eat() {
// ...
}
}
class Penguin : Bird() {
override fun fly() {
throw UnsupportedOperationException("Penguins cannot fly")
}
}
Observe how the Bird
class above doesn't trigger any exceptions, whereas the Penguin
class does. You can't substitute Penguin for Bird in your client code without causing it to break or needing to modify it. This is a breach of the Liskov Substitution principle. By having Penguin
extend Bird
, it disrupts the client-side code, which also violates the open/closed principle.
One solution is to adjust how you implement your design.
open class FlightlessBird {
open fun eat() {
// ...
}
}
open class Bird : FlightlessBird() {
open fun fly() {
// ...
}
}
class Penguin : FlightlessBird() {
// ...
}
class Eagle : Bird() {
// ...
}
The code above demonstrates that if a FlightlessBird
has the ability to eat, then every subclass that extends from FlightlessBird
will also be able to eat. In a similar way, if a Bird
can fly, then it is required that all subclasses of Bird
should also have the capability to fly.
I — Interface Segregation
Interfaces shouldn't compel their clients to rely on methods that they don't utilize.
Interfaces should avoid making their clients rely on methods that are unnecessary for them.
This definition really isn't intimidating. Think about it: you're constructing a car, an airplane, and a bicycle. Because they're all types of vehicles, you're essentially working with the Vehicle interface.
interface Vehicle {
fun turnOn()
fun turnOff()
fun drive()
fun fly()
fun pedal()
}
class Car : Vehicle {
override fun turnOn() { /* Implementation */ }
override fun turnOff() { /* Implementation */ }
override fun drive() { /* Implementation */ }
override fun fly() = Unit
override fun pedal() = Unit
}
class Aeroplane : Vehicle {
override fun turnOn() { /* Implementation */ }
override fun turnOff() { /* Implementation */ }
override fun drive() = Unit
override fun fly() { /* Implementation */ }
override fun pedal() = Unit
}
class Bicycle : Vehicle {
override fun turnOn() = Unit
override fun turnOff() = Unit
override fun drive() = Unit
override fun fly() = Unit
override fun pedal() { /* Implementation */ }
}
Yuck! Notice how the classes are being forced to include methods they don't even need? Plus, I can't make the classes abstract either. According to the Interface Segregation Principle, we should opt for a different design.
interface SystemRunnable {
fun turnOn()
fun turnOff()
}
interface Drivable() {
fun drive()
}
interface Flyable() {
fun fly()
}
interface Pedalable() {
fun pedal()
}
class Car : SystemRunnable, Drivable {
override fun turnOn() { /* Implementation */ }
override fun turnOff() { /* Implementation */ }
override fun drive() { /* Implementation */ }
}
class Aeroplane : SystemRunnable, Flyable {
override fun turnOn() { /* Implementation */ }
override fun turnOff() { /* Implementation */ }
override fun fly() { /* Implementation */ }
}
class Bicycle : Pedalable {
override fun pedal() { /* Implementation */ }
}
This approach makes everything look much tidier, and it simplifies the process of referencing various capabilities by their interfaces.
D — Dependency Inversion
1. Top-tier modules shouldn't rely on lower-tier modules; instead, both should rely on abstractions. 2. Abstractions mustn't depend on specifics. Instead, specifics should be based on abstractions.
1. High-level modules and low-level modules should both rely on abstractions rather than each other.
2. Abstractions shouldn't rely on specifics. Instead, specifics should align with abstractions.
What does that even mean? High-level modules are the ones that the business or the UI interacts with. Low-level modules take care of the application's detailed operations. Think back to my example from the Solid Responsibility Principle:
class Repository(
private val api: MyRemoteDatabase,
private val cache: MyCachingService
) {
fun fetchRemoteData() = flow {
// Fetching API data
val response = api.getData()
val model = cache.save(response.payload)
// Sending back the data
model?.let {
emit(Success(it))
} ?: emit(Error("Error caching the remote data"))
}
}
class MyRemoteDatabase {
suspend fun getData(): Response { /* ... */ }
}
class MyCachingService(
private val local: MyLocalDatabase
) {
suspend fun save(): Model? { /* ... */ }
}
class MyLocalDatabase {
suspend fun add(model: Model): Boolean { /* ... */ }
suspend fun find(key: Model.Key): Model { /* ... */ }
}
It seems fine and it should function without any issues. However, should I opt to switch my Local database from PostgreSql to MongoDB in the future, or decide to revamp my caching mechanism entirely, I would need to overhaul all the implementation specifics, including the client-side code. The high-level modules are currently reliant on the low-level concrete modules.
This approach is incorrect. What you should do is encapsulate the functionality within an interface and create concrete classes that implement this interface.
interface CachingService {
suspend fun save(): Model?
}
interface SomeLocalDb() {
suspend fun add(model: Model): Boolean
suspend fun find(key: Model.Key): Model
}
class Repository(
private val api: SomeRemoteDb,
private val cache: CachingService
) { /* Implementation */ }
class MyCachingService(
private val local: SomeLocalDb
) : CachingService { /* Implement methods */ }
class MyAltCachingService(
private val local: SomeLocalDb
) : CachingService { /* Implement methods */ }
class PostgreSQLLocalDb : SomeLocalDb { /* Implement methods */ }
class MongoLocalDb : SomeLocalDb { /* Implement methods */ }
With just a single word, you can seamlessly switch between various implementations for your repository across your whole application. It never fails to give me chills whenever I hear about it.