-
Notifications
You must be signed in to change notification settings - Fork 135
SE 08 Refactoring
Part of the Software Engineering Principles series
Refactoring is the process of restructuring existing code without changing its observable behaviour. The goal is to improve internal quality — readability, maintainability, testability — while leaving the system's external behaviour identical.
Martin Fowler's definition: "A change made to the internal structure of software to make it easier to understand and cheaper to modify without changing its observable behaviour."
The two key words are "without changing observable behaviour." If you are fixing a bug, you are not refactoring — you are changing behaviour. If you are adding a feature, you are not refactoring — you are adding behaviour. Refactoring is a distinct activity, often done before or after making a functional change.
Every shortcut taken during development accumulates as technical debt — code that works but is harder than it should be to understand or modify. Like financial debt, technical debt accrues interest: future changes take longer because the codebase is harder to work with.
Refactoring pays down technical debt.
Code that is hard to read is hard to change safely. Refactoring restructures code to make the next feature or bug fix cheaper to implement.
Refactoring without tests is dangerous — you have no way to confirm that behaviour has not changed. The process is: write tests → refactor → run tests → confirm behaviour unchanged.
- First time: just do it
- Second time you do something similar: note the duplication
- Third time: refactor
Make the change easy, then make the easy change. If the code structure makes it hard to add a feature safely, refactor first to create the right structure, then add the feature.
When the code is confusing enough that you had to work hard to find the bug, refactor to make it obvious — then the bug becomes visible and can be fixed simply.
If a reviewer struggles to understand the code, that is a signal to refactor before merging.
The most common refactoring. Take a fragment of code and turn it into a method with a meaningful name.
// Before — one long method doing many things
public void processPharmacyOrder(Order order) {
// Validate
if (order.getItems().isEmpty()) throw new IllegalArgumentException("Empty order");
for (OrderItem item : order.getItems()) {
if (item.getQuantity() <= 0) throw new IllegalArgumentException("Invalid qty");
}
// Check stock
for (OrderItem item : order.getItems()) {
StockItem stock = stockRepo.find(item.getMedicine(), order.getDepartment());
if (stock.getQuantity() < item.getQuantity()) {
throw new InsufficientStockException(item.getMedicine());
}
}
// Deduct stock and create bill
for (OrderItem item : order.getItems()) {
stockRepo.deduct(item);
}
Bill bill = billService.create(order);
auditService.log(order, bill);
}
// After — each step is a named method
public void processPharmacyOrder(Order order) {
validateOrder(order);
checkStockAvailability(order);
deductStock(order);
Bill bill = billService.create(order);
auditService.log(order, bill);
}
private void validateOrder(Order order) { ... }
private void checkStockAvailability(Order order) { ... }
private void deductStock(Order order) { ... }The refactored version reads like a description of the process. A reader can understand what happens at a glance and drill into any step individually.
Rename a variable, method, or class to better describe what it is or does.
// Before
int d;
boolean flag;
void proc(Patient p) { }
// After
int appointmentExpiryDays;
boolean isInsuranceVerified;
void validatePatientEligibility(Patient patient) { }Renaming is safe in modern IDEs — use the refactor-rename function so all references are updated atomically.
Replace a complex expression with a named variable to explain its intent.
// Before — what does this condition mean?
if (bill.getCreatedDate().plusDays(30).isBefore(LocalDate.now())
&& bill.getStatus() == BillStatus.DRAFT
&& bill.getNetValue() > 0) { }
// After — the intent is clear
boolean isOverdueUnpaidBill = bill.getCreatedDate().plusDays(30).isBefore(LocalDate.now())
&& bill.getStatus() == BillStatus.DRAFT
&& bill.getNetValue() > 0;
if (isOverdueUnpaidBill) { }The opposite of extract: replace a method or variable with its content when abstraction obscures more than it clarifies.
// An intermediary that adds no value
private boolean isGreaterThanZero(double value) {
return value > 0;
}
// Just write the condition directly
if (amount > 0) { }Move a method to the class whose data it primarily uses.
// Bad — PatientBillingService knows a lot about Bill's internals
public class PatientBillingService {
public boolean isOverdue(Bill bill) {
return bill.getDueDate() != null
&& bill.getDueDate().isBefore(LocalDate.now())
&& bill.getStatus() != BillStatus.PAID;
}
}
// Better — Bill knows about itself
public class Bill {
public boolean isOverdue() {
return dueDate != null
&& dueDate.isBefore(LocalDate.now())
&& status != BillStatus.PAID;
}
}Replace type-checking switch/if chains with polymorphic method calls.
// Before — every new type requires modifying this method
public double calculateFee(Service service) {
switch (service.getType()) {
case CONSULTATION: return service.getBasePrice();
case LAB_TEST: return service.getBasePrice() * 0.9;
case RADIOLOGY: return service.getBasePrice() * 0.85;
default: return service.getBasePrice();
}
}
// After — each type knows its own fee calculation
public interface Service {
double calculateFee();
}
public class Consultation implements Service {
public double calculateFee() { return basePrice; }
}
public class LabTest implements Service {
public double calculateFee() { return basePrice * 0.9; }
}Replace a long list of related parameters with a single object.
// Before
public List<Bill> findBills(Department dept, LocalDate from, LocalDate to,
BillStatus status, Patient patient) { }
// After
public List<Bill> findBills(BillSearchCriteria criteria) { }Before refactoring, you must have tests that cover the behaviour you are preserving. If tests do not exist, write characterisation tests first — tests that record what the code actually does so you can detect unintended changes.
Each refactoring step should be small enough to verify immediately. Do not combine multiple refactorings into a single change.
After each small change, run the test suite. If a test fails, you know which step introduced the regression.
Mixing refactoring with feature additions makes code review harder and regression investigation harder. Commit the refactoring first (with a refactor: commit message), then make the functional change.
| Not refactoring | Why |
|---|---|
| Rewriting from scratch | Observable behaviour intentionally changes |
| Fixing a bug | Behaviour changes by intent |
| Adding a feature | Behaviour changes by intent |
| Performance optimisation | Internal changes may change observable timing |
| Changing the database schema | External contract change |
Performance optimisation and schema changes may be valuable, but they are not refactoring and must be treated with appropriate care.
Previous: SE-07: Software Architecture
Next: SE-09: Testing Principles