Modernising a System You Cannot Switch Off
Institutional platforms do not get rewrite windows. The constraint is not technical difficulty — it is that the system must keep working every single day while you change it underneath.
Every proposal to rewrite a long-running institutional system assumes a window: a semester break, a quiet period, a weekend. The window never arrives, or it arrives and turns out to be three days long while the migration needs three weeks.
So the real question is not how to build the replacement. It is how to change the existing system continuously, in production, without a day where it does not work.
Start with ownership, not with code
Before writing anything, establish which module owns which data. In an accreted system this is genuinely unclear — three modules write to the same table, each assuming different invariants, and none of them documented.
Answer one question per table: which module is allowed to write this? Everything else reads through a published interface.
This is unglamorous and it is the whole foundation. Without it, every subsequent boundary is drawn on sand, and you will discover the real ownership during an incident.
Migrate forward only
Rollback scripts are a comforting fiction under load. A down migration that drops a column discards the data written since deployment, which means running it is a second incident.
Design so that forward is always available:
-- V23: additive, safe against a running old version
ALTER TABLE exam_schedules ADD COLUMN room_capacity INTEGER;
-- V24: backfill, idempotent, restartable
UPDATE exam_schedules es
SET room_capacity = r.capacity
FROM rooms r
WHERE r.id = es.room_id
AND es.room_capacity IS NULL;
-- V25: constrain, only after the backfill is verified complete
ALTER TABLE exam_schedules ALTER COLUMN room_capacity SET NOT NULL;
Three deployments instead of one. Each is individually safe with the previous application version still running, and none of them requires downtime. If step two reveals bad data, you stop there with a nullable column and nothing broken.
The discipline that makes this work is that a schema change and the code that requires it never ship together.
Strangle at the read path first
When replacing a module, route reads to the new implementation before writes. Reads are recoverable — if the new path returns something wrong, you switch the flag back and no data was harmed.
@Service
@RequiredArgsConstructor
public class ScheduleQueryService {
private final LegacyScheduleGateway legacy;
private final ScheduleRepository repository;
private final FeatureFlags flags;
public List<ScheduleView> findForBatch(UUID batchId) {
if (flags.isEnabled(Feature.SCHEDULE_READ_V2)) {
return repository.findByBatchId(batchId).stream()
.map(ScheduleView::from)
.toList();
}
return legacy.findForBatch(batchId);
}
}
Run both for a period and compare outputs in the background. Discrepancies tell you what the legacy system actually did — which is reliably different from what its documentation, and its authors, believe it did.
Only when reads have been stable for a full business cycle do you move writes.
Instrument before you optimise
Modernisation programmes attract performance assumptions. Someone is certain the bottleneck is the ORM, or the lack of caching, or the database.
Measure first. In my experience the answer is usually a missing index or an N+1 query in a code path nobody suspected, and the architectural work everyone wanted to do would not have touched it.
@Query("""
SELECT DISTINCT p FROM Project p
LEFT JOIN FETCH p.technologies
LEFT JOIN FETCH p.category
WHERE p.published = TRUE
ORDER BY p.displayOrder ASC
""")
List<Project> findPublishedWithDetails();
One query instead of one plus N. Not architecture — just knowing where the time went.
Accept that it takes longer
A staged modernisation takes more calendar time than a rewrite would if the rewrite went perfectly. It also has the property that the system works on every day of the programme, and that partial progress is retained if priorities change or funding stops.
Rewrites are all-or-nothing bets on an estimate. For a platform an institution depends on daily, that is not a trade worth making — and the teams that made it are usually the ones maintaining two systems instead of one.