Introductie
SOLID is een mnemonisch acroniem voor vijf principes van objectgeoriënteerd ontwerp die zijn geïntroduceerd door Robert C. Martin, die in de volksmond ook wel wordt aangeduid als Uncle Bob. Deze principes zijn bedoeld om softwareontwerpers, architecten, engineers en ontwikkelaars te helpen flexibelere, onderhoudbaardere en schaalbaardere software te maken. Door deze principes te volgen, kun je klassen ontwerpen die eenvoudiger te testen, te refactoren, te hergebruiken en uit te breiden zijn.
Het SOLID-acroniem staat voor:
S – Single Responsibility Principle
O – Open-Closed Principle
L – Liskov Substitution Principle
I – Interface Segregation Principle
D – Dependency Inversion Principle
In dit artikel leggen we elk principe afzonderlijk uit om te begrijpen hoe het je kan helpen betere code te schrijven. Daarnaast voegen we codefragmenten toe voor elk principe om te laten zien hoe je ze kunt toepassen in je programmeertraject, en wat je moet vermijden in een clean code-architectuur. Om de concepten te demonstreren, maken we gebruik van de Kotlin-programmeertaal ontwikkeld door JetBrains en open-source bijdragers.
Single Responsibility Principle
Het Single Responsibility Principle (SRP) is een softwareontwerpprincipe dat stelt dat elke klasse of module in een programma een enkele, goed gedefinieerde verantwoordelijkheid moet hebben. Dit betekent dat een klasse slechts één reden mag hebben om te veranderen, en dat deze verantwoordelijk moet zijn voor slechts een enkel deel van de functionaliteit van het programma.
Beschouw het volgende codefragment als een voorbeeld van hoe dit principe kan worden toegepast in Kotlin:
|
1 2 3 4 5 6 7 8 9 |
data class User( val firstName: String, val lastName: String, val email: String ) |
Het data-sleutelwoord in Kotlin geeft aan dat deze klasse een data-klasse is, wat betekent dat deze bedoeld is om gegevens te bevatten en geen complex gedrag vertoont. Met behulp van deze data-klasse kunnen we een UserService -klasse maken voor het beheren van gebruikers, zoals hieronder weergegeven:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // code to create a user in the database } fun deleteUser(user: User) { // code to delete a user from the database } fun updateUser(user: User) { // code to update a user in the database } fun getUser(id: Int): User { // code to retrieve a user from the database } } |
In dit voorbeeld heeft de UserService-klasse een enkele verantwoordelijkheid: het beheren van gebruikers in een database. Elke gebruiker wordt vertegenwoordigd door de data class User -code die eerder is gedeeld. Alle methoden in de UserService-klasse zijn gerelateerd aan deze verantwoordelijkheid en zijn daarom coherent. Dit maakt de klasse gemakkelijker te begrijpen en te onderhouden, omdat het duidelijk is dat alle methoden in de klasse gerelateerd zijn aan een enkele, goed gedefinieerde taak.
Een methode die het Single Responsibility Principle (SRP) schendt in de UserService-klasse zou een methode zijn die niet gerelateerd is aan de primaire verantwoordelijkheid van de klasse, namelijk het beheren van gebruikers in een database. Neem bijvoorbeeld de volgende variatie van de UserService-klasse:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // code om een gebruiker in de database aan te maken } fun deleteUser(user: User) { // code om een gebruiker uit de database te verwijderen } fun updateUser(user: User) { // code om een gebruiker in de database bij te werken } fun getUser(id: Int): User { // code om een gebruiker uit de database op te halen } fun sendEmail(user: User, subject: String, message: String) { // code om een e-mail naar de gebruiker te sturen } } |
In dit voorbeeld heeft de sendEmail methode geen betrekking op de primaire verantwoordelijkheid van de UserService class, namelijk het beheren van gebruikers in een database. Deze methode is verantwoordelijk voor het verzenden van e-mails, wat een afzonderlijke verantwoordelijkheid is van het beheren van gebruikers. Als gevolg hiervan schendt deze methode het SRP, omdat het een tweede reden introduceert voor de UserService-klasse om te veranderen.
Om te voldoen aan het Single Responsibility Principle, zou het beter zijn om de functionaliteit voor het verzenden van e-mails te scheiden in een aparte klasse, zoals een EmailService klasse. Dit zou de UserService klasse in staat stellen zich te concentreren op haar primaire verantwoordelijkheid van het beheren van gebruikers, en de EmailService klasse om zich te concentreren op haar verantwoordelijkheid voor het verzenden van e-mails.
Merk op dat het Single Responsibility Principle niet gaat over het aantal methoden dat een klasse heeft, maar eerder over de samenhang van de methoden en de duidelijke scheiding van verantwoordelijkheden binnen een klasse.
Open-Closed Principle
Het Open-Closed Principle (OCP) is een ontwerpprincipe voor software dat stelt dat software-entiteiten (zoals klassen, modules of functies) open moeten staan voor uitbreiding, maar gesloten moeten zijn voor aanpassing. Dit betekent dat het mogelijk moet zijn om nieuwe functionaliteit aan een klasse of module toe te voegen zonder de bestaande code te wijzigen.
Neem de UserService klasse uit het vorige voorbeeld. Stel dat we een nieuwe functie willen toevoegen aan de UserService klasse waarmee we naar gebruikers kunnen zoeken op e-mailadres. Een manier om dit te doen is door een nieuwe methode toe te voegen aan de UserService klasse, zoals gemarkeerd in de onderstaande code:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // code om een gebruiker in de database aan te maken } fun deleteUser(user: User) { // code om een gebruiker uit de database te verwijderen } fun updateUser(user: User) { // code om een gebruiker in de database bij te werken } fun getUser(id: Int): User { // code om een gebruiker uit de database op te halen } fun searchUsersByEmail(email: String): List<User> { // code om te zoeken naar gebruikers op e-mailadres } } |
Deze aanpak werkt, maar schendt het Open-Closed Principle, omdat we de bestaande UserService klasse moesten aanpassen om de nieuwe functie toe te voegen. Een betere aanpak zou zijn om overerving of compositie te gebruiken om de functionaliteit van de UserService klasse uit te breiden zonder de code ervan te wijzigen.
Om dit te bereiken, zouden we een nieuwe klasse kunnen maken genaamd UserSearchService die de UserService klasse uitbreidt en de e-mailzoekfunctionaliteit toevoegt:
|
1 2 3 4 5 6 7 8 9 |
class UserSearchService(private val userService: UserService) : UserService() { fun searchUsersByEmail(email: String): List<User> { // code om te zoeken naar gebruikers op e-mailadres } } |
In dit voorbeeld staat de UserSearchService klasse open voor uitbreiding, omdat deze extra functionaliteit biedt die verder gaat dan wat wordt aangeboden door de UserService class. Tegelijkertijd blijft de UserService class gesloten voor wijziging, omdat we de code ervan niet hoefden aan te passen om de e-mailzoekfunctie toe te voegen.
Liskov-substitutieprincipe
Het Liskov-substitutieprincipe (LSP) is een softwareontwerpprincipe dat stelt dat objecten van een superklasse vervangen moeten kunnen worden door objecten van een subklasse zonder de correctheid van het programma te beïnvloeden. Dit betekent dat een subklasse een geldige vervanger moet zijn voor zijn superklasse, en zich op dezelfde manier moet gedragen als de superklasse wanneer deze in dezelfde context wordt gebruikt.
We vervolgen de demonstratie met de User en de UserService klassen uit de vorige voorbeelden. Om de User-klasse uitbreidbaar te maken, gebruiken we het open sleutelwoord in Kotlin:
|
1 2 3 4 5 6 7 8 9 |
open class User( val firstName: String, val lastName: String, val email: String ) |
Hier is de originele UserService klasse die de bovenstaande User data-klasse gebruikt:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // code om een gebruiker in de database aan te maken } fun deleteUser(user: User) { // code om een gebruiker uit de database te verwijderen } fun updateUser(user: User) { // code om een gebruiker in de database bij te werken } fun getUser(id: Int): User { // code om een gebruiker uit de database op te halen } } |
Stel dat we een subklasse van User willen maken genaamd AdminUser die gebruikers met administratieve rechten vertegenwoordigt. Dit zouden we als volgt kunnen doen:
|
1 2 3 4 5 6 7 8 9 |
class AdminUser( firstName: String, lastName: String, email: String, ) : User(firstName, lastName, email) |
In dit voorbeeld is de AdminUser klasse een geldige vervanger voor de User klasse, aangezien deze zich op dezelfde manier gedraagt als de User klasse en overal kan worden gebruikt waar een User object wordt verwacht. We kunnen bijvoorbeeld de AdminUser klasse als volgt gebruiken met de UserService klasse:
|
1 2 3 4 5 6 7 8 9 |
fun main() { val userService = UserService() val adminUser = AdminUser("John", "Doe", "john.doe@example.com") userService.createUser(adminUser) } |
Deze code is correct, aangezien de AdminUser klasse een geldige vervanger is voor de User klasse en op dezelfde manier kan worden gebruikt als een User object.
Het is belangrijk om op te merken dat het Liskov-substitutieprincipe over meer gaat dan alleen overerving. Het gaat erom ervoor te zorgen dat objecten van een subklasse zich op dezelfde manier gedragen als objecten van de superklasse, ongeacht hoe de subklasse is geïmplementeerd. Bijvoorbeeld, als de AdminUser klasse zich op een of andere manier anders zou gedragen dan de User klasse, zou dit het Liskov-substitutieprincipe schenden, aangezien het geen geldige vervanger zou zijn voor de User klasse.
Interface-segregatieprincipe
Het Interface-segregatieprincipe (ISP) is een softwareontwerpprincipe dat stelt dat clients niet gedwongen moeten worden om afhankelijk te zijn van interfaces die ze niet gebruiken. Dit betekent dat het over het algemeen een goed idee is om kleine, gerichte interfaces te maken die één ding goed doen, in plaats van het maken van grote, algemene interfaces die veel dingen proberen te doen.
Hier is een voorbeeld van hoe dit principe kan worden toegepast door het herschrijven van de User en UserService klassen uit de vorige voorbeelden:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User } class DatabaseUserService : UserService { override fun createUser(user: User) { // code om een gebruiker in de database aan te maken } override fun deleteUser(user: User) { // code om een gebruiker uit de database te verwijderen } override fun updateUser(user: User) { // code om een gebruiker in de database bij te werken } override fun getUser(id: Int): User { // code om een gebruiker uit de database op te halen } } |
In dit voorbeeld definieert de UserService interface vier methoden die gerelateerd zijn aan het beheren van gebruikers in een database. De DatabaseUserService klasse implementeert deze interface en biedt concrete implementaties voor deze methoden.
Suppose we want to add a new feature to the UserService interface that allows us to search for users by email address. One way to do this would be to add a new method to the UserService interface:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User fun searchUsersByEmail(email: String): List<User> } |
Je code zal niet werken, tenzij je deze methode ook implementeert in alle klassen die de UserService interface implementeren:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService { override fun createUser(user: User) { // code om een gebruiker in de database aan te maken } override fun deleteUser(user: User) { // code om een gebruiker uit de database te verwijderen } override fun updateUser(user: User) { // code om een gebruiker in de database bij te werken } override fun getUser(id: Int): User { // code om een gebruiker uit de database op te halen } override fun searchUsersByEmail(email: String): List<User> { // code om te zoeken naar gebruikers op e-mailadres } } |
Hoewel deze aanpak werkt, schendt het het Interface Segregation Principle, omdat het de DatabaseUserService klasse dwingt om een methode te implementeren ( searchUsersByEmail ) die het misschien niet nodig heeft of gebruikt.
Een betere aanpak zou zijn om een aparte interface te maken voor de e-mailzoekfunctionaliteit:
|
1 2 3 4 5 |
interface UserSearchService { fun searchUsersByEmail(email: String): List<User> } |
Nu hebben we aparte, kleine en gerichte interfaces, d.w.z. UserService en UserSearchServicedie een enkele verantwoordelijkheid hebben. Een klasse die alle functionaliteiten van deze interfaces vereist, kan deze implementeren zoals getoond in het onderstaande codefragment:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService, UserSearchService { override fun createUser(user: User) { // code om een gebruiker in de database aan te maken } override fun deleteUser(user: User) { // code om een gebruiker uit de database te verwijderen } override fun updateUser(user: User) { // code om een gebruiker in de database bij te werken } override fun getUser(id: Int): User { // code om een gebruiker uit de database op te halen } override fun searchUsersByEmail(email: String): List<User> { // code om te zoeken naar gebruikers op e-mailadres } } |
Dit voldoet aan het Interface Segregation Principle, omdat het ervoor zorgt dat clients (zoals de DatabaseUserService klasse) niet worden gedwongen om afhankelijk te zijn van interfaces die ze niet gebruiken.
Om dit concept beter te begrijpen, stel dat we een andere klasse hebben genaamd MemoryUserService die de UserService interface implementeert maar de e-mailzoekfunctionaliteit niet nodig heeft, dan kunnen we de code als volgt schrijven:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserService : UserService { private val users = mutableListOf<User>() override fun createUser(user: User) { users.add(user) } override fun deleteUser(user: User) { users.remove(user) } override fun updateUser(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun getUser(id: Int): User { return users[id] } } |
In dit voorbeeld hoeft de MemoryUserService klasse alleen de methoden te implementeren die zijn gedefinieerd in de UserService interface, en hoeft zich geen zorgen te maken over de e-mailzoekfunctionaliteit. Dit stelt de MemoryUserService klasse in staat om zich te concentreren op haar primaire verantwoordelijkheid van het beheren van gebruikers in het geheugen, in plaats van gedwongen te worden om niet-gerelateerde functionaliteit te implementeren.
Dependency Inversion Principle
Het Dependency Inversion Principle (DIP) is een softwareontwerpprincipe dat stelt dat high-level modules niet afhankelijk moeten zijn van low-level modules, maar dat beide afhankelijk moeten zijn van abstracties. Dit betekent dat het over het algemeen een goed idee is om uw software zo te ontwerpen dat high-level componenten niet gebonden zijn aan specifieke implementaties van low-level componenten, maar in plaats daarvan afhankelijk zijn van abstracties (zoals interfaces of abstracte klassen) die op verschillende manieren kunnen worden geïmplementeerd.
Laten we eens kijken naar een voorbeeld van hoe dit principe kan worden toegepast op de User en UserService klassen die in eerdere codefragmenten zijn gebruikt:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserRepository { fun create(user: User) fun delete(user: User) fun update(user: User) fun get(id: Int): User } class UserService(private val repository: UserRepository) { fun createUser(user: User) { repository.create(user) } fun deleteUser(user: User) { repository.delete(user) } fun updateUser(user: User) { repository.update(user) } fun getUser(id: Int): User { return repository.get(id) } } |
In dit voorbeeld is de UserService klasse afhankelijk van een abstractie genaamd de UserRepository interface, in plaats van afhankelijk te zijn van een specifieke implementatie van een user repository. Dit stelt ons in staat om de UserRepository interface op verschillende manieren te implementeren, zoals met een database of in-memory opslag, zonder dat dit invloed heeft op de UserService klasse.
Hier is bijvoorbeeld een implementatie van de UserRepository interface die een database gebruikt:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class DatabaseUserRepository: UserRepository { override fun create(user: User) { // code om een gebruiker aan te maken in de database } override fun delete(user: User) { // code om een gebruiker te verwijderen uit de database } override fun update(user: User) { // code om een gebruiker bij te werken in de database } override fun get(id: Int): User { // code om een gebruiker op te halen uit de database } } |
Hier is een andere implementatie van de UserRepository interface die in-memory opslag gebruikt:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserRepository : UserRepository { private val users = mutableListOf<User>() override fun create(user: User) { users.add(user) } override fun delete(user: User) { users.remove(user) } override fun update(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun get(id: Int): User { return users[id] } } |
Dit maakt het systeem flexibeler en beter te onderhouden, omdat het ons in staat stelt de repository-implementatie te wijzigen zonder de rest van het systeem te beïnvloeden. Het maakt het ook gemakkelijker om de UserService-klasse te testen, omdat we de UserRepository-afhankelijkheid in onze tests kunnen mocken.
Conclusie
In dit artikel hebben we de vijf principes besproken van SOLID code en gedeelde codefragmenten die aan elk principe voldoen. Het naleven van deze principes kan je helpen softwaresystemen te ontwerpen die flexibeler, onderhoudbaarder en schaalbaarder zijn. Het is echter belangrijk om in gedachten te houden dat deze principes richtlijnen zijn, in plaats van harde regels, en het is aan de ontwikkelaar om te beslissen wanneer en hoe deze toe te passen in de context van hun specifieke project. Blijf leren door onze blog te bekijken voor meer diepgaande en actuele artikelen en tutorials over cloud computing en DevOps, softwareontwerp en -ontwikkeling, technologische trends om in de gaten te houden, en meer.
Veel codeerplezier!
Reacties
Nog geen reacties. Wees de eerste.