Code quality: eerste hulp bij refactoring
Hoe je complexe methodes stap voor stap beheersbaar maakt
Complexe code kan enorme problemen opleveren. Lange, onoverzichtelijke stukken code vol logica, maken het moeilijk te begrijpen, aan te passen en foutvrij te houden. Helaas krijgen we er toch vaak mee te maken als we aan een bestaand project gaan werken met verouderde code, als meerdere ontwikkelaars zonder duidelijke afspraken aan dezelfde code werken of als tijdsdruk leidt tot haastige implementaties zonder grondige refactoring. Daarom gaan we in deze blog een complexe methode onder de loep nemen en refactoren we deze stap voor stap. Onderweg laat ik zien hoe IntelliJ’s refactoring-tools je helpen om sneller en foutloos te werken.
Aangezien ik de laatste tijd wat ziekenhuisbezoeken heb gehad vanwege sportblessures, leek het me passend om voor deze blog een voorbeeld uit het zorgdomein te gebruiken. Net als in de zorg draait goede code om duidelijkheid en betrouwbaarheid—iets waar deze methode nog wat hulp in kan gebruiken. Laten we de “patiënt” eens bekijken.
public void processPatientAdmission(Long patientId) throws
AdmissionProcessingException, NoAvailableRoomException,
PatientNotFoundException {
try {
PatientData patient =
patientService.getPatientById(patientId);
if (patient == null) {
throw new PatientNotFoundException("Patient not found: " + patientId);
}
InsuranceData insurance = insuranceService
.getInsuranceDetails(patient.getInsuranceNumber());
boolean isCovered =
insurance != null && insurance.getCoverageAmount()
.compareTo(BigDecimal.ZERO) > 0;
List<HospitalRoom> availableRooms = roomService
.getAvailableRooms();
if (availableRooms.isEmpty()) {
throw new NoAvailableRoomException("No available rooms found for patient: " + patientId);
}
HospitalRoom assignedRoom = availableRooms.getFirst();
assignedRoom.assignToPatient(patientId);
List<MedicationData> medications =
medicationService.getPrescribedMedications(patientId);
for (MedicationData medication : medications) {
if (!medicationService.isMedicationAvailable(
medication.getCode())) {
log.warn("Medication not available: {}", medication.getName());
} else {
medicationService.reserveMedication(
medication.getCode(), patientId);
}
}
patientService.sendAdmissionNotification(
patient.getContactInfo(), assignedRoom.getRoomNumber());
if (!isCovered) {
patientService.sendInsuranceWarning(
patient.getContactInfo());
}
patientService.registerAdmission(
patientId, assignedRoom.getRoomNumber(), isCovered);
} catch (Exception e) {
log.error("Error while registering patient", e);
throw new AdmissionProcessingException("Something went wrong while registering patient " + patientId, e);
}
}
Wat meteen opvalt is dat deze methode veel te veel verantwoordelijkheden heeft. Het regelt alles rondom een patiëntopname: van verzekeringscontrole en kamerindeling tot medicatieplanning en notificaties. Dit maakt de code moeilijk leesbaar en onderhoudbaar, omdat alle stappen kriskras door elkaar staan. Voor deze blog gaan we ervan uit dat er uitgebreide tests zijn, zodat we tijdens het refactoren kunnen valideren dat alles blijft werken. Als die tests ontbreken of onvoldoende dekking hebben, moeten die eerst worden geschreven voordat we wijzigingen doorvoeren, maar dat is niet de focus van deze oefening. Tijd om deze methode stap voor stap te verbeteren.
Om de leesbaarheid te verbeteren, beginnen we met het opsplitsen van de methode in logisch gescheiden stappen. Op dit moment is het lastig om snel te begrijpen wat er precies gebeurt, omdat alle verantwoordelijkheden door elkaar heen lopen. Door delen van de code te verplaatsen naar aparte methoden met duidelijke namen, kunnen we in één oogopslag zien wat processPatientAdmission
doet, zonder dat we ons door alle details hoeven te worstelen.
In plaats van handmatig code te knippen en plakken, gebruiken we de Extract Method-functie van IntelliJ, waardoor we snel en foutloos afzonderlijke methoden kunnen maken (refactor/extract method, of ⌥+⌘+M op de Mac).
Deze aanpassingen leiden tot de volgende verbeterde versie van de methode, waarin elke afzonderlijke verantwoordelijkheid is ondergebracht in een eigen methode.
public void processPatientAdmission(Long patientId) throws
AdmissionProcessingException, NoAvailableRoomException,
PatientNotFoundException {
try {
PatientData patient = getPatientData(patientId);
boolean isCoveredByInsurance =
determineInsuranceCoverage(patient);
HospitalRoom assignedRoom =
findAndAssignAvailableRoom(patientId);
processMedicationPlan(patientId);
notifyRelevantParties(patient, assignedRoom,
isCoveredByInsurance);
finalizePatientAdmission(patientId, assignedRoom,
isCoveredByInsurance);
} catch (Exception e) {
log.error("Error while registering patient: {}", e.getMessage());
throw new AdmissionProcessingException("Something went wrong while registering patient " + patientId, e);
}
}
private PatientData getPatientData(Long patientId) throws
PatientNotFoundException {
PatientData patient =
patientService.getPatientById(patientId);
if (patient == null) {
throw new PatientNotFoundException("Patient not found: " + patientId);
}
return patient;
}
private boolean determineInsuranceCoverage(
PatientData patient) {
InsuranceData insurance = insuranceService
.getInsuranceDetails(patient.getInsuranceNumber());
return insurance != null && insurance.getCoverageAmount()
.compareTo(BigDecimal.ZERO) > 0;
}
private HospitalRoom findAndAssignAvailableRoom(
Long patientId) throws NoAvailableRoomException {
List<HospitalRoom> availableRooms =
roomService.getAvailableRooms();
if (availableRooms.isEmpty()) {
throw new NoAvailableRoomException("No available rooms for patient: " + patientId);
}
HospitalRoom assignedRoom = availableRooms.getFirst();
assignedRoom.assignToPatient(patientId);
return assignedRoom;
}
private void processMedicationPlan(Long patientId) {
List<Medication> medications = medicationService
.getPrescribedMedications(patientId);
for (MedicationData medication : medications) {
if (!medicationService.isMedicationAvailable(
medication.getCode())) {
log.warn("Medication not available: " + medication.getName());
} else {
medicationService.reserveMedication(
medication.getCode(), patientId);
}
}
}
private void notifyRelevantParties(PatientData patient,
HospitalRoom assignedRoom, boolean isCovered) {
patientService.sendAdmissionNotification(
patient.getContactInfo(), assignedRoom.getRoomNumber());
if (!isCovered) {
patientService.sendInsuranceWarning(
patient.getContactInfo());
}
}
private void finalizePatientAdmission(
Long patientId, HospitalRoom assignedRoom,
boolean isCovered) {
patientService.registerAdmission(
patientId, assignedRoom.getRoomNumber(), isCovered);
}
Door de logica op te splitsen in aparte methodes met duidelijke namen, wordt de processPatientAdmission
methode direct een stuk leesbaarder. In plaats van door een lange lap code te moeten scrollen, zie je nu in één oogopslag wat er gebeurt: elke methode beschrijft precies wat haar verantwoordelijkheid is. De methode processPatientAdmission
leest hierdoor bijna als een stappenplan, zonder dat je hoeft te weten hoe elke stap werkt. Dat maakt de code niet alleen overzichtelijker, maar ook makkelijker te onderhouden en te begrijpen voor collega’s (of jezelf over een paar maanden).
Daarnaast hebben we de naam van de variabele isCovered
hernoemd naar isCoveredByInsurance
, zodat direct duidelijk is dat het gaat om verzekeringsdekking en niet bijvoorbeeld om een dekking door een ziekenhuisregeling of een andere vorm van dekking. Hierdoor wordt verwarring voorkomen en wordt de intentie van de code explicieter.
Een volgende stap in onze refactoring is het verbeteren van de methode findAndAssignAvailableRoom
. De oorspronkelijke naam bevatte een And
, wat suggereert dat de methode meerdere verantwoordelijkheden heeft. En dat klopt: de methode zoekt een beschikbare kamer én wijst die toe aan een patiënt. Volgens het Single Purpose-principe uit CUPID hoort een methode slechts één duidelijke taak te hebben. Door deze verantwoordelijkheden te scheiden, verhogen we de herbruikbaarheid en testbaarheid van de code.
Na refactoring ziet de methode er zo uit:
private RoomNumber bookRoomForPatient(Long patientId)
throws NoAvailableRoomException {
HospitalRoom room = findAvailableRoom(patientId);
room.assignToPatient(patientId);
return room.getRoomNumber();
}
private RoomNumber findAvailableRoom(Long patientId)
throws NoAvailableRoomException {
List availableRooms = roomService.getAvailableRooms();
if (availableRooms.isEmpty()) {
throw new NoAvailableRoomException("No available room for patient: " + patientId);
}
return availableRooms.getFirst().getRoomNumber();
}
We hebben er voor gekozen om het kamernummer (in de vorm van een RoomNumber
value object) terug te geven in plaats van het hele HospitalRoom
object. Dit maakt expliciet wat de uitkomst van de methode is, zonder terug te vallen op primitieve types zoals String. De methode-signature laat nu direct zien wat je van de methode kunt verwachten, zonder dat je de implementatie hoeft te lezen.
Gebruik IntelliJ’s Change Signature (⌘+F6 op Mac) om deze wijziging door te voeren in de methode en alle aanroepen ervan. Zo refactor je gecontroleerd en minimaliseer je het risico op regressies.
Met deze refactoring hebben we al veel gewonnen: de complexe opnameprocedure is opgesplitst, herbruikbare componenten zijn geïntroduceerd en de code is beter testbaar. Maar we zijn er nog niet. Hoewel de structuur verbeterd is, is de stijl nog voornamelijk procedureel. Dat zie je meteen aan namen als: assigner
, processor
, service
. Dit zijn typische serviceklassen die operaties uitvoeren op objecten, maar zelf geen betekenis of gedrag uit het domein modelleren.
De volgende stap is om de logica verder te verplaatsen: van deze services naar de domeinobjecten zelf. Je kijkt per stukje logica waar het thuishoort. Bijvoorbeeld: het vinden van een beschikbare kamer hoort misschien thuis bij een Hospital
, het toewijzen van een kamer bij HospitalRoom
, en de beslissing over het medicatieplan bij de Patient
of MedicationPlan
.
Zo ontstaat er geleidelijk een rijk domeinmodel, waarin de objecten zelf hun gedrag bevatten. Je services verdwijnen, of worden gereduceerd tot het aanroepen van de methoden op die objecten. Dit leidt tot:
-
Betere testbaarheid: logica zit waar de data zit.
-
Hogere herbruikbaarheid: gedrag is losgekoppeld van context.
-
Meer domeinkennis op logische plekken: je hoeft niet te zoeken in een wildgroei aan services.
Bijvoorbeeld, in plaats van:
HospitalRoom room =
roomAssigner.assignRoomToPatient(patientId);
zou je uiteindelijk iets als dit willen:
HospitalRoom room = hospital.assignRoomTo(patient);
Of nog mooier, als de Patient
zelf de actie initieert:
RoomNumber roomNumber = patient.admitTo(hospital);
Deze stijl maakt het model niet alleen krachtiger, maar ook zelfverklarend. De code leest als domeintaal. Door die laatste stap te zetten, maak je de sprong van structuurverbetering naar echte objectgeoriënteerde modellering.
Goede code schrijf je niet in één keer. Refactoren is een iteratief proces waarbij je stap voor stap verantwoordelijkheden verkleint, namen verduidelijkt en logica opsplitst. Door gebruik te maken van de functies van IntelliJ én principes volgt van CUPID, maak je zelfs de meest chaotische methodes weer beheersbaar.
Clean code is geen doel op zich—het is een manier om fouten te voorkomen, leesbaarheid te verbeteren en je code toekomstbestendig te maken. Net als in de zorg draait het uiteindelijk om duidelijkheid en betrouwbaarheid. En met deze refactors blijft je code stabiel, goed behandelbaar en ver weg van de intensive care.
Want to know more about what we do?
We are your dedicated partner. Reach out to us.