The three previous cases shared a safety net that the last lesson pointed out on its way out: they were modern systems, with tests, with healthy version control and with teams that could decide how to work. Here that net is removed. Gestor Citas 4 is the company's previous product, the one Reservalia came to replace: a Java/JSP monolith built with Ant and half-migrated to Maven in 2011, deployed onto a Tomcat that lives on a virtual machine with a name of its own, without a single automated test, with manual FTP deployment on Saturday nights, the configuration edited by hand directly on the server, and three large customers still paying 4,200 euros a month for it who are not going to migrate for two years. Nobody wants to touch it and it has to be touched. This lesson is not about choosing between monorepo and polyrepo or tuning a canary: it is about deciding where to start when there is nothing, in what order to build the increments so that each one delivers value on its own even if the next one never arrives, which concrete tactics to apply to each type of debt, how progress is measured when the baseline is one deployment every two months, how the investment is justified to whoever pays for it, and — the most forgotten part — when to stop.
Contents
- Gestor Citas 4: the honest inventory
- Why it is not rewritten and why "tests first" never arrives
- The increment strategy
- Increments 1 and 2: version control and a build in CI
- Increments 3 and 4: characterisation and golden master
- Increment 5: automated deployment even if it stays manual
- Increment 6: containerise to reproduce the environment
- Increment 7: strangler fig
- Specific debt and its tactics
- Metrics when the baseline is one deployment every two months
- The conversation with the business
- When to stop
- Case summary
- Common Mistakes and Tips
- Exercises
- Conclusion and close of the module
- Gestor Citas 4: the honest inventory
| Reservalia (modules 2-4) | Gestor Citas 4 | |
|---|---|---|
| Code | TypeScript, monorepo | Java 8, JSP, Struts 1, 240,000 lines |
| Build | npm ci, reproducible |
Ant + Maven half-way, only works on one machine |
| Tests | Unit, integration, E2E | None automated |
| Deployment | cd.yml, 12 min, 12/week |
Manual FTP, Saturday night, ~1 every 2 months |
| Configuration | Terraform + secrets manager | .properties files edited on the server |
| Environments | dev, staging, prod identical | Prod, and "the test one" that nobody knows matches |
| Database | Versioned migrations | Loose SQL scripts in a shared folder |
| Who knows it | The whole team | One person, and they no longer work here |
| Revenue | 340 businesses | 3 customers, €4,200/month, contract to 2028 |
The last row is what makes this lesson exist. A system with no tests and manual deployment that makes no money gets switched off; one that does make money and has a contract signed through to 2028 has to be kept in decent shape. And the second-to-last row explains why it is so frightening: there is nobody to ask, so the code is the only documentation and every change is made blind.
What happens today when something has to change: two days of development, half a day of manual testing by somebody who opens the application and clicks around, and a three-hour Saturday night during which a .war is uploaded by FTP, Tomcat is restarted and prayers are said. Over the last twelve months, two of the six deployments failed and recovery meant manually copying the previous .war from a folder called backup_ok_final.
- Why it is not rewritten and why "tests first" never arrives
The rewrite is the answer everybody proposes and almost nobody finishes. The numbers for Gestor Citas 4: 240,000 lines, twelve years of accumulated business rules, no written specification, and behaviours the three customers use daily without anybody knowing they exist — the quarterly billing report one customer exports in a specific format, the SFTP integration with another one's accounting system. A rewrite would have to reproduce all of that blind, and while it lasts, the old system still needs changes that have to be made twice. It is the classic scenario in which a nine-month project turns into three years and gets cancelled with both versions half-finished.
The second habitual answer also fails, and more subtly: "first we write tests, and once we have coverage we set up CI/CD". It never arrives, for three concrete reasons. The code is not testable — business logic inside JSPs, new database connections inside methods, statics everywhere — so writing the first unit test demands refactoring, and refactoring without tests is exactly what you were trying to avoid. It is a closed loop. On top of that, writing tests produces no visible benefit for the business, so the task always loses against any request from a customer paying 4,200 euros a month. And finally, the most urgent problem is not the lack of tests: it is that nobody knows how to rebuild the system, and that is fixed sooner and more cheaply.
Hence the inversion of order that governs the whole lesson: you do not start with tests, you start with reproducibility. Each increment must satisfy three conditions — deliver value on its own even if the next one is never done, not require touching the business code, and be reversible — and be ordered by the ratio of value to risk.
- The increment strategy
flowchart TD
I1["1 · Version control<br/>and a local build"] --> I2["2 · Build in CI<br/>compiles = the first signal"]
I2 --> I3["3 · Characterisation<br/>E2E smoke over what makes money"]
I3 --> I4["4 · Golden master<br/>for what cannot be tested"]
I4 --> I5["5 · Automated deployment<br/>even if triggered by hand"]
I5 --> I6["6 · Containerise<br/>reproduce the environment"]
I6 --> I7["7 · Strangler fig<br/>move functionality out"]
I2 -.->|"value: you can stop here"| P1["Valid stopping point"]
I5 -.->|"value: you can stop here"| P2["Valid stopping point"]
The two "valid stopping points" are deliberate and we will come back to them in section 12: there are products for which reaching increment 2 is already a success, and many for which 5 is the reasonable final destination. A modernisation plan that only delivers value if it is completed in full is a plan that is going to fail, because priorities will change before it is finished.
- Increments 1 and 2: version control and a build in CI
Increment 1 — Get it into git and rebuild it outside "the machine". The real starting point was a folder on a network drive containing GestorCitas_v4_2_FINAL, GestorCitas_v4_2_FINAL_2 and GestorCitas_v4_2_parcheJulio. The work, two weeks of archaeology: identify which version corresponds to the .war currently in production — comparing dates and decompiling two doubtful classes — create the repository with that as the first commit, and rebuild the build on a clean machine, noting down everything that is missing. This produces the first list of real debt: three JARs that are in no public repository and existed only in the local lib/, a GC_HOME environment variable somebody set by hand years ago, and an Ant task that copies files from an absolute path on the C drive.
The success criterion for this increment is exact and verifiable: the .war built from the repository on a clean machine has the same functional content as the one in production. Comparing it whole byte by byte is no good — the timestamps inside the ZIP always differ — so the class lists and the resource hashes are compared instead. When that lines up, the most valuable property that had been lost has been recovered: knowing which code is running.
Increment 2 — Let CI build it. Without a single test:
name: gc4-ci
on: { push: {}, pull_request: {} }
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '8', cache: maven } # 1
- name: Compile and package
run: mvn -B -s .mvn/settings.xml clean package -DskipTests # 2
- name: Publish the artifact
uses: actions/upload-artifact@v4
with:
name: gc4-${{ github.sha }} # 3
path: target/gestorcitas.war
retention-days: 90- Java 8 explicitly, because the code does not compile with later versions and finding that out through a cryptic error costs an afternoon. Upgrading the Java version is a separate project, not a prerequisite for having CI.
-s .mvn/settings.xmlpoints at the internal repository where the three orphaned JARs have been uploaded (section 9).-Bswitches off interactive mode, which in CI hangs the job waiting for an answer nobody will give.- The
.waris published as an artifact with the commit SHA. That is an immutable, traceable artifact (02-06) without having changed a single line of the product: for the first time in twelve years, "which version is in production?" can be answered with an identifier rather than a date.
It sounds like very little and it is a great deal. "It compiles" is the first automatic signal this project has ever had, and from day one it catches the kind of error that used to be discovered at eleven o'clock on a Saturday night. In Gestor Citas 4 it found, in the first two months, three commits that did not compile because somebody had edited a JSP directly on the server and then copied it into the repository by hand.
- Increments 3 and 4: characterisation and golden master
Now the tests do arrive, and they arrive from where nobody expects: not from below with unit tests, but from above with end-to-end smoke tests. The reason is that tests from above do not require touching the code, and touching the code is precisely what cannot yet be done safely. The pyramid from 02-04 is still the long-term goal, but in a legacy system you start at the tip and work down.
Characterisation tests have a different purpose from normal tests: they do not verify that the system does the right thing, they verify that it does exactly what it did before. If there is odd behaviour, the test documents it as it is, bug included, because a customer may have been depending on it for eight years.
// src/test/java/characterisation/BookingSmokeTest.java
@Test
public void booking_a_free_slot_creates_appointment_and_email() {
driver.get(BASE + "/login.do");
login("demo@customer1.test", "demo");
driver.get(BASE + "/schedule.do?date=2026-10-25");
driver.findElement(By.cssSelector("td[data-time='10:30'] a")).click(); // 1
driver.findElement(By.name("customerName")).sendKeys("Fictitious Customer 1");
driver.findElement(By.name("save")).click();
assertTrue(driver.getPageSource().contains("Appointment saved successfully")); // 2
assertEquals(1, countRows("SELECT 1 FROM APPOINTMENTS WHERE TIME='10:30'")); // 3
}- Selectors based on existing data attributes, because the HTML generated by JSP is fragile and is not going to be redesigned. In a legacy system you do not refactor the view to make it testable; you take it as it is.
- The exact text the user sees is checked, however ugly it may be. The test documents actual behaviour, not desirable behaviour.
- The effect on the database is verified too, because in this system there is logic inside stored procedures and a check through the interface alone misses half of it.
How many? Between six and ten, and chosen by revenue, not by coverage. In Gestor Citas 4: logging in, creating an appointment, cancelling it, the day's listing, the quarterly billing report — the one a customer exports every three months and which, if it breaks, generates a call from the director — and the SFTP export. Eight tests cover what makes three customers pay; the resulting line coverage will be 11% and it does not matter in the slightest.
Increment 4 — Golden master for what does not admit a unit test. The billing calculation is 1,800 lines in a class with statics, direct database access and System.out. Isolating it to test it demands refactoring; refactoring it without tests is the frightening part. The technique that breaks the loop:
@Test
public void quarterly_invoice_does_not_change() throws Exception {
for (String testCase : listCases("src/test/resources/cases/")) { // 1
loadData(testCase);
String output = LegacyBilling.generateReport(2026, 3); // 2
String expected = read("src/test/resources/golden/" + testCase + ".txt");
assertEquals("Case " + testCase, normalise(expected), normalise(output)); // 3
}
}- Many input cases, captured from anonymised real data (04-06): thirty scenarios covering discounts, pro-rating, split months and the odd cases that turned up over twelve years.
- The complete output is compared against a reference file generated with the current code. Nobody has read the 1,800 lines or decided what is correct: what it does today is frozen.
normalisestrips out what changes between runs — issue date, report number — because otherwise the test always fails. It is the same masking as in the visual regression of 05-01.
From then on, the class can be refactored with a real net: any behaviour change produces an exact diff of the affected line. And an important warning: the golden master freezes the bugs too. When one is discovered and the decision is taken to fix it, the reference file is updated deliberately and in a separate PR, with the diff visible in review. That diff is the best behavioural documentation this system has ever had.
- Increment 5: automated deployment even if it stays manual
Here is the greatest return per unit of effort in the whole lesson, and the idea that is hardest to accept: automating deployment does not mean deploying automatically. You can have a fully scripted, repeatable deployment with a way back that is still triggered by a person on a Saturday. Human review and control are preserved and the part that fails disappears: manual execution.
Let us compare the old Saturday with the new one:
| Before | After | |
|---|---|---|
| Who decides | One person | The same person |
| How it runs | FTP, clicks, restart by hand | workflow_dispatch with the SHA to deploy |
| What gets deployed | The .war that was in the folder |
The exact artifact CI built |
| Prior backup | Sometimes, with a made-up name | Always, versioned and with retention |
| Configuration | Edited by hand on the server | Template + per-environment values |
| Way back | Copy the old .war, if you can find it |
A workflow_dispatch with the previous SHA |
| Duration | 3 hours | 7 minutes |
| Verification | Open the site and look | Automatic smoke test |
name: gc4-deploy
on:
workflow_dispatch: # 1 · a person triggers it
inputs:
sha: { description: 'SHA to deploy', required: true }
environment: { description: 'test | prod', required: true, default: test }
jobs:
deploy:
runs-on: ubuntu-22.04
environment: gc4-${{ inputs.environment }} # 2 · reviewers for prod
steps:
- uses: actions/download-artifact@v4 # 3 · nothing is rebuilt
with: { name: gc4-${{ inputs.sha }}, github-token: '${{ secrets.GITHUB_TOKEN }}',
run-id: '${{ inputs.sha }}' }
- name: Back up the current war
run: |
ssh "$HOST" "cp /opt/tomcat/webapps/gc.war \
/opt/backups/gc-\$(date +%F-%H%M).war" # 4
- name: Generate the configuration from the template
run: envsubst < config/gc.properties.tpl > gc.properties # 5
env:
DB_URL: ${{ secrets.GC4_DB_URL }}
DB_PASS: ${{ secrets.GC4_DB_PASS }}
- name: Deploy and restart
run: |
scp gestorcitas.war "$HOST:/opt/tomcat/webapps/gc.war.new"
scp gc.properties "$HOST:/opt/gc/conf/gc.properties"
ssh "$HOST" "systemctl stop tomcat && \
mv /opt/tomcat/webapps/gc.war.new /opt/tomcat/webapps/gc.war && \
systemctl start tomcat" # 6
- name: Smoke test
run: |
for i in $(seq 1 30); do
curl -fsS "https://$DOMAIN/gc/health.jsp" && exit 0 # 7
sleep 5
done
echo "The service did not respond after 150 s"; exit 1workflow_dispatchkeeps the decision in a person's hands. It is the intermediate step between manual work and continuous deployment, and for this product it may well be the final destination.- The GitHub environment with reviewers (03-02) replaces "ping Marta on Slack before uploading". Who approved and when is recorded.
- The artifact is downloaded, not rebuilt. Even though the pipeline is rudimentary, the build-once rule (02-06) applies from day one: it is free and it removes a whole class of surprises.
- The backup is part of the deployment, with a deterministic name rather than
backup_ok_final. It is what makes the way back real. - The configuration is generated from a versioned template and the values come from secrets. With this, the
.propertiesedited by hand on the server disappears — the reason "the test one" bore no resemblance to production. - Stop, replace, start. It is not elegant and there is about 40 seconds of downtime, but it is deterministic. A zero-downtime deployment for this system would require a second server and a load balancer; that is a later improvement, not a prerequisite for automating. This is where it pays to resist the temptation to do everything properly on the first attempt.
- The smoke test with retries (03-02) turns "open the site and look" into an objective verification that also decides whether the job finishes green.
Measured result in Gestor Citas 4: the deployment went from three hours to seven minutes, the way back from "half an hour hunting for the right .war" to a seven-minute workflow_dispatch, and Saturday nights ceased to exist because a seven-minute deployment with a verified way back can be done on a Tuesday at five in the afternoon. That last consequence is the one the team really noticed.
- Increment 6: containerise to reproduce the environment
With deployment automated, one problem remains open: the server is unique and irreproducible. Nobody knows which version of Tomcat is running, what JVM parameters it has, or what was installed by hand in 2019. Putting the legacy system into an image does not modernise it, but it turns "the server" into a versioned file.
FROM tomcat:8.5-jdk8-temurin # 1
RUN rm -rf /usr/local/tomcat/webapps/* # 2
COPY docker/server.xml /usr/local/tomcat/conf/server.xml
COPY docker/setenv.sh /usr/local/tomcat/bin/setenv.sh # 3
COPY target/gestorcitas.war /usr/local/tomcat/webapps/gc.war
ENV JAVA_OPTS="-Xms512m -Xmx2048m -Duser.timezone=Europe/Madrid" # 4
HEALTHCHECK --interval=30s CMD curl -fsS http://localhost:8080/gc/health.jsp || exit 1- The exact version of Tomcat and Java is pinned on the first line, which permanently answers a question that previously required an SSH session into the server.
- The sample applications the image ships with are deleted, since they are a known attack surface. It is the least privilege of 04-03 applied to what you inherit.
- The container's configuration lives in the repository and goes through review, instead of being edited inside the machine.
- The explicit time zone deserves a separate mention: in an appointment system, letting the JVM take the host's zone is a classic source of bugs that only show up when the server moves. Pinning it here plugs a hole that had been open for twelve years.
The immediate benefits are not about deployment but about development and testing: anyone can bring the complete system up with docker compose up — application plus database with synthetic data — and the characterisation tests from increment 3 start running in CI against that ephemeral environment instead of against a shared server that is sometimes down. That is when the eight smoke tests go from being a manual ritual to an automatic gate.
An honest warning: containerising a legacy system does not always go well. If the application writes to absolute paths on disk, depends on a local printer or keeps state on the server's file system, the container exposes it and it has to be resolved. That is good in the medium term — it is debt that was hidden — but it can turn a two-week increment into a two-month one. It is worth exploring with a proof of concept before committing to dates.
- Increment 7: strangler fig
The last increment does not modernise the legacy system: it starts taking functionality out of it. The strangler fig pattern consists of putting an intermediary in front that routes by path, and moving features one by one to new services with modern CI/CD, until the old one is left empty or keeps only the little that is not worth moving.
flowchart LR
U["Customers"] --> R["Reverse proxy<br/>routes by path"]
R -->|"/gc/reports/*"| NEW["Reports service<br/>modern pipeline"]
R -->|"everything else"| OLD["Gestor Citas 4<br/>Tomcat"]
NEW --> BD[("Shared DB<br/>read only")]
OLD --> BD
location /gc/reports/ {
proxy_pass http://reports-new:8080/; # 1
}
location /gc/ {
proxy_pass http://tomcat-legacy:8080/; # 2
}- One specific path goes to the new service, with its full pipeline from modules 2 and 3. Reverting means changing one line of proxy configuration and reloading: the cheapest rollback there is, and it is what makes the pattern safe.
- Everything else stays in the legacy system, none the wiser.
What to take out first, with judgement: what changes most — if the billing report is touched every quarter and everything else is frozen, moving the report pays off on every change — what has clear boundaries and little shared logic, and what can be read without writing, because a read-only service over the same database avoids the hard problem of double writes. What you do not take out first: the core of the schedule, which is the most coupled and the riskiest part.
And the trap that has to be named: the intermediate phase, with two systems coexisting, is more complex than either of the two extremes. You have to decide in advance how far you go and by when, because a half-finished strangulation abandoned for years is worse than the original monolith. With a product serving three customers and a contract to 2028, the reasonable answer in Gestor Citas 4 was to move two specific features and no more.
- Specific debt and its tactics
| Debt | Symptom | Tactic |
|---|---|---|
| JARs with no public repository | The build only works with one machine's lib/ |
Upload them to an internal repository (04-02) with their checksum; document origin and version |
| No lockfile equivalent | LATEST and open ranges in the POM |
Pin every version to an exact value, including transitive ones with dependencyManagement |
| Machine-dependent build | Absolute paths, GC_HOME, local Ant tasks |
Replace with paths relative to the project; CI on a clean machine verifies it on every commit |
| Secrets in versioned files | DB password in gc.properties in git |
Rotate first, then extract to secrets and a template; assume the history is compromised |
| DB without migrations | A folder of loose SQL and an order held in someone's memory | Adopt Flyway with baseline over the existing schema |
| No reliable test environment | "The test one" differs from prod | Containerise (increment 6) and bring it up ephemerally |
| Java 8 and unsupported libraries | Known vulnerabilities with no patch | Scan (04-03), prioritise by real exploitability, not by count |
Two deserve elaboration, because they have a specific trap.
Versioned secrets. The instinct is to delete the line and commit. That achieves nothing: the password is still in the git history and anyone with access to the repository can recover it. The correct order is to rotate first — change the password in the database and deploy the new one through the increment 5 mechanism — then extract the value into the template, and only then decide whether rewriting the history is worth it. With an internal repository and controlled access, it almost never is; what you must do is add the secret scanning from 04-03 to CI so that none ever gets in again.
Database without migrations. You cannot start from scratch: there is a schema in production with twelve years of data. Adoption is done with baseline:
# 1 · mark the current schema as the starting point, without running anything
flyway -url="$JDBC_URL" -baselineVersion=4.2.0 \
-baselineDescription="Existing GC4 schema" baseline-- 2 · from here on, every change is a versioned file
-- db/migration/V4.2.1__appointments_date_index.sql
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_appointments_date ON APPOINTMENTS(APPOINTMENT_DATE);baselinedoes not touch the schema: it creates Flyway's control table and records that version 4.2.0 is "already applied". It is the exact equivalent of theapplied_migrationstable from 04-06, adopted half-way through.- From that point on, the whole discipline of 04-06 applies unchanged: versioned files, review, execution from the pipeline,
CONCURRENTLY,lock_timeoutand expand and contract for any incompatible change. The historical schema is neither documented nor recreated; it is simply declared the starting point and you move on from there. One practical detail: it is worth dumping the current schema (pg_dump --schema-only) into the repository asV4.2.0__baseline.sqlnot executable but versioned, so there is a readable reference for where things started.
- Metrics when the baseline is one deployment every two months
The DORA metrics from 01-05 still hold, but comparing Gestor Citas 4 with Reservalia tells you nothing: they are products with different purposes. The useful comparison is against itself.
| Metric | Before | After increment 2 | After increment 5 | 12-month target |
|---|---|---|---|---|
| Deployment frequency | 1 / 2 months | 1 / 2 months | 2 / month | 1 / week if needed |
| Lead time (commit → prod) | ~45 days | ~45 days | 6 days | 3 days |
| Change failure rate | 33% (2 of 6) | 33% | 11% | < 15% |
| Time to restore | ~2 h, uncertain | 2 h | 7 min | 7 min |
| Deployment duration | 3 h | 3 h | 7 min | 7 min |
| "Compiles in CI" | ❌ | ✅ | ✅ | ✅ |
| "Can be rebuilt on a clean machine" | ❌ | ✅ | ✅ | ✅ |
Three readings. First: increment 2 moved no DORA metric at all and was still the most valuable of the lot, because it made the others possible. That is why the last two rows — binary, not numeric — appear in the table: in a legacy system, the useful metrics of the early phases are capabilities acquired, not numbers. Measuring only DORA at this stage makes the foundational work look useless, and it is the fastest way for the business to cancel it.
Second: a change failure rate of 11% would be unacceptable at Reservalia and here it is a resounding success, because it came down from 33%. Metrics are compared with your own baseline, never with another product's or with the industry's.
Third: the twelve-month target is not "deploy ten times a day". It is "be able to deploy within a week when needed". For a product in maintenance with three customers, the ability to respond quickly to a problem is worth far more than frequency, and confusing the two leads to investing in the wrong place.
- The conversation with the business
None of these increments gets funded by talking about technology. What does not work: "we need CI/CD", "the code is a disaster", "we have technical debt". These are statements whoever holds the budget cannot evaluate, and they sound like a team preference.
What does work is translating into risk, cost and response time:
| Instead of saying | Say |
|---|---|
| "There are no automated tests" | "Every change costs us half a day of manual testing and even so two of the last six deployments failed" |
| "The build is not reproducible" | "If the laptop of whoever builds it breaks, we cannot ship an urgent fix. Today we do not know how long it would take us to recover" |
| "We need to automate deployment" | "We go from three hours on a Saturday to seven minutes on a Tuesday; an urgent failure gets fixed the same day instead of waiting for the window" |
| "The system has technical debt" | "A change that costs two days at Reservalia costs two weeks here. On €4,200/month of revenue, every request eats the margin" |
| "The secrets are in git" | "A customer's database password is visible to anyone with repository access; this is a notifiable issue" |
And three tactics that worked in Gestor Citas 4. Tie each increment to a real incident that happened: the failed March deployment that left a customer without service for four hours justifies increment 5 better than any abstract argument. Ask for small, demonstrable slices: two weeks for increment 1, with a showable result — "here is the system building itself" — rather than a six-month project. And present the risk of "doing nothing" with a number: if the contract is worth €4,200/month to 2028, that is around €100,000 at risk; thirty days of work to substantially reduce the chance of losing it is an easy decision when it is framed that way.
Marta: "You do not sell the business a pipeline. You sell them the disappearance of Saturday night."
- When to stop
The question almost no material on modernisation answers. Gestor Citas 4 is not going to have continuous deployment, or a canary, or feature flags, or distributed observability, and that is a correct decision, not a surrender. The reasonable level of automation depends on three variables:
| Variable | Gestor Citas 4 | Reservalia |
|---|---|---|
| Change frequency | 1-2 a month | Daily |
| Expected remaining life | 2 years (contract to 2028) | Indefinite |
| Cost of a failure | 3 customers, high per customer | 340+ businesses |
| People who touch it | 1, part-time | 3, full-time |
| Reasonable level | Up to increment 5-6 | The whole of module 4 |
The rule, phrased so you can take it away with you: automation is justified by frequency of use multiplied by remaining life. An automatic canary that would prevent one incident every two years, in a system that is switched off in two years, never pays for itself. An automated deployment used twice a month for two years — 48 uses, saving nearly three hours each — pays for itself in the second month.
The signals that it is time to stop are concrete: when the next increment costs more than the problem it solves; when the improvement affects something that never changes — automating the tests of a module nobody has touched in five years is wasted work; when there is a credible, signed switch-off date; and when the team starts modernising for technical pleasure rather than for a measurable problem. That last one is the hardest to recognise from the inside, which is why every increment should be born with its justification written in the terms of section 11: if you cannot draft the sentence from the right-hand column of that table, that increment probably is not the one to do.
- Case summary
| Context | Java 8/JSP monolith from 2011, 240,000 lines, no tests, manual FTP, 3 customers, €4,200/month |
| What still holds as-is | Immutable artifact (02-06), environments with reviewers (03-02), smoke test (03-02), security scanning (04-03), versioned migrations (04-06) |
| Decision 1 | Do not rewrite; do not wait for tests; start with reproducibility |
| Decision 2 | Seven increments, each with value of its own and two declared valid stopping points |
| Decision 3 | Eight characterisation tests chosen by revenue, not by coverage; golden master for what cannot be tested |
| Decision 4 | Automated deployment but triggered by hand with workflow_dispatch and reviewers |
| Decision 5 | Flyway with baseline over the existing schema; secrets rotated before being extracted |
| Decision 6 | Strangler fig limited to two features, with a written deadline |
| Cost | ~30 days of work spread over 8 months |
| Effect on DORA | Lead time 45 → 6 days; CFR 33% → 11%; restore 2 h → 7 min; deployment 3 h → 7 min |
| What you take to any project | The first increment is not the tests: it is being able to rebuild the system and know what is deployed |
Common Mistakes and Tips
Mistake 1: proposing a complete rewrite. With 240,000 lines and no specification, it is the project that lasts three years and gets cancelled with two half-finished systems. Mistake 2: waiting for coverage before setting up CI; the code is not testable, refactoring it without tests is what you are trying to avoid, and the loop does not break on its own. Mistake 3: starting with unit tests rather than with end-to-end smoke tests, which are the only thing that does not require touching the code.
Mistake 4: fixing bugs discovered during characterisation. A characterisation test documents what the system does, errors included; fixing them is a separate task, with its own decision and its own PR. Mistake 5: deleting a secret from the file and committing in the belief that it is resolved: it is still in the history and unrotated. Mistake 6: attempting zero-downtime deployment from the start, when 40 seconds of downtime is perfectly acceptable in a system deployed twice a month.
Mistake 7: measuring only DORA in the early phases, which makes the foundational work look useless and gets it cancelled just before it bears fruit. Mistake 8: comparing the legacy system's metrics with the modern product's instead of with its own baseline. Mistake 9: leaving a strangler fig half-finished with no written deadline or scope: the intermediate phase is more complex than either extreme. Mistake 10: carrying on modernising for technical pleasure a system with a switch-off date.
Tip 1: the first success criterion is rebuilding the production artifact from the repository on a clean machine. Tip 2: choose the smoke tests by revenue, not by coverage; eight well chosen are worth more than two hundred scattered ones. Tip 3: automate the deployment before the trigger, which is where almost all the benefit lies. Tip 4: write the business justification for each increment before starting it; if the sentence will not come out, that increment is not the one to do.
Exercises
Exercise 1
You join to maintain Gestor Citas 4 and in the first week an urgent request arrives: a customer needs a new field in the quarterly billing report for next month's close. There are no tests, the build only works on a laptop that no longer exists and the last deployment was seven weeks ago. Describe what you do in the first two weeks, in what order and why, distinguishing what is essential to deliver the request from what is investment.
Exercise 2
During increment 4, the golden master of the billing calculation reveals that a volume discount is applied twice when the customer has more than three sites, something that has been happening for at least four years. One of the three customers has five sites. Analyse the technical and business situation and propose a complete action plan.
Exercise 3
After completing increment 5, a colleague proposes going all the way: containerise, set up continuous deployment with a canary, add full observability with SLOs and an error budget, and start the strangler fig on the scheduling module. Estimate the effort and benefit of each proposal using the criteria from section 12 and give a prioritised recommendation.
Solutions
Solution 1. The key is to separate delivering from improving, and to accept that you cannot do everything. With the request on the table, the order I would follow:
Days 1-3 — Rebuild the build (essential, not investment). Without being able to produce a .war there is no possible delivery, so this is not optional even if it looks like background work. The code corresponding to production is recovered, the repository is created, and every obstacle is documented: the orphaned JARs go to an internal repository, the absolute paths are replaced with relative ones, the environment variable is declared in the POM. The stopping criterion is concrete: the built .war has the same classes and resources as the deployed one. Days 3-4 — CI that compiles. Half a day's work, and from then on every commit produces an artifact identified by SHA. It is cheap and it transforms the rest of the two weeks: there is no longer any doubt about what is being built. Days 4-6 — Golden master of the report, and only the report. Here is the most important decision in the exercise: since the change touches the billing calculation, its current output is frozen over twenty anonymised data cases before touching a line. This is not "investment for the future": it is the only way of knowing that the new field does not alter the existing amounts. Without it, verification would consist of somebody looking at a PDF and thinking it looks fine. Days 6-9 — Implement the change, with the golden master running on every iteration. Any difference other than the new field shows up as an exact diff. When the change is correct, the reference file is updated deliberately, in a separate commit and with the diff visible. Days 9-11 — Automate the deployment. The first deployment after seven weeks is the highest-risk moment of the year, so scripting workflow_dispatch + backup + smoke test pays for itself in this very delivery. It is rehearsed at least once against the test environment beforehand. Days 11-12 — Deploy on a Tuesday afternoon, with the smoke test verifying and the way back tested in advance.
What is essential and what is investment. Essential: the reproducible build and the report's golden master. Investment that pays for itself within these same two weeks: CI and automated deployment. Investment I would not do now and would write down for later: the full eight smoke tests, containerisation, extracting secrets — unless I find a critical one, in which case I rotate it immediately — and any refactoring of the business code. And a rule I would apply from day one: none of this is done on the same branch as the functional change, so the delivery does not depend on the modernisation going well.
Solution 2. Technically, the discovery is proof that the golden master works: in one afternoon it found an error that had gone undetected for four years. But the crude mistake would be to fix it there and then. A four-year-old billing bug is not a technical decision.
Step 1 — Quantify before deciding anything. With a query over the historical data: which customers have more than three sites, in how many quarters the duplicated discount was applied and how much the difference adds up to. Without that figure no decision can be taken, and the conversation degenerates into opinions. Suppose a plausible result: one affected customer, sixteen quarters, around €9,400 under-billed — the duplicated discount favours the customer.
Step 2 — Freeze the current behaviour in the golden master, with an explicit comment. The reference file is generated with the bug included and a note is added in the test code: what it is, since when, and that its correction is pending a business decision. This makes it possible to carry on working in the module without dragging a red test around, which would end with everybody ignoring it.
Step 3 — Escalate it with the data. It goes to the business and to whoever manages the customer relationship; it is not decided within the team. The options, with their consequences: (a) fix forward and not claim the past — the most common: the amount is bearable, it avoids an unpleasant conversation with a customer who contributes a third of the product's revenue, and it has to be assessed whether under-billed amounts can legally be claimed retroactively; (b) fix and regularise, which requires legal and accounting review; (c) do not fix, which is only defensible if it is documented as agreed behaviour, and it is the worst option because the bug will resurface in any future review.
Step 4 — Execute the decision as an explicit behaviour change. Whatever it is, the fix goes in a PR of its own whose main content is the diff of the golden file, showing exactly which amounts change and in which cases. That diff is the evidence shown to the business for approval, and it stays in the history with a date, an author and a reason. It is deployed with the increment 5 mechanism and verified against a known real case before the next quarterly close.
Step 5 — Learn from the finding. If a billing error has survived four years, the interesting question is what else is there. It is worth extending the golden master's cases to the less frequent scenarios — pro-rating, sign-ups and cancellations mid-quarter, tariff changes — before a customer discovers them. It is the best return available in this system and it comes practically free, because the machinery is already in place.
Solution 3. Applying frequency of use × remaining life, with two years of contract ahead and 1-2 deployments a month:
| Proposal | Effort | Benefit in this context | Verdict |
|---|---|---|---|
| Containerise | 2-4 weeks (risk of absolute paths) | Reproducible environment; automatic smoke tests in CI; eliminates "the magic server" | Yes, priority 1 |
| Eight smoke tests in CI | 1-2 weeks | Automatic verification on every change; it is what is missing for increment 5 to pay off | Yes, priority 2 |
| Continuous deployment with a canary | 4-6 weeks + new infrastructure | It would be used ~24 times in two years; requires a second server and a load balancer; 40 s of downtime is already acceptable | No |
| Full observability with SLOs and an error budget | 3-5 weeks | An error budget governs deployment cadence… which here is 2 a month: it governs nothing | No — but yes to a minimal version |
| Strangler fig on the schedule | 3-6 months, high risk | The schedule is the most coupled core; with two years of life it does not pay off | No |
Prioritised recommendation. First containerise, because it unblocks the rest: without an environment you can bring up there are no reliable smoke tests in CI, and it also removes the risk of losing the irreproducible server, which is the real continuity threat to this product. With a three-day proof of concept before committing to a date, given what the lesson warns about absolute paths and state on disk. Second, the eight smoke tests running in CI against that ephemeral environment: it is what turns increment 5's deployment into a verified deployment, and it is the natural complement to what has already been done. Third, a minimal version of observability that is not the one from 03-06: basic availability and latency metrics, symptom-based alerting — "the application is not responding" — and log retention. It costs days, not weeks, and it answers the question that has no answer today: how long does it take us to find out it is down. The canary, the error budget and the strangler fig on the schedule are ruled out, and it is worth writing the reason into the decision so it does not come up again every quarter. The sentence that sums up the criterion: this product's goal is not to deploy more, it is to be able to deploy well when needed, and from increment 6 onwards that capability is already achieved.
Conclusion and close of the module
Gestor Citas 4 has been the most uncomfortable case because it removes every favourable condition at once, and precisely for that reason it teaches what the other three cannot. The first lesson is about order: you do not start with tests, you start with reproducibility, because the loop "you need to refactor in order to test, and to test in order to refactor" cannot be broken from the inside, and because the most urgent problem was not the lack of coverage but that nobody knew how to rebuild the system. The second is about structure: seven increments, each with value of its own even if the next never arrives, and two valid stopping points declared in advance. The third is about technique: end-to-end smoke before unit tests, a golden master to freeze what does not admit a test — bugs included, which are fixed separately and deliberately — deployment automated even if still triggered by hand, containerisation to turn an irreproducible server into a versioned file, and a strangler fig with written scope and deadline. The fourth is about measurement: in the early phases the useful metrics are binary capabilities — "compiles in CI", "can be rebuilt on a clean machine" — and not DORA numbers, and when the numbers do arrive they are compared with your own baseline and nobody else's. And the fifth is the one that saves the most projects: knowing when to stop, because automation pays for itself through frequency of use multiplied by remaining life, and in a product with an expiry date there is a level beyond which carrying on is technical pleasure and not engineering.
That closes the module. Four contexts that look nothing like one another, put through the same system:
| Web (05-01) | Mobile (05-02) | Microservices (05-03) | Legacy (05-04) | |
|---|---|---|---|---|
| Deployable unit | Static files | Signed binary in a store | 5 independent services | One .war |
| Who controls delivery | You | The user and the store | Each team | You, on a Saturday |
| Rollback | Repoint the HTML: <1 min | Does not exist: forward only | One per service | workflow_dispatch: 7 min |
| Characteristic gate | Bundle budget, Lighthouse | Staged rollout with a crash-free pause | can-i-deploy |
"It compiles" |
| Compatibility problem | Yesterday's tabs | Dozens of live versions | Contracts between pairs and events | None: one client, your own |
| Dominant cost | Quality gates | macOS runners and review | 2 platform people | The initial archaeology |
| What changed most | Configuration moved to runtime | Detecting earlier instead of reverting | Buying the signal with contracts | The order of the increments |
And what was invariant across all four, which is the answer to what to take away when the context resembles none of those seen:
- An immutable artifact, built once and promoted. A hashed
dist/, a signed AAB, five images by digest or a.warwith the commit SHA: in all four cases it is built once and moved, never rebuilt in order to deploy. - Traceability of what is deployed and where it came from. The version in the web footer, the
versionNamein the crash report,git logof the deployments repository, the legacy system's SHA-identified artifact. The first question of any incident is "what changed?", and it always had an objective answer. - Feedback as fast and as far left as possible. When the delivery channel is slow — a store, a review, the Saturday window — the investment goes not into recovering fast but into detecting earlier: beta, staged rollout, contracts, automatic smoke tests, budgets.
- Everything as code and under review. Workflows, templates, infrastructure, event schemas, environment configuration, migrations. In no case did anything stay edited by hand on a server, and where it did, removing it was one of the first increments.
- Backward compatibility as a permanent discipline. Open tabs, apps a year old, consumers that have not migrated yet: the form changes, the obligation does not. Expand and contract (04-06) appeared in all four cases in a different disguise.
What was specific, by contrast, was everything else: where the configuration lives, whether there is rollback or only roll-forward, whether the gate is a bundle budget or a can-i-deploy, whether the destination is continuous deployment or a hand-triggered workflow_dispatch. And that is the module's conclusion: the principles are not negotiable, the decisions are taken by looking at the context, and knowing how to tell one from the other is what separates applying CI/CD from copying somebody else's pipeline.
With the principles settled and four contexts covered, what remains is the detail of the machinery. So far the tool has almost always been GitHub Actions, with occasional excursions when the case called for them, and always as a means rather than a subject. Module 6, Tools and Technologies, inverts the focus: it goes into the detail of Jenkins, GitLab CI/CD, CircleCI, Travis CI, Docker and Kubernetes and GitHub Actions in depth, each with its execution model, its syntax, its strengths and its real limits, and finishes with a comparison and criteria for choosing. It starts with Jenkins, which is the one you will most often find already installed when you arrive at a project, and the one that best explains why the rest of the tools were designed the way they were.
CI/CD Course: Continuous Integration and Deployment
Module 1: Introduction to CI/CD
- Basic CI/CD Concepts
- Benefits of CI/CD
- Popular CI/CD Tools
- The Course Project: the Application We Are Going to Automate
- DORA Metrics: How Software Delivery Is Measured
Module 2: Continuous Integration (CI)
- Introduction to Continuous Integration
- Setting Up a CI Environment
- Build Automation
- Automated Testing
- Code Quality and Static Analysis
- Artifacts, Versioning and Promotion
- Integration with Version Control
Module 3: Continuous Deployment (CD)
- Introduction to Continuous Deployment
- Deployment Automation
- Infrastructure as Code and Reproducible Environments
- Deployment Strategies
- Feature Flags, Rollback and Failure Recovery
- Monitoring and Feedback
Module 4: Advanced CI/CD Practices
- CI/CD Pipelines
- Dependency Management
- Security in CI/CD
- Scalability and Performance
- Pipeline as Code: Templates, Reuse and Testing the Pipeline
- Databases in the Pipeline: Safe Migrations
Module 5: Implementing CI/CD in Real Projects
- Case Study: Web Project
- Case Study: Mobile Application
- Case Study: Microservices
- Case Study: Modernising a Legacy Project
Module 6: Tools and Technologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker and Kubernetes
- GitHub Actions in Depth
- Comparison and Criteria for Choosing a Tool
Module 7: Practical Exercises
- Exercise 1: Setting Up a Basic Pipeline
- Exercise 2: Integrating Automated Tests
- Exercise 3: Deploying to a Production Environment
- Exercise 4: Monitoring and Feedback
- Exercise 5: Hardening the Pipeline with Security and Secrets
- Final Project: A Complete End-to-End Pipeline
