Wat is het structureren van code?
Het structureren van code gaat over de interne organisatie van de code zelf en hoe de logica binnen het project georganiseerd is. Dit omvat de verdeling van de code binnen klassen of functies, zoals het gebruik van objectgeoriënteerde of functionele principes om de code binnen bestanden of modules te organiseren. Daarnaast speelt herbruikbaarheid en de scheiding van verantwoordelijkheden een belangrijke rol. Het doel is om modulaire code te creëren die eenvoudig te testen en te onderhouden is. Dit kan betekenen dat je ervoor zorgt dat de code gescheiden wordt in verschillende componenten of modules. Ook de samenwerking tussen de verschillende lagen van de code is van belang, bijvoorbeeld hoe de interactie tussen de presentatie- en logica-laag wordt beheerd of hoe externe systemen in de code worden geïntegreerd.
Modulariteit
Op niveau 2 gaan we in op modulariteit. Modulariteit is een ontwerpprincipe waarbij de code wordt opgesplitst in kleinere, onafhankelijke eenheden, zoals klassen, functies of modules. Deze eenheden moeten duidelijke en eenduidige verantwoordelijkheden hebben. Het doel van modulariteit is om de code gemakkelijk te begrijpen, testen, onderhouden en hergebruiken.
Single Responsiblitiy
Een belangrijk concept binnen modulariteit is het Single Responsibility Principle (SRP), dat stelt dat elke klasse of functie slechts één taak of verantwoordelijkheid zou moeten hebben. Dit betekent dat een klasse of functie niet te veel verschillende dingen moet doen, maar zich moet concentreren op een specifiek aspect van de applicatie.
Casus
Tom werkte aan een applicatie voor het beheren van klanten. In het begin had hij weinig tijd, dus besloot hij een snelle oplossing te schrijven. Hij maakte één grote klasse genaamd
CustomerManager
, die verantwoordelijk was voor alles: het ophalen van klantgegevens, het valideren van de gegevens, het opslaan van de klant in de database, en zelfs het verzenden van bevestigingsmails.De
CustomerManager
klasse groeide snel. Er zaten functies in voor het ophalen van klantgegevens van een externe API, voor het controleren van de geldigheid van e-mailadressen, voor het bijwerken van klantinformatie in de database en voor het versturen van een welkomstmail. Tom dacht dat het prima werkte, totdat hij merkte dat het steeds moeilijker werd om de klasse te testen en bij te werken. Elke keer als er een wijziging werd aangebracht in de validatie, moest hij zich zorgen maken dat de API-oproep of de e-mailfunctionaliteit daardoor zou breken.Op een dag vroeg een collega, Lisa, om een nieuwe feature: de klantgegevens moesten niet alleen naar de database worden geschreven, maar ook naar een externe CRM-software worden gestuurd. Tom begon aan de wijziging, maar realiseerde zich al snel dat de
CustomerManager
klasse weer te veel verschillende verantwoordelijkheden had. Als hij de CRM-integratie toevoegde, zou de klasse nog groter worden, en het zou nog moeilijker zijn om alles te testen en te onderhouden.Daarom besloot Tom de code te refactoren volgens het Single Responsibility Principle (SRP). Hij deelde de
CustomerManager
klasse op in kleinere, meer gerichte klassen:
- CustomerFetcher: Verantwoordelijk voor het ophalen van klantgegevens van de externe API.
- CustomerValidator: Verantwoordelijk voor het valideren van klantgegevens, zoals het controleren van e-mailadressen.
- CustomerSaver: Verantwoordelijk voor het opslaan van klantgegevens in de database.
- CustomerEmailSender: Verantwoordelijk voor het verzenden van bevestigingsmails naar de klant.
- CustomerCRMUpdater: Verantwoordelijk voor het versturen van klantgegevens naar de externe CRM-software.
Elke klasse had nu slechts één verantwoordelijkheid. Dit maakte de code veel gemakkelijker te begrijpen, en Tom kon nu elke module afzonderlijk testen. Bovendien was het eenvoudiger om nieuwe functionaliteit toe te voegen zonder dat de bestaande code werd beïnvloed. Als de validatie- of e-mailfunctionaliteit ooit moest worden aangepast, zou dat nu op een veilige manier kunnen gebeuren zonder andere delen van de applicatie te breken.
Door de code te refactoren naar een SRP-gebaseerde structuur was de applicatie veel flexibeler, beter onderhoudbaar en gemakkelijker te testen. Tom had geleerd dat hoewel het aanvankelijk sneller was om alles in één klasse te stoppen, het uiteindelijk veel meer tijd en moeite zou kosten om de code schaalbaar en robuust te houden zonder modulariteit en SRP.
Hoe zit het structureren van code in elkaar?
Wanneer je het Single Responsibility Principle (SRP) wilt toepassen, zijn er verschillende belangrijke aspecten om rekening mee te houden:
1. Identificeren van verantwoordelijkheden
- Wat is de primaire taak van een klasse of functie?
- Kijk naar de huidige code en bepaal welke taken of verantwoordelijkheden de klasse of functie uitvoert. Als je merkt dat de klasse meerdere verschillende zaken regelt, is dat een aanwijzing dat de code refactored moet worden.
- Bijvoorbeeld: Als een klasse zowel klantgegevens haalt uit een database als klantgegevens valideert, dan heeft die klasse meerdere verantwoordelijkheden en zou je deze moeten splitsen.
Ongeldige implementatie (meerdere verantwoordelijkheden):
public class CustomerService
{
public void AddCustomer(Customer customer)
{
// Voeg klant toe
// Verwerkt klantgegevens
SaveCustomerToDatabase(customer);
SendWelcomeEmail(customer);
}
private void SaveCustomerToDatabase(Customer customer)
{
// Logica voor opslaan
}
private void SendWelcomeEmail(Customer customer)
{
// Logica voor het verzenden van een e-mail
}
}
Geldige implementatie (verantwoordelijkheden gescheiden):
public class CustomerService
{
private readonly ICustomerRepository _customerRepository;
private readonly IEmailService _emailService;
public CustomerService(ICustomerRepository customerRepository, IEmailService emailService)
{
_customerRepository = customerRepository;
_emailService = emailService;
}
public void AddCustomer(Customer customer)
{
_customerRepository.Save(customer);
_emailService.SendWelcomeEmail(customer);
}
}
public interface ICustomerRepository
{
void Save(Customer customer);
}
public interface IEmailService
{
void SendWelcomeEmail(Customer customer);
}
2. Minimaliseren van afhankelijkheden
- Zorg ervoor dat de klassen niet te afhankelijk zijn van andere klassen. Dit maakt het makkelijker om de code in de toekomst te wijzigen zonder dat het hele systeem wordt beïnvloed.
- Een klasse moet bijvoorbeeld niet verantwoordelijk zijn voor het beheren van een databaseverbinding én voor het versturen van e-mails. Deze verantwoordelijkheden moeten worden gescheiden.
Ongeldige implementatie (directe afhankelijkheid):
public class OrderService
{
private readonly PaymentService _paymentService;
public OrderService()
{
_paymentService = new PaymentService(); // Directe afhankelijkheid
}
public void ProcessOrder(Order order)
{
_paymentService.MakePayment(order.Payment);
}
}
Geldige implementatie (gebruik van Dependency Injection):
public class OrderService
{
private readonly IPaymentService _paymentService;
public OrderService(IPaymentService paymentService)
{
_paymentService = paymentService;
}
public void ProcessOrder(Order order)
{
_paymentService.MakePayment(order.Payment);
}
}
public interface IPaymentService
{
void MakePayment(Payment payment);
}
3. Kleinere, modulaire klassen
- Verdeel grote, complexe klassen in kleinere klassen die zich richten op één taak. Elke klasse zou slechts één verantwoordelijkheid moeten hebben, wat haar eenvoudiger maakt om te begrijpen, te testen en te onderhouden.
- Bijvoorbeeld: In plaats van één klasse die de logica bevat voor het ophalen, verwerken en opslaan van gegevens, maak je aparte klassen voor DataFetcher, DataProcessor, en DataSaver.
Ongeldige implementatie (te veel verantwoordelijkheden):
public class OrderProcessor
{
public void ProcessOrder(Order order)
{
ValidateOrder(order);
ProcessPayment(order);
ShipOrder(order);
}
private void ValidateOrder(Order order) { }
private void ProcessPayment(Order order) { }
private void ShipOrder(Order order) { }
}
Geldige implementatie (klassen met één verantwoordelijkheid):
public class OrderProcessor
{
private readonly IOrderValidator _orderValidator;
private readonly IPaymentProcessor _paymentProcessor;
private readonly IShippingService _shippingService;
public OrderProcessor(IOrderValidator orderValidator, IPaymentProcessor paymentProcessor, IShippingService shippingService)
{
_orderValidator = orderValidator;
_paymentProcessor = paymentProcessor;
_shippingService = shippingService;
}
public void ProcessOrder(Order order)
{
_orderValidator.Validate(order);
_paymentProcessor.Process(order);
_shippingService.Ship(order);
}
}
public interface IOrderValidator { void Validate(Order order); }
public interface IPaymentProcessor { void Process(Order order); }
public interface IShippingService { void Ship(Order order); }
4. Zorg voor duidelijke scheidingen
- Maak gebruik van interfaces en abstracties om de communicatie tussen klassen te beheren. Dit zorgt ervoor dat klassen niet rechtstreeks afhankelijk zijn van elkaar, wat de flexibiliteit verhoogt.
- Gebruik bijvoorbeeld Dependency Injection om klassen van afhankelijkheden te voorzien in plaats van ze direct te creëren. Dit maakt de klassen minder afhankelijk van elkaar.
Ongeldige implementatie (directe koppeling tussen klassen):
public class UserRegistration
{
private readonly DatabaseService _databaseService = new DatabaseService();
public void RegisterUser(User user)
{
_databaseService.Save(user);
}
}
public class DatabaseService
{
public void Save(User user) { }
}
Geldige implementatie (interface gebruikt voor scheiding):
public class UserRegistration
{
private readonly IDatabaseService _databaseService;
public UserRegistration(IDatabaseService databaseService)
{
_databaseService = databaseService;
}
public void RegisterUser(User user)
{
_databaseService.Save(user);
}
}
public interface IDatabaseService
{
void Save(User user);
}
5. Herbruikbaarheid en testbaarheid
- Het toepassen van SRP leidt vaak tot klassen die herbruikbaarder en testbaarder zijn, omdat de klassen geen extra verantwoordelijkheden bevatten die het moeilijk maken om ze apart te testen.
- Als een klasse zich bijvoorbeeld alleen richt op het valideren van gegevens, kan deze eenvoudig worden getest zonder dat er andere functionaliteit bij betrokken is.
Ongeldige implementatie (moeilijk te testen):
public class OrderService
{
public void PlaceOrder(Order order)
{
// Plaatst bestelling en stuurt notificatie
var emailService = new EmailService();
var paymentService = new PaymentService();
paymentService.ProcessPayment(order);
emailService.SendOrderConfirmation(order);
}
}
Geldige implementatie (makkelijk te testen):
public class OrderService
{
private readonly IPaymentService _paymentService;
private readonly IEmailService _emailService;
public OrderService(IPaymentService paymentService, IEmailService emailService)
{
_paymentService = paymentService;
_emailService = emailService;
}
public void PlaceOrder(Order order)
{
_paymentService.ProcessPayment(order);
_emailService.SendOrderConfirmation(order);
}
}
public interface IPaymentService { void ProcessPayment(Order order); }
public interface IEmailService { void SendOrderConfirmation(Order order); }
6. Aparte functies voor verschillende verantwoordelijkheden
- Binnen een klasse moet elke functie slechts één taak uitvoeren. Als een functie meerdere verantwoordelijkheden heeft (bijvoorbeeld het ophalen van gegevens en het verwerken van die gegevens), dan moet deze ook opgesplitst worden in kleinere, specifieke functies.
- Bijvoorbeeld: In plaats van een functie die zowel klantgegevens ophaalt als verifieert of die geldig zijn, maak je twee aparte functies: eentje die de klantgegevens ophaalt en een andere die de validatie uitvoert.
Ongeldige implementatie (meerdere verantwoordelijkheden in één functie):
public class CustomerHandler
{
public void ProcessCustomer(Customer customer)
{
// Haalt klantgegevens op
var customerDetails = GetCustomerDetails(customer.Id);
// Valideert klantgegevens
if (IsValidCustomer(customerDetails))
{
// Verwerkt klantgegevens
SaveCustomer(customerDetails);
}
}
private CustomerDetails GetCustomerDetails(int customerId) { return new CustomerDetails(); }
private bool IsValidCustomer(CustomerDetails details) { return true; }
private void SaveCustomer(CustomerDetails details) { }
}
Geldige implementatie (gescheiden verantwoordelijkheden):
public class CustomerHandler
{
private readonly ICustomerService _customerService;
public CustomerHandler(ICustomerService customerService)
{
_customerService = customerService;
}
public void ProcessCustomer(int customerId)
{
var customerDetails = _customerService.GetCustomerDetails(customerId);
if (_customerService.IsValidCustomer(customerDetails))
{
_customerService.SaveCustomer(customerDetails);
}
}
}
public interface ICustomerService
{
CustomerDetails GetCustomerDetails(int customerId);
bool IsValidCustomer(CustomerDetails details);
void SaveCustomer(CustomerDetails details);
}
7. Balans tussen complexiteit en eenvoud
- Het toepassen van SRP vereist soms dat je de code iets complexer maakt door extra klassen en functies te introduceren. Zorg ervoor dat je deze complexiteit in balans houdt, zodat de code niet onnodig ingewikkeld wordt.
- Bijvoorbeeld, als een feature klein en eenvoudig is, kan het onnodig zijn om te veel scheidingen aan te brengen. Zorg voor pragmatische keuzes en pas SRP alleen toe waar het de code echt verbetert.
Ongeldige implementatie (onnodige complexiteit):
public class OrderManager
{
public void ProcessOrder(Order order)
{
// Ongeorganiseerde logica
var paymentService = new PaymentService();
var emailService = new EmailService();
var shippingService = new ShippingService();
if (order.Amount > 1000)
{
paymentService.ProcessLargePayment(order);
}
else
{
paymentService.ProcessRegularPayment(order);
}
emailService.SendEmail(order);
shippingService.ShipOrder(order);
}
}
Geldige implementatie (eenvoudigere en meer modulaire structuur):
public class OrderManager
{
private readonly IPaymentService _paymentService;
private readonly IEmailService _emailService;
private readonly IShippingService _shippingService;
public OrderManager(IPaymentService paymentService, IEmailService emailService, IShippingService shippingService)
{
_paymentService = paymentService;
_emailService = emailService;
_shippingService = shippingService;
}
public void ProcessOrder(Order order)
{
_paymentService.ProcessPayment(order);
_emailService.SendEmail(order);
_shippingService.ShipOrder(order);
}
}
8. Verantwoordelijkheden bijhouden
- Zorg ervoor dat je de verantwoordelijkheden van de klassen bijhoudt en controleer regelmatig of klassen niet te veel verantwoordelijkheden krijgen naarmate de applicatie evolueert.
- Documenteer de verantwoordelijkheid van elke klasse of module duidelijk, zodat iedereen in het team begrijpt wat de taak van een bepaalde module is.
Hoe gebruik je het structureren van code?
Tijdens het ontwikkelproces zijn er meerdere momenten waarbij je als ontwikkelaar bewust kan zijn van het structureren van code.
In de requirements en ontwerp fase wordt nagedacht over het opstellen van acceptatiecriteria en het opzetten van een architectuur. Dit zijn momenten waarbij stilgestaan kan worden of verantwoordelijkheden met elkaar conflicteren. In de developement fase ga je als developer na wat een goede structuur is. Dit moet ook een vast onderdeel zijn van de code review.
Hierna volgen een aantal voorbeelden van momenten wanneer je stil kunt staan bij het structureren van de code.
Voorbeelden van momenten om verantwoordelijkheden bij te houden
Hier is een lijst met mogelijke momenten in het proces van SCRUM en het Software Development Life Cycle (SDLC) om de verantwoordelijkheden van klassen en services bij te houden:
1. Tijdens de Sprint Planning (SCRUM)
- Doel: Zorg ervoor dat het team begrijpt welke verantwoordelijkheden elke service en klasse zal hebben.
- Wat bij te houden:
- Documenteer de verantwoordelijkheid van nieuwe services of klassen.
- Bespreek en verifieer of de verantwoordelijkheden van bestaande componenten goed afgebakend zijn.
2. Gedurende de Sprint (SCRUM) / Gedurende development fase (SDLC)
- Doel: Zorg ervoor dat de ontwikkeling van nieuwe features de principes van Single Responsibility Principle (SRP) respecteert.
- Wat bij te houden:
- Codeerstandaarden en SRP in de ontwikkelingsprocessen volgen.
- Controleer tijdens de implementatie of de klassen of functies niet te veel verantwoordelijkheden krijgen.
3. Code Reviews (SCRUM / SSDLC)
- Doel: Reviewen of de code voldoet aan de principes van SRP en de juiste verantwoordelijkheden zijn toegewezen.
- Wat bij te houden:
- Zorg ervoor dat tijdens de code review geen klassen of functies worden geïntroduceerd die te veel verantwoordelijkheden bevatten.
- Geef feedback als de verantwoordelijkheid van een klasse of service te vaag of te breed is.
4. Tijdens de Sprint Retrospective (SCRUM)
- Doel: Reflecteren op de ontwikkeling en procesverbetering.
- Wat bij te houden:
- Bespreek of er klassen of services zijn die te veel verantwoordelijkheden hadden en refactoring vereisten.
- Verbeter het proces voor het verdelen van verantwoordelijkheden en pas het aan voor de volgende sprint.
5. Bij het aanmaken van User Stories (SCRUM)
- Doel: Zorg ervoor dat de user stories en acceptatiecriteria de verantwoordelijkheden van de betrokken services duidelijk afbakenen.
- Wat bij te houden:
- Definieer duidelijk welke services en klassen de verantwoordelijkheid dragen voor het oplossen van de user story.
- Zorg ervoor dat user stories geen overlap creëren tussen meerdere componenten die verschillende verantwoordelijkheden hebben.
6. Tijdens het ontwerpen van de Architectuur
- Doel: De initiële architectuur van de applicatie moet de verantwoordelijkheden van services en klassen duidelijk definiëren.
- Wat bij te houden:
- Documenteer de verantwoordelijkheden van elk onderdeel in het ontwerp (bijv. via architectuurdiagrammen, klasse diagrammen).
- Zorg ervoor dat de ontwerpkeuzes voldoen aan principes zoals SRP en dat er geen onnodige afhankelijkheden of overlappingen ontstaan.
7. Tijdens de Testfase (SDLC)
- Doel: Testen van de klassen en services om ervoor te zorgen dat ze alleen hun specifieke verantwoordelijkheden vervullen.
- Wat bij te houden:
- Verifieer tijdens de unit tests of elke klasse of service echt alleen zijn eigen verantwoordelijkheid heeft.
- Zorg ervoor dat tests niet afhankelijk zijn van te veel logica in één klasse, wat kan wijzen op een schending van SRP.
8. Bij het uitvoeren van Refactoring
- Doel: Refactoren van bestaande code om de verantwoordelijkheden beter te verdelen en SRP na te leven.
- Wat bij te houden:
- Documenteer elke wijziging en verduidelijk welke verantwoordelijkheden per klasse of service zijn aangepast.
- Zorg ervoor dat refactoring leidt tot beter beheerbare en testbare code met een duidelijk afgebakende verantwoordelijkheid per klasse.
9. Tijdens het releasen
- Doel: Zorg ervoor dat de code die naar productie gaat de principes van SRP respecteert.
- Wat bij te houden:
- Voer een laatste controle uit om te verifiëren of er geen klassen of services met te veel verantwoordelijkheden in de release zijn.
- Zorg ervoor dat alle nieuwe features geen bestaande code breken die goed gescheiden verantwoordelijkheden heeft.
Door regelmatig deze momenten te gebruiken om de verantwoordelijkheden van klassen en services bij te houden, kun je ervoor zorgen dat de applicatie goed gestructureerd blijft en gemakkelijk te onderhouden en uit te breiden is.
Conclusie:
Het toepassen van het Single Responsibility Principle betekent dat je jezelf voortdurend afvraagt: “Wat is de verantwoordelijkheid van deze klasse of functie?” Als je merkt dat een klasse of functie meerdere verantwoordelijkheden heeft, moet je die opsplitsen in kleinere, meer gerichte eenheden. Hierdoor wordt je code flexibeler, makkelijker te onderhouden en beter testbaar.
Bronnen
SOLID: https://www.geeksforgeeks.org/single-responsibility-in-solid-design-principle/
Volgende stap: Oefeningen structureren code