ExerciseProduction incident
Production incident
The audit record that was edited after the fact
45 minintermediate2–10 yrs
A real incident: symptom first, cause hidden, tradeoff at the end.
The incident
AuditEntry is the compliance record of who changed what and when. It is
written once and must never change afterwards — that is the entire point of
an audit trail, and the class was written to be immutable: every field is
private final and there is not a single setter.
An external auditor re-ran last month's export and found it did not match
the export taken at the time. Two differences:
1. Some entries have fewer changes recorded than when they were written.
2. Some timestamps have moved by hours.
The database was not touched. Both mutations happened in memory, in the
reporting code — which is documented as read-only and does not assign to
any field of AuditEntry.
Fix AuditEntry so the reporting code cannot do this, without changing the
reporting code. Then say precisely what final guaranteed and what it did
not.
What this teaches
- final freezes the reference and says nothing about the object behind it
- The constructor and the getters leak state without a single setter existing
- Both directions must be closed — fixing one leaves the other open
- java.util.Date is mutable, so it needs copying on both sides
- Returning a mutable copy protects you while leaving existing callers working
Starter
Starter.javaOpen in playground
import java.util.*;
/**
* Incident reproduction: the audit record that was edited after the fact.
*
* AuditEntry is the compliance record of who changed what and when. It is
* written once and must never change afterwards — that is the entire point
* of an audit trail, and the class was written to be immutable: every field
* is private final and there is not a single setter.
*
* An external auditor re-ran last month's export and found it did not match
* the export taken at the time. Two differences:
*
* 1. Some entries have fewer changes recorded than when they were written.
* 2. Some timestamps have moved by hours.
*
* The database was not touched. Both mutations happened in memory, in the
* reporting code, which is documented as read-only and does not assign to
* any field of AuditEntry.
*
* TASKS
* 1. Run it and confirm the trail no longer matches what was written.
* 2. Find the two lines in the reporting code that did it. Neither is a
* setter and neither touches a field.
* 3. Fix AuditEntry so the reporting code cannot do this, without
* changing the reporting code.
* 4. In a comment: every field was final. Say precisely what final
* guaranteed and what it did not.
*/
public class Starter {
/**
* "Immutable": all fields private final, no setters. Both leaks are
* here — the constructor stores what it is given, and the getters hand
* it back.
*/
static final class AuditEntry {
private final String actor;
private final List<String> changes;
private final Date at;
AuditEntry(String actor, List<String> changes, Date at) {
this.actor = actor;
this.changes = changes;
this.at = at;
}
String actor() {
return actor;
}
List<String> changes() {
return changes;
}
Date at() {
return at;
}
/** The canonical form the auditor compares. */
String export() {
return actor + "|" + at.getTime() + "|" + String.join(",", changes);
}
}
/* ── the reporting code, documented read-only ── */
/** Hides internal-only changes from the customer-facing report. */
static List<String> customerVisible(AuditEntry entry) {
List<String> visible = entry.changes();
visible.removeIf(change -> change.startsWith("internal:"));
return visible;
}
/** Renders the timestamp in the viewer's timezone. */
static String inViewerTimezone(AuditEntry entry, long offsetMillis) {
Date shown = entry.at();
shown.setTime(shown.getTime() + offsetMillis);
return "at " + shown.getTime();
}
public static void main(String[] args) {
List<String> changes = new ArrayList<>(
List.of("price: 100 -> 120", "internal:reindexed", "status: draft -> live"));
Date at = new Date(1_700_000_000_000L);
AuditEntry entry = new AuditEntry("anita", changes, at);
String writtenAtTheTime = entry.export();
System.out.println("── written ──");
System.out.println(" " + writtenAtTheTime);
System.out.println();
System.out.println("── the read-only report runs ──");
System.out.println(" customer sees : " + customerVisible(entry));
System.out.println(" rendered : " + inViewerTimezone(entry, 5 * 3_600_000L));
String exportedLater = entry.export();
System.out.println();
System.out.println("── re-exported ──");
System.out.println(" " + exportedLater);
System.out.println();
boolean trailIntact = writtenAtTheTime.equals(exportedLater);
System.out.println("audit entry unchanged after reporting : " + trailIntact);
System.out.println(trailIntact ? "PASS" : "FAIL");
}
}Run it locally:
cd exercises/java/oop/immutability-in-practice/03-production
javac Starter.java -d /tmp/out && java -cp /tmp/out StarterHints
Hint 1
Neither offending line assigns to anything. Look for methods called ON what a getter returned.
Hint 2
removeIf and setTime are both mutations of an object AuditEntry handed out. Where did the reporting code get those objects?
Hint 3
Now look at the constructor. Who else still holds the list and the Date that were passed in?
Hint 4
If your getter returns List.copyOf, the report starts throwing. Decide whether that is a better or worse outcome before you choose — and note that the brief says not to change the reporting code.
Done when
- The re-export matches the original export exactly
- The reporting code is unchanged and still runs without throwing
- Both the constructor and the getters copy
- A comment states what final guaranteed and what it did not
Solution
Show the solution — try it yourself first
Solution.javaOpen in playground
import java.util.*;
/**
* Solution: the audit record that was edited after the fact.
*
* final guaranteed that the three fields would never be REASSIGNED. It said
* nothing whatsoever about the objects they point at, and both of those
* objects were mutable and reachable from outside.
*
* Two leaks, in opposite directions, and both were needed:
*
* The constructor stored the caller's list and the caller's Date, so the
* code that built the entry kept live handles to its insides.
*
* The getters handed those same objects to every reader. That is how the
* reporting code — which assigns to nothing and is honestly documented as
* read-only — deleted rows from the audit trail and moved a timestamp.
*
* THE FIX: copy on the way in, copy on the way out.
*
* In: List.copyOf(changes) and new Date(at.getTime()) in the constructor,
* so the builder's references no longer reach the entry.
*
* Out: a fresh ArrayList and a fresh Date from the getters, so a reader
* can do whatever it likes to what it was given.
*
* WHY THE GETTER RETURNS A MUTABLE COPY
* Returning List.copyOf would also protect the entry — and customerVisible
* calls removeIf on it, so the report would start throwing
* UnsupportedOperationException. That is a legitimate choice and arguably
* the better one, because it makes the mistake loud instead of harmless.
* It is not this fix, because the brief was to protect the entry without
* changing the reporting code. The stretch task is to make the other
* choice and deal with the fallout.
*/
public class Solution {
/** Genuinely immutable: copies on the way in and on the way out. */
static final class AuditEntry {
private final String actor;
private final List<String> changes;
private final Date at;
AuditEntry(String actor, List<String> changes, Date at) {
this.actor = actor;
this.changes = List.copyOf(changes); // copy IN
this.at = new Date(at.getTime()); // Date cannot be frozen
}
String actor() {
return actor; // String is immutable
}
List<String> changes() {
return new ArrayList<>(changes); // copy OUT, and mutable
}
Date at() {
return new Date(at.getTime()); // copy OUT
}
/** The canonical form the auditor compares. */
String export() {
return actor + "|" + at.getTime() + "|" + String.join(",", changes);
}
}
/* ── the reporting code, documented read-only ── */
/** Hides internal-only changes from the customer-facing report. */
static List<String> customerVisible(AuditEntry entry) {
List<String> visible = entry.changes();
visible.removeIf(change -> change.startsWith("internal:"));
return visible;
}
/** Renders the timestamp in the viewer's timezone. */
static String inViewerTimezone(AuditEntry entry, long offsetMillis) {
Date shown = entry.at();
shown.setTime(shown.getTime() + offsetMillis);
return "at " + shown.getTime();
}
public static void main(String[] args) {
List<String> changes = new ArrayList<>(
List.of("price: 100 -> 120", "internal:reindexed", "status: draft -> live"));
Date at = new Date(1_700_000_000_000L);
AuditEntry entry = new AuditEntry("anita", changes, at);
String writtenAtTheTime = entry.export();
System.out.println("── written ──");
System.out.println(" " + writtenAtTheTime);
System.out.println();
System.out.println("── the read-only report runs ──");
System.out.println(" customer sees : " + customerVisible(entry));
System.out.println(" rendered : " + inViewerTimezone(entry, 5 * 3_600_000L));
String exportedLater = entry.export();
System.out.println();
System.out.println("── re-exported ──");
System.out.println(" " + exportedLater);
System.out.println();
boolean trailIntact = writtenAtTheTime.equals(exportedLater);
System.out.println("audit entry unchanged after reporting : " + trailIntact);
System.out.println(trailIntact ? "PASS" : "FAIL");
}
}Stretch
Make the changes() getter return an immutable list instead of a mutable
copy. The report will now throw UnsupportedOperationException at the exact
line that was corrupting the trail. Fix the report too, and then argue
which version you would ship: the one where a mistake is impossible and
loud, or the one where it is harmless and silent. There is a defensible
answer either way, and the reason matters more than the choice.