# Inleiding
Refactoren is een essentieel onderdeel van software ontwikkeling dat vaak onderbelicht blijft in de dagelijkse praktijk. Het is de kunst van het herstructureren van bestaande code zonder de externe functionaliteit te wijzigen, met als doel de leesbaarheid, onderhoudbaarheid en efficiëntie te verbeteren. In dit artikel onderzoeken we de theoretische onderbouwing, praktische technieken en strategieën voor effectief refactoren, geïllustreerd met Java voorbeelden.
# Theoretische Grondslag van Refactoren
# Definitie en Doelstellingen
Refactoren kan worden gedefinieerd als:
Het proces van wijzigen van een softwaresysteem op zodanige wijze dat het de externe functionaliteit niet verandert maar wel de interne structuur verbetert.
De primaire doelstellingen van refactoren zijn:
- Verbetering van code leesbaarheid - Code wordt geschreven voor mensen, niet alleen voor computers
- Vermindering van technische schuld - Het proactief aanpakken van suboptimale implementaties
- Verhoging van onderhoudbaarheid - Het eenvoudiger maken om toekomstige wijzigingen door te voeren
- Eliminatie van code duplicatie - Naleving van het DRY-principe (Don't Repeat Yourself)
- Verbetering van performance - Optimalisatie van inefficiënte code patronen
# Code Smells als Refactoring Indicatoren
"Code smells" zijn symptomen in de broncode die mogelijkerwijs wijzen op diepere problemen. Ze vormen effectieve indicatoren voor wanneer refactoring noodzakelijk is:
- Duplicatie: Herhaalde code in meerdere locaties
- Lange methoden: Functies die te veel verantwoordelijkheden hebben
- Grote klassen: Klassen die te veel doen of weten
- Overmatig gebruik van primitieve types: Complex gedrag gemodelleerd met strings of integers
- Switch statements: Vaak een indicatie van missende polymorfisme
- Tijdelijke velden: Velden die slechts in bepaalde omstandigheden gebruikt worden
- Message chains: A.getB().getC().getD()...
- Middle man: Klassen die alleen delegeren naar andere klassen
- Feature envy: Methoden die meer geïnteresseerd zijn in andere klassen dan hun eigen
# Systematische Aanpak van Refactoren
# Voorbereidende Fase
Voordat we beginnen met refactoren, is het essentieel om:
- Uitgebreide tests te hebben - Unit tests en integratie tests zorgen ervoor dat functionaliteit behouden blijft
- Een duidelijk doel te definiëren - Wat wil je precies verbeteren?
- Kleine stapjes te plannen - Refactoring werkt het beste in incrementele stappen
# Praktijkvoorbeeld: Van Procedurele naar Object-Georiënteerde Code
Laten we beginnen met een eenvoudig maar illustratief voorbeeld van een methode die berekent of een persoon in aanmerking komt voor een korting:
public double berekenFactuurbedrag(double bedrag, int leeftijd, boolean isStudent,
int aantalAankopen) {
double kortingsPercentage = 0.0;
// Leeftijdskorting
if (leeftijd < 18) {
kortingsPercentage += 0.10;
} else if (leeftijd >= 65) {
kortingsPercentage += 0.15;
}
// Studentenkorting
if (isStudent) {
kortingsPercentage += 0.05;
}
// Loyaliteitskorting
if (aantalAankopen > 10) {
kortingsPercentage += 0.05;
} else if (aantalAankopen > 5) {
kortingsPercentage += 0.03;
}
// Maximum korting is 20%
if (kortingsPercentage > 0.20) {
kortingsPercentage = 0.20;
}
double factuurTotaal = bedrag * (1 - kortingsPercentage);
return factuurTotaal;
}
Deze methode voldoet aan meerdere "code smells":
- Lange methode - Het doet te veel dingen in één functie
- Primitieve obsessie - Het gebruikt primitieve typen in plaats van domeinobjecten
- Single Responsibility Principle schending - De methode berekent verschillende soorten kortingen
Laten we dit refactoren naar een meer object-georiënteerde benadering:
public class Klant {
private int leeftijd;
private boolean isStudent;
private int aantalAankopen;
public Klant(int leeftijd, boolean isStudent, int aantalAankopen) {
this.leeftijd = leeftijd;
this.isStudent = isStudent;
this.aantalAankopen = aantalAankopen;
}
public int getLeeftijd() {
return leeftijd;
}
public boolean isStudent() {
return isStudent;
}
public int getAantalAankopen() {
return aantalAankopen;
}
}
public class KortingsCalculator {
private static final double MAXIMUM_KORTING = 0.20;
public double berekenKortingsPercentage(Klant klant) {
double kortingsPercentage = 0.0;
kortingsPercentage += berekenLeeftijdsKorting(klant.getLeeftijd());
kortingsPercentage += berekenStudentenKorting(klant.isStudent());
kortingsPercentage += berekenLoyaliteitsKorting(klant.getAantalAankopen());
return Math.min(kortingsPercentage, MAXIMUM_KORTING);
}
private double berekenLeeftijdsKorting(int leeftijd) {
if (leeftijd < 18) {
return 0.10;
} else if (leeftijd >= 65) {
return 0.15;
}
return 0.0;
}
private double berekenStudentenKorting(boolean isStudent) {
return isStudent ? 0.05 : 0.0;
}
private double berekenLoyaliteitsKorting(int aantalAankopen) {
if (aantalAankopen > 10) {
return 0.05;
} else if (aantalAankopen > 5) {
return 0.03;
}
return 0.0;
}
}
public class FactuurService {
private KortingsCalculator kortingsCalculator;
public FactuurService() {
this.kortingsCalculator = new KortingsCalculator();
}
public double berekenFactuurbedrag(double bedrag, Klant klant) {
double kortingsPercentage = kortingsCalculator.berekenKortingsPercentage(klant);
return bedrag * (1 - kortingsPercentage);
}
}
De voordelen van deze refactoring zijn:
- Single Responsibility Principle - Elke klasse en methode heeft nu één verantwoordelijkheid
- Open/Closed Principle - De code is nu open voor uitbreiding (nieuwe kortingstypen) maar gesloten voor wijziging
- Verbeterde leesbaarheid - Duidelijke naamgeving maakt de intentie van elke methode duidelijk
- Verbeterde testbaarheid - Kleinere methoden zijn eenvoudiger te testen
- Verwijdering van primitieve obsessie - We gebruiken nu domeinobjecten
# Geavanceerde Refactoring Technieken
# Toepassing van Design Patterns
Design patterns bieden bewezen oplossingen voor veelvoorkomende problemen. Laten we kijken hoe we ons voorbeeld kunnen verbeteren met behulp van het Strategy Pattern:
// Interface voor kortingsstrategie
public interface KortingsStrategie {
double berekenKorting(Klant klant);
}
// Implementaties voor verschillende kortingstypen
public class LeeftijdsKortingsStrategie implements KortingsStrategie {
@Override
public double berekenKorting(Klant klant) {
int leeftijd = klant.getLeeftijd();
if (leeftijd < 18) {
return 0.10;
} else if (leeftijd >= 65) {
return 0.15;
}
return 0.0;
}
}
public class StudentenKortingsStrategie implements KortingsStrategie {
@Override
public double berekenKorting(Klant klant) {
return klant.isStudent() ? 0.05 : 0.0;
}
}
public class LoyaliteitsKortingsStrategie implements KortingsStrategie {
@Override
public double berekenKorting(Klant klant) {
int aantalAankopen = klant.getAantalAankopen();
if (aantalAankopen > 10) {
return 0.05;
} else if (aantalAankopen > 5) {
return 0.03;
}
return 0.0;
}
}
// Verbeterde KortingsCalculator
public class KortingsCalculator {
private static final double MAXIMUM_KORTING = 0.20;
private List<KortingsStrategie> strategieën = new ArrayList<>();
public KortingsCalculator() {
// Registreer alle kortingsstrategieën
strategieën.add(new LeeftijdsKortingsStrategie());
strategieën.add(new StudentenKortingsStrategie());
strategieën.add(new LoyaliteitsKortingsStrategie());
}
public double berekenKortingsPercentage(Klant klant) {
double totaleKorting = strategieën.stream()
.mapToDouble(strategie -> strategie.berekenKorting(klant))
.sum();
return Math.min(totaleKorting, MAXIMUM_KORTING);
}
}
Deze implementatie maakt gebruik van het Strategy Pattern en biedt de volgende voordelen:
- Uitbreidbaarheid - Nieuwe kortingstypen kunnen worden toegevoegd zonder bestaande code te wijzigen
- Scheiding van verantwoordelijkheden - Elke strategie is verantwoordelijk voor één type korting
- Verbeterde testbaarheid - Strategieën kunnen individueel worden getest
- Open/Closed Principle - De code is nu echt open voor uitbreiding, gesloten voor wijziging
# Refactoren voor Testbaarheid
Een cruciale maar vaak onderschatte reden voor refactoren is het verbeteren van de testbaarheid. Laten we kijken hoe dependency injection de testbaarheid van onze FactuurService kan verbeteren:
public class FactuurService {
private final KortingsCalculator kortingsCalculator;
// Constructor injection voor betere testbaarheid
public FactuurService(KortingsCalculator kortingsCalculator) {
this.kortingsCalculator = kortingsCalculator;
}
public double berekenFactuurbedrag(double bedrag, Klant klant) {
double kortingsPercentage = kortingsCalculator.berekenKortingsPercentage(klant);
return bedrag * (1 - kortingsPercentage);
}
}
Deze refactoring maakt het mogelijk om de FactuurService te testen met een mock van de KortingsCalculator:
@Test
public void testBerekenFactuurbedrag() {
// Arrange
KortingsCalculator mockCalculator = Mockito.mock(KortingsCalculator.class);
Klant testKlant = new Klant(30, false, 3);
double verwachteKorting = 0.10;
Mockito.when(mockCalculator.berekenKortingsPercentage(testKlant)).thenReturn(verwachteKorting);
FactuurService service = new FactuurService(mockCalculator);
// Act
double resultaat = service.berekenFactuurbedrag(100.0, testKlant);
// Assert
assertEquals(90.0, resultaat, 0.001);
Mockito.verify(mockCalculator).berekenKortingsPercentage(testKlant);
}
# Refactoren in een Bestaand Codebase
# Incrementele Aanpak
In de praktijk werken we vaak met bestaande, complexe codebases. Een gefaseerde aanpak is dan essentieel:
- Identificeer probleemgebieden - Gebruik metrics zoals cyclomatische complexiteit, koppeling en cohesie
- Herschrijf tests - Zorg voor uitgebreide tests voordat je begint met refactoren
- Kleine stappen - Voer incrementele wijzigingen door, test na elke wijziging
- Documenteer beslissingen - Houd een logboek bij van refactoring beslissingen en redeneringen
# Praktijkvoorbeeld: Legacy Code Refactoring
Stel dat we de volgende legacy code tegenkomen:
public class GebruikerManager {
private Database db;
public GebruikerManager() {
this.db = Database.getInstance(); // Singleton, moeilijk te mocken voor tests
}
public boolean authenticateUser(String username, String password) throws Exception {
if (username == null || password == null)
throw new Exception("Ongeldige invoer");
// Directe SQL in code - moeilijk te onderhouden
String sql = "SELECT * FROM users WHERE username='" + username +
"' AND password='" + password + "'";
ResultSet rs = db.executeQuery(sql);
boolean authenticated = rs.next();
// Vergeet niet resources te sluiten
rs.close();
if (authenticated) {
logActivity(username, "LOGIN_SUCCESS");
} else {
logActivity(username, "LOGIN_FAILURE");
}
return authenticated;
}
private void logActivity(String username, String activityType) {
// Directe SQL in code
String sql = "INSERT INTO user_activity (username, activity, timestamp) " +
"VALUES ('" + username + "', '" + activityType + "', NOW())";
try {
db.executeUpdate(sql);
} catch (Exception e) {
System.err.println("Kon activiteit niet loggen: " + e.getMessage());
// Log fout wordt genegeerd - problematisch!
}
}
}
Deze code heeft verschillende problemen:
- Slechte testbaarheid door directe afhankelijkheid van een singleton
- SQL injectie risico door directe string concatenatie
- Exception handling is niet consistent
- Resource lekkage risico als er exceptions optreden
- Multiple responsibilities - authenticatie én logging
Laten we dit stap voor stap refactoren:
Stap 1: Introductie van interfaces voor losse koppeling
public interface DatabaseConnector {
ResultSet executeQuery(String sql, Object... parameters) throws SQLException;
int executeUpdate(String sql, Object... parameters) throws SQLException;
}
public interface ActivityLogger {
void logActivity(String username, String activityType);
}
Stap 2: Refactor de GebruikerManager klasse
public class GebruikerManager {
private final DatabaseConnector db;
private final ActivityLogger activityLogger;
// Dependency injection voor testbaarheid
public GebruikerManager(DatabaseConnector db, ActivityLogger activityLogger) {
this.db = db;
this.activityLogger = activityLogger;
}
public boolean authenticateUser(String username, String password) {
if (username == null || password == null) {
throw new IllegalArgumentException("Username en password mogen niet null zijn");
}
// Prepared statement patroon voor veiligheid
String sql = "SELECT * FROM users WHERE username = ? AND password = ?";
try (ResultSet rs = db.executeQuery(sql, username, password)) {
boolean authenticated = rs.next();
if (authenticated) {
activityLogger.logActivity(username, "LOGIN_SUCCESS");
} else {
activityLogger.logActivity(username, "LOGIN_FAILURE");
}
return authenticated;
} catch (SQLException e) {
throw new AuthenticatieException("Fout bij authenticatie", e);
}
}
}
// Custom exception voor betere error handling
public class AuthenticatieException extends RuntimeException {
public AuthenticatieException(String message, Throwable cause) {
super(message, cause);
}
}
Stap 3: Implementatie van de interfaces
public class DefaultDatabaseConnector implements DatabaseConnector {
private final DataSource dataSource;
public DefaultDatabaseConnector(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public ResultSet executeQuery(String sql, Object... parameters) throws SQLException {
Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
for (int i = 0; i < parameters.length; i++) {
stmt.setObject(i + 1, parameters[i]);
}
return stmt.executeQuery();
}
@Override
public int executeUpdate(String sql, Object... parameters) throws SQLException {
try (
Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)
) {
for (int i = 0; i < parameters.length; i++) {
stmt.setObject(i + 1, parameters[i]);
}
return stmt.executeUpdate();
}
}
}
public class DatabaseActivityLogger implements ActivityLogger {
private final DatabaseConnector db;
private static final Logger logger = LoggerFactory.getLogger(DatabaseActivityLogger.class);
public DatabaseActivityLogger(DatabaseConnector db) {
this.db = db;
}
@Override
public void logActivity(String username, String activityType) {
String sql = "INSERT INTO user_activity (username, activity, timestamp) VALUES (?, ?, NOW())";
try {
db.executeUpdate(sql, username, activityType);
} catch (SQLException e) {
logger.error("Kon activiteit niet loggen voor gebruiker: {}", username, e);
// Correct gebruik van logging framework
}
}
}
De voordelen van deze refactoring zijn:
- Verbeterde testbaarheid - We kunnen nu mocks injecteren voor tests
- SQL injectie preventie - Gebruik van prepared statements
- Correcte resource handling - Try-with-resources constructie
- Betere exception handling - Met gepaste logging en specifieke exceptions
- Single Responsibility Principle - Scheiding van authenticatie en logging
- Verbeterde leesbaarheid - Duidelijke intentie en verbeterde naamgeving
# Meten van Refactoring Impact
# Codebase Metrics
Het is belangrijk om de impact van refactoring te kunnen meten. Enkele nuttige metrics zijn:
- Cyclomatische complexiteit - Kwantificeert de complexiteit van code
- Afferente en efferente koppeling - Meet afhankelijkheden tussen componenten
- LCOM (Lack of Cohesion of Methods) - Meet hoe gerelateerd methoden in een klasse zijn
- Code coverage - Percentage code gedekt door tests
- Duplicatie - Hoeveelheid gedupliceerde code
Laten we een voorbeeld bekijken van hoe we deze metrics kunnen analyseren met behulp van SonarQube:
// Voor refactoring
public class VoorRefactoring {
public void processeerBetaling(String type, double bedrag, Klant klant) {
if (type.equals("CREDITCARD")) {
// Creditcard verwerking logic - 20 regels
} else if (type.equals("BANKOVERSCHRIJVING")) {
// Bankoverschrijving logic - 25 regels
} else if (type.equals("PAYPAL")) {
// PayPal logic - 30 regels
}
// Meer conditionals...
}
}
// Na refactoring met Strategy Pattern
public interface BetalingsProcessor {
void processeer(double bedrag, Klant klant);
}
public class CreditcardProcessor implements BetalingsProcessor {
@Override
public void processeer(double bedrag, Klant klant) {
// Creditcard logic - 20 regels
}
}
public class BankoverschrijvingProcessor implements BetalingsProcessor {
@Override
public void processeer(double bedrag, Klant klant) {
// Bankoverschrijving logic - 25 regels
}
}
public class PayPalProcessor implements BetalingsProcessor {
@Override
public void processeer(double bedrag, Klant klant) {
// PayPal logic - 30 regels
}
}
public class BetalingsService {
private Map<String, BetalingsProcessor> processors = new HashMap<>();
public BetalingsService() {
processors.put("CREDITCARD", new CreditcardProcessor());
processors.put("BANKOVERSCHRIJVING", new BankoverschrijvingProcessor());
processors.put("PAYPAL", new PayPalProcessor());
}
public void processeerBetaling(String type, double bedrag, Klant klant) {
BetalingsProcessor processor = processors.get(type);
if (processor == null) {
throw new OngeldigBetalingsTypeException("Ongeldig betalingstype: " + type);
}
processor.processeer(bedrag, klant);
}
}
Metriekanalyse zou aantonen:
- Cyclomatische complexiteit: Van hoog (vele conditionals) naar laag (één lookup)
- Koppeling: Van hoog naar laag, met betere scheiding van verantwoordelijkheden
- Testbaarheid: Verbeterd, omdat elke processor afzonderlijk kan worden getest
# Refactoren in Team Verband
# Best Practices voor Teams
Refactoren in een team vereist coördinatie en consensus:
- Gemeenschappelijk begrip - Zorg ervoor dat iedereen dezelfde refactoring principes begrijpt
- Code reviews - Gebruik code reviews om refactoring inspanningen te evalueren
- Gedeelde coding standaarden - Definieer en handhaaf consistente coding standaarden
- Continuous integration - Gebruik CI om te verifiëren dat refactoring geen regressies introduceert
- Gedeelde verantwoordelijkheid - Moedig het "boy scout rule" principe aan: "Laat de code beter achter dan je het vond"
# Technische Schuld Management
Refactoren is een essentieel hulpmiddel voor het beheren van technische schuld:
- Identificeer technische schuld - Gebruik tools zoals SonarQube om probleemgebieden te identificeren
- Prioriteer refactoring inspanningen - Focus op gebieden met de hoogste impact op ontwikkelingssnelheid
- Alloceer tijd voor refactoring - Plan specifieke tijd voor refactoring in sprints
- Meet voortgang - Houd bij hoe refactoring de kwaliteit en ontwikkelingssnelheid verbetert
# Conclusie
Refactoren is geen luxe maar een essentieel onderdeel van professionele software ontwikkeling. Het stelt ontwikkelaars in staat om codebases gezond en onderhoudbaar te houden, waardoor de ontwikkelingssnelheid op lange termijn wordt verbeterd.
Door systematisch code smells te identificeren en bewezen refactoring technieken toe te passen, kunnen we de kwaliteit van onze software aanzienlijk verbeteren. Refactoren is niet alleen een technische activiteit, maar ook een mentaliteit - een toewijding aan continue verbetering en excellentie in software engineering.
# Referenties
- Fowler, M. (2018). Refactoring: Improving the Design of Existing Code (2nd Edition). Addison-Wesley Professional.
- Martin, R. C. (2008). Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall.
- Feathers, M. (2004). Working Effectively with Legacy Code. Prentice Hall.
- Beck, K. (2002). Test Driven Development: By Example. Addison-Wesley Professional.
- Evans, E. (2003). Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley Professional.
Dit artikel is bedoeld als educatieve bron en representeert algemeen aanvaarde best practices in software ontwikkeling op het moment van publicatie.
Reacties (0 )
Geen reacties beschikbaar.
Log in om een reactie te plaatsen.