LLD Dojo

Syllabus · A2

Value object / record, immutable by construction

The idea

Immutable is half of it

A parking lot's Vehicle is two facts: a plate and a type. Write it the way C++ trains you to and you get a final class, final fields, no setters. Nothing can change it after construction and copying is cheap, so it is a value and done.

Then the operator adds a ban list. She types plates into a console; the gate reads them off a camera. The check is one line, banned.contains(vehicle), and in contrast/a-ban/ it returns false forever. The barred car parks, is audited, gets a receipt and leaves. Four calls, no exception, nothing to grep for. Declare Vehicle as a record and the same seven lines throw at call one: KA01AB1234 is banned from this lot.

Measured with the instrument that grades a real attempt: the ban list costs 7 lines in both designs, and measureChange reports them identical. The hand-written version costs 26 lines to fix, 19 of them inside Vehicle.java.

In Java sharing is the default, so equality is a decision rather than a consequence. A value object is where you decide that two instances with the same fields are one thing. That decision is what lets the type be a list member, a map key or an assertEquals argument.

Two limits. A record gives final fields and no setters, not a defensive copy of a mutable component. And its equality is never selective. when-not.md measures both, and gives the test for deciding whether you hold a value at all.


Worked walkthrough

NOTES — nine files, and the two lines where a wrong answer is returned with no exception

Run it first

From worked/src:

..\..\..\..\.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
..\..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main

javac -Xlint:all exits 0 and prints nothing. Real output, from exactly this code:

== 1. the ban list, built in one place and checked in another ==
  list.contains(gate read)   record  true
  list.contains(gate read)   class   false
  set.contains(gate read)    record  true
  set.contains(gate read)    class   false
  lot.park(banned car)             IllegalStateException: KA01AB1234 is banned from this lot

== 2. the same value as a map key ==
  prepaid.get(gate read)     record  4500
  prepaid.get(gate read)     class   null

== 3. what a test assertion does ==
  assertEquals(expected, stay.vehicle())  record  true
  assertEquals(expected, actual)          class   false
  assertEquals(ticket, same three fields) record  true

== 4. final components, no setters, and a live list ==
  at construction, both read       [S1] and [S1]
  caller adds S9 to its own list
  Stay.spotIds()                   [S1]
  LeakyStay.spotIds()              [S1, S9]
  Stay.spotIds().add("S9")         UnsupportedOperationException
  LeakyStay.spotIds().add("S9")    added, size is now 3
  both stays now read              [S1, S9, S9]
  set.contains(filed) before the change    true
  set.contains(filed) after the change     false
  the set still holds it           size=1, iterating finds it: [S1, S9, S9, S12]

== 5. one spot, two bikes, both registered BIKE1 ==
  spot holds Vehicles, remove(second)  removed=true, 1 left, and the row that went was the first bike's: true
  spot holds Occupancy, vacate T2      left [Occupancy[ticketId=T1, type=MOTORBIKE]]

== 6. what A2 did not buy you ==
  new Vehicle(null, CAR)           built: Vehicle[registration=null, type=CAR]
  lot.park(blank plate)            parked on S1, ticket T1
  lot.audit(that stay)             ticket=T1 registration=    type=CAR spots=[S1]
  lot.park(null type)              NullPointerException: type
  first frame in this lesson's code  Occupancy.<init>(Occupancy.java:13)

Every line above is deterministic. There is no identity hash and no clock anywhere in it.

Two lines carry the lesson, and neither of them throws. list.contains(gate read) class false means a banned car drove in. set.contains(filed) after the change false means a stay the set is holding cannot be found, by the set, using the object it is holding. No exception, no log line, no failing test. A wrong answer travelled out of the building.

Compare that with block 6's last line, which is loud. NullPointerException: type, blamed on Occupancy.<init>. You get a stack trace and a file to open. That is the expensive difference between an invariant that is missing and equality that is missing.


Vehicle.java — one line, and it is handed to you

public record Vehicle(String registration, VehicleType type) {}

Verbatim from corpus/parking-lot/contract/Vehicle.java, where it arrives marked GIVEN. Do not edit. J11 covered what the compiler writes out of that line. What matters here is which of the guarantees you need it actually makes.

It makes two. Both components are final, so a Vehicle handed to the gate, the audit log and the billing pipeline is the same Vehicle in all three. And equals compares the two components, so a Vehicle built by the camera and a Vehicle built by the operator console are one value.

It makes neither of the other two. Nothing refuses a null registration, so an invalid Vehicle is representable. And it holds no mutable component, so the question of copying one never arises here. Both of those are picked up below.

In C++ this line would be the boring part. You would write a struct, get member-wise copies, and operator== would either be defaulted or be five lines you never think about again. The reason it is not boring in Java is the next file.


PlainVehicle.java — immutability on its own, and what that is worth

public final class PlainVehicle {

    private final String registration;
    private final VehicleType type;

Read this as a C++ programmer would and it looks finished. The class is final, both fields are final, there are no setters, and the constructor is the only writer. Nothing can change a PlainVehicle after it exists. It satisfies "immutable by construction" completely.

It is also the thing that let a banned car through.

The reason is that Java gives you an equals whether you write one or not, and the one it gives you is reference identity. Two PlainVehicle objects with identical fields are two different values as far as every collection in the standard library is concerned. C++ has no equivalent trap, because == on a type with no operator== does not compile.

So immutability and equality are separate decisions in Java, and only the second one is about being findable. The C++ instinct bundles them, and the bundle is what does not carry over.

    public String registration() {
        return registration;
    }

An accessor per field, and the reason a record is not merely shorter. These two accessors are the ones you would write anyway. What a record adds on top is the three methods that make the object usable as data: equals, hashCode and toString. Skipping the accessors saves you six lines. Skipping the other three costs you block 1.


Blocks 1, 2 and 3 — why a value object and a collection are one decision

The three blocks ask the same question of three different places, and the answer is the same each time.

  list.contains(gate read)   record  true
  list.contains(gate read)   class   false

List.contains is defined as "some element e such that o.equals(e)". So contains on a type with no equals is asking whether the exact object you are holding is in the list. The gate never holds the operator console's object. It holds one it built from a camera read.

The ban list in Lot.park is one line:

        if (banned.contains(vehicle)) {

That line is either the whole ban feature or none of it, and which one depends on a decision made in a different file about whether Vehicle is a value. Nothing in Lot.java shows you which. That is the coupling worth naming out loud in a round: the collection call site cannot defend itself.

  prepaid.get(gate read)     record  4500
  prepaid.get(gate read)     class   null

HashMap is worse than List, because it fails earlier. It compares hash codes first, so a PlainVehicle key is looked for in a bucket chosen by its identity hash. Even a correct equals would never be consulted. J6 measures that half; the design consequence is that a value type is what makes a Map keyed by anything other than a String possible at all.

  assertEquals(expected, stay.vehicle())  record  true
  assertEquals(expected, actual)          class   false

And this is the one that decides a graded run. assertEquals calls equals. A test builds the Vehicle it expects and compares it against the one your API returned. On a type with no equals that fails every time, for a design that is otherwise correct. The same applies to assertEquals(expectedReceipt, actual) and to any assertion over a returned list of your types.

The third row of block 3 is Ticket, and it is worth a second:

  assertEquals(ticket, same three fields) record  true

Ticket carries a ticketId, which is an entity's identity. The record is still a value, because the piece of paper is a value. Two tickets reading the same id, spot and time are the same ticket. That distinction is the whole content of the value-versus-entity test in when-not.md.


Stay.java — the one line a record does not write for you

public record Stay(Ticket ticket, Vehicle vehicle, List<String> spotIds) {

Verbatim from corpus/parking-lot/reference/src/Stay.java. Three components, and the third one is a collection.

        spotIds = List.copyOf(spotIds);

This is the line that makes final mean something. final on a component stops the reference being repointed. It says nothing whatsoever about the list the reference points at. Store the caller's ArrayList and the caller keeps a live handle on where this vehicle is parked.

Block 4 is that sentence as output. The caller adds S9 to its own list, and Stay still reads [S1] while LeakyStay reads [S1, S9].

The bug it prevents is in Lot.unpark:

        for (String spotId : stay.spotIds()) {
            occupants.get(spotId).removeIf(o -> o.ticketId().equals(ticketId));
        }

unpark frees the spots the stay says it holds. If a caller can extend that list, a caller can free a spot that belongs to somebody else. Freeing S9 removes any occupancy on S9 carrying this ticket id, which is none, so the visible symptom is nothing at all until two stays overlap.

copyOf also does the second half of the job, which block 4's fifth line shows:

  Stay.spotIds().add("S9")         UnsupportedOperationException

The stored list is unmodifiable, so spotIds() can hand the field straight out with no copy per call. That is the accessor side of the problem and it belongs to A7, which measures it. What A2 owes you is one recognition. A record gives you two of the three requirements for immutability, and the third one is yours. The three are final fields, no setters, and a defensive copy of every mutable component.

One caveat, so the rule is not over-applied. Ticket, Vehicle and Occupancy here hold String, Instant and enum components, and all three of those are immutable already. Copying them would be noise. The rule bites on collections, arrays, Date, and any type of your own that is not itself a value.


LeakyStay.java — the set that cannot find the object it is holding

public record LeakyStay(Ticket ticket, Vehicle vehicle, List<String> spotIds) {

One line removed from Stay, and javac -Xlint:all still prints nothing. The last three lines of block 4 are the part that surprises people:

  set.contains(filed) before the change    true
  set.contains(filed) after the change     false
  the set still holds it           size=1, iterating finds it: [S1, S9, S9, S12]

A record's hashCode is computed from its components, and one of these components changed. The object went into the bucket its old hash chose. contains now looks in the bucket the new hash chooses, finds nothing, and reports false. The set has not lost the object: size is 1 and iteration returns it. It has lost the ability to look it up.

This is the failure mode that makes "immutable by construction" more than a slogan. A value object whose equality can change is a value object that can hide inside any hash-based collection, and it takes its Map entry with it. The mechanism is J6's. The decision is A2's, and it is one line.


Occupancy.java — the cost of value equality, and how the corpus paid it

public record Occupancy(String ticketId, VehicleType type) {

Verbatim from the reference solution, and its own comment says why:

The ticket id, not the Vehicle, because a Vehicle is a value ... and removing an occupant by value would free the wrong one.

The words the ellipsis covers are "two bikes registered BIKE1 are equal".

Block 5 runs it:

  spot holds Vehicles, remove(second)  removed=true, 1 left, and the row that went was the first bike's: true
  spot holds Occupancy, vacate T2      left [Occupancy[ticketId=T1, type=MOTORBIKE]]

List.remove(Object) removes the first element that compares equal, not the one you passed. Two bikes on one plate are one value, so the second bike's owner rides away and the first bike's row is the one that disappears. The lot now believes a bike that is standing there has left.

Notice what this is not. It is not an argument against making Vehicle a value. It is the reason the reference tracks occupancy by ticket id, which is the identity of the stay:

            occupants.get(spotId).removeIf(o -> o.ticketId().equals(ticketId));

corpus/parking-lot/reference/DECISION_LOG.md has a heading for this, "Identity · occupancy is tracked by ticket id, never by Vehicle", and closes it with: "Entity versus value, decided rather than stumbled into." The decision is the deliverable. An interviewer asking "why is the spot holding a ticket id rather than the car?" is asking for exactly this answer.


Lot.java — the type that is deliberately not a value

public final class Lot {

    private final List<String> spotIds;
    private final Map<String, List<Occupancy>> occupants = new LinkedHashMap<>();

No record, no equals, and every method changes it. Two lots with the same three spots are two lots, and if you had a set of them you would want both. So Lot gets reference equality, which is the correct answer and also the free one.

There is a second reason, and it is the one that decides borderline cases. occupants changes on every entry and exit. A value whose fields change is the LeakyStay failure with more steps: put two lots in a Map and the first park call makes one of them unfindable. A type with a lifecycle cannot be a value, whatever its fields look like.

    public List<Occupancy> occupantsOf(String spotId) {
        return List.copyOf(occupants.get(spotId));
    }

The copy here is per call, because the field behind it is mutable and has to be. Stay copies once at construction and never again. That difference in cost is one of the things a value object buys, and it is measured in contrast/.


Block 6 — where A2 stops and A5 starts

  new Vehicle(null, CAR)           built: Vehicle[registration=null, type=CAR]
  lot.park(blank plate)            parked on S1, ticket T1
  lot.audit(that stay)             ticket=T1 registration=    type=CAR spots=[S1]

The corpus's Vehicle has no compact constructor, so an invalid Vehicle is representable. A null registration constructs. A blank one parks, takes a spot, and writes an audit line with an empty field where the plate should be. corpus/parking-lot/problem.json lists A5 in its syllabus array, and this is not that.

Say plainly what is true of the file rather than what its author meant. Vehicle sits in contract/, which is given to the candidate and marked read-only, so the file is the interface the grader calls rather than the design being marked. The A5 work on this problem is in the reference solution: SpotGrid refuses a lot with fewer than one spot, Stay refuses to exist without a spot, Occupancy refuses nulls. The DECISION_LOG.md heading for that is "Invariants sit in constructors". What Vehicle shows is the boundary itself: A2 decides the shape, and shape alone does not decide validity.

The last two lines are the same fact from the other side:

  lot.park(null type)              NullPointerException: type
  first frame in this lesson's code  Occupancy.<init>(Occupancy.java:13)

The null type does not survive, because it reaches a record that does have a compact constructor. Occupancy is three calls downstream of the Vehicle that was wrong, so the stack trace names the wrong file. A5's contrast set measures precisely that distance.

Two other corpus records show what Vehicle would look like with the check in place. corpus/cost-explorer/contract/MonthlyCharge.java refuses a negative amount. corpus/tic-tac-toe/contract/MoveResult.java null-checks its Optional components, then refuses a winner that is not the player who moved.

Adding those checks is A5's lesson, not this one. What this one asks of you is the decision one step earlier: is this thing a value, and if it is, does it hold anything that can change.


When not to

When the thing in front of you is not a value

Records are cheap to write, so the risk with A2 is not that you skip it. The risk is that you reach for it on a type that has an identity, or a lifecycle, or a component that should not decide equality. All three produce working code that fails later, and only the third one has a measurement in contrast/.

The test, and it points both ways

Write one sentence per type, in this exact shape:

Two <Type>s with every field equal are ______.

"the same thing" means it is a value. Make it a record. "two different things" means it is an entity. It needs an id, and every lookup and every removal goes through that id rather than through equality.

Then two checks pointing opposite ways, which is what makes this usable rather than merely true.

Answered "two different things" and cannot name the field that tells them apart. You have not found an entity. You have found a value you are nervous about, and reference equality will make it unfindable in every collection you put it in.

Answered "the same thing" and the type has a field that changes while the object is alive. You have not found a value. You have found an entity whose changing part has not been separated out yet. contrast/b-leaky/ measures what happens if you ship it anyway: a stay goes missing from the HashSet that is holding it.

One more thing to keep straight, because it is where most people get stuck. Immutable and value are separate axes. An entity can be a record. A value can be mutable, badly.

The test against parking-lot, where the answers are already on disk

corpus/parking-lot decided all of this before this lesson existed, so it is a fair check.

TypeTwo of them with equal fields areShipped as
Vehiclethe same vehiclerecord, in contract/
Ticket, Receiptthe same piece of paperrecord, in contract/
Occupancythe same occupancyrecord, in reference/src/
Staytwo different staysrecord with a ticket id, looked up only by it
SpotGridtwo different lots, and different again a second laterpublic final class

Five rows produced by one sentence, and they match what is in the two directories. The two interesting rows are the last two.

Stay is an entity and a record, and its own javadoc says why:

An entity, not a value ... so nothing ever looks a stay up by anything but its ticket id. It is a record because it never changes after entry.

The ellipsis covers "two stays with identical fields are still two different stays". So the first check gave "two different things", the id is the ticket id, and the second check found nothing that changes. Entity by identity, record by immutability. Both at once.

SpotGrid is where the second check fires. Its occupants map changes on every entry and exit, so "two of them with equal fields are the same lot" is false a second after you say it. It gets reference equality, which is the correct answer and also the free one.

And corpus/parking-lot/reference/DECISION_LOG.md has a heading for the consequence, "Identity · occupancy is tracked by ticket id, never by Vehicle", which closes: "Entity versus value, decided rather than stumbled into." The test above is that decision written down as a procedure.

The types the test says nothing about. PricingPolicy, SpotAllocator and FirstFitAllocator hold no fields worth comparing, and no code anywhere puts two of them in a set. The sentence has no answer, so the question does not arise, and reference equality costs nothing. If you find yourself writing equals for a stateless policy, the test is telling you to stop.

1 · Identity matters, and value equality then deletes the wrong row

Two customers named John Smith are two customers. Two bikes on the plate BIKE1 are two bikes. A value type cannot tell them apart, because being unable to tell them apart is the whole point of one.

Here is the concrete version. This compiles next to worked/src, and javac -Xlint:all prints nothing:

/** Who is standing on a spot, described by the vehicle itself rather than by the stay. */
public record VehicleOccupancy(Vehicle vehicle) {

    public VehicleOccupancy {
        Objects.requireNonNull(vehicle, "vehicle");
    }
}

Two motorbikes share a standard spot and both are registered BIKE1, which is a plate misread or a cloned plate. The second rider leaves. Real output:

S1 before          2 occupants
second bike leaves removed=true, 1 left
who is left        the second bike
who rode away      the first bike

List.remove(Object) removes the first element that compares equal, not the one you passed. The lot now believes the bike that is standing there has gone, and it will admit somebody else onto that spot. Nothing threw.

The fix is not to give Vehicle reference equality. That breaks the ban list, the prepaid map and every assertion, which contrast/ measures at 7 lines against 26. The fix is the corpus's: a record that carries the identity of the stay.

public record Occupancy(String ticketId, VehicleType type) {

Threshold. If a collection of this type will ever hold two elements you need to tell apart, the element carries an id and every removal goes through the id. Value equality decides what a thing is, never which one you meant.

2 · A component that should not decide equality

This is the one with a measurement, and it is the cost that surprises people about records.

A record's equals compares every component. There is no way to exclude one, and adding a component changes what equality means everywhere the type is used. Here is the requirement that does it, from contrast/:

Plates get misread. Put the colour the camera saw on the vehicle, so the operator can spot a misread in the log. The ban list stays as it is.

  a-ban-fixed -> a-colour        8 lines across 1 existing file(s)
  b-ban       -> b-colour        6 lines across 1 existing file(s)

a-colour      BaseTest 4/4   BanTest 2/2   ColourBanTest 2/2
b-colour      BaseTest 4/4   BanTest 2/2   ColourBanTest 1/2

The record version is two lines cheaper and the ban list stopped working. The console bans a plate and a type; the gate supplies a colour as well; the two values are no longer equal. The hand-written equals over registration and type was unaffected, because somebody had to say which fields count.

Read the middle column once more. BanTest still passes 2/2, because both of its vehicles come from the two-argument constructor and both get "unknown". The suite that already existed cannot see the regression.

The sharper version of the same mistake is a timestamp. This compiles next to worked/src, clean:

/** A vehicle, plus the instant the camera saw it. Every component is part of the value. */
public record SeenVehicle(String registration, VehicleType type, Instant seenAt) {}

Real output:

console banned at 09:00, gate read at 12:00
  equals                        false
  banned.contains(gate read)    false
  prepaid.get(gate read)        null
  the same car, two seconds apart: false

No two camera reads of one car are ever equal. Every lookup misses, forever, and the type looks finished.

Threshold. A component belongs on the record when it is part of the answer to "is this the same thing". If it is not, it belongs on the entity that owns the occasion: seenAt is a property of the sighting, and Stay is where a sighting lives. When you cannot move it, the value has to shrink: a record Plate(String value) banned as a Set<Plate> is unaffected by anything you add to Vehicle. That costs one type and a wrapper at each call site.

3 · Copying is genuinely expensive

A value's copy is proportional to its components, which is free while the components are a String, an Instant and an enum. It stops being free when a component is a collection whose size grows with the data, and the value is rebuilt per operation.

corpus/tic-tac-toe/contract/GameState.java copies size * size cells in its compact constructor, on every snapshot. At 3x3 that is nine references and nobody cares. On a board sized by a curveball it is the whole board, per call, and the snapshot is handed out after every move.

The corpus's answer to the version that does not fit is corpus/shopping-cart/contract/CartPage.java:

public record CartPage(List<LineItem> lines, int pageIndex, int pageSize, int totalLines, boolean hasMore) {

It copies lines, which is one page, and carries totalLines as a number. A cart with fifty thousand lines costs a page-sized copy rather than a cart-sized one, and the pager still knows how to draw itself.

Threshold. Keep the value when the copy is bounded by something you choose, such as a page size. Stop when the copy is bounded by the data and it happens per operation. Then the value describes a window onto the state rather than the state, and the state stays behind a class. This one is a rule rather than a measurement: nothing in contrast/ times it, and a real decision here needs a benchmark against your own sizes.

4 · Wrapping a string that did not ask to be wrapped

Ticket carries ticketId and spotId as bare Strings, and the reference solution keeps them that way. That is restraint, not an oversight, and it is worth being able to defend.

Threshold. Wrap a String in a value type when two strings of the same type meet at one call site and swapping them would compile. corpus/rate-limiter/contract earns it: a client key and a rule name are both strings, and a limiter that swaps them limits the wrong thing. Nothing in parking-lot takes a ticket id and a spot id together, so record TicketId(String value) there buys a wrapper at every call site and no check. A5's when-not.md measures the same restraint from the invariant side.

The threshold, in one place

Make it a value object when all three hold.

  1. Two of them with equal fields are the same thing, and no collection needs to tell two apart.
  2. Nothing about it changes while it is alive. Anything that does belongs to whatever owns the occasion.
  3. Every component is part of the answer to "is this the same thing", and copying the components is bounded by something you choose.

Fail the first and you have an entity: give it an id and look it up by that. Fail the second and you have an entity with a snapshot: keep the snapshot as a value and the changing part behind a class, which is what Stay and SpotGrid are. Fail the third and either shrink the value or hold a window onto the state instead of the state.

Then, having made it a value, copy every mutable component in the constructor. A record gives you final components and no setters. The third requirement is one line, it is yours, and A7 measures what it is worth.


The contrast pair

The measured set: ten designs of one lot, and a diff that cannot see the difference

Ten trees, five files each. BaseTest.java compiles and passes 4/4 against every one of them, so every comparison here is between designs that work.

Run it yourself:

node lessons/A2/contrast/measure.mjs

Which instrument, and why not the obvious one

Three instruments, because the first one reports a tie.

Real output, from exactly these directories:

=== on-axis, the ban list ===
  a -> a-ban           7 lines across 1 existing file(s), 0 new file(s)
      Lot.java         +7 -0
  b -> b-ban           7 lines across 1 existing file(s), 0 new file(s)
      Lot.java         +7 -0

Seven lines in both, in the same file, and the two diffs are character for character identical. measureChange is what grades D4 in a real attempt, and on this change it cannot tell the two designs apart. That is the honest reading of the number, and it is worth sitting with, because one of those two designs does not ban anybody.

The instrument that can see it is the suite:

a             BaseTest 4/4   LeakTest 2/2
b             BaseTest 4/4   LeakTest 2/2
a-ban         BaseTest 4/4   BanTest 1/2
a-ban-fixed   BaseTest 4/4   BanTest 2/2
b-ban         BaseTest 4/4   BanTest 2/2
a-clamp       BaseTest 4/4   ClampTest 2/3
b-clamp       BaseTest 4/4   ClampTest 3/3
b-leaky       BaseTest 4/4   LeakTest 0/2

a-ban passes the shared behaviour in full and fails the one assertion that matters.

The requirement, in an interviewer's words

The operator keeps a list of vehicles barred from the lot. She types the plate into a console. The gate reads plates off a camera. A barred vehicle has to be turned away at the barrier.

Both designs answer it with the same seven lines: a List<Vehicle> field, a ban method, and a check at the top of park.

        if (banned.contains(vehicle)) {
            throw new IllegalStateException(vehicle.registration() + " is banned from this lot");
        }

Nothing about that line is careless. Its correctness is decided in Vehicle.java.

How far the banned car gets

Probe.java walks one banned car through the gate, the audit line, the receipt and the exit, and counts calls. a-ban/, unedited:

scenario 1   the banned car arrives at the gate
  same object as the console's: false
  detected  no
  calls     4  [Lot.park, Lot.audit, Lot.receipt, Lot.unpark]
  outcome   ticket=T1 registration=KA01AB1234 type=CAR spots=[S1]
            receipt ticket=T1 registration=KA01AB1234 spots=[S1]

The barred car parked, was audited, was given a receipt, and left. Four calls, no exception, and the audit line is already written. There is nothing in any log to grep for, because from the lot's point of view nothing went wrong.

b-ban/, unedited:

  detected  yes, after 1 call(s)
  calls     [Lot.park]
  threw     java.lang.IllegalStateException
  message   KA01AB1234 is banned from this lot
  blamed    Lot.park(Lot.java:33)

One call, and the message names the plate.

That pair of outputs is the whole argument. Same requirement, same seven lines, same file. In one design the feature works and in the other it silently does nothing. The difference is a decision made in a different file, about whether two vehicles with the same plate are the same value.

What a/ costs to fix

a-ban-fixed/ keeps the class and writes the three methods a record generates:

  a -> a-ban-fixed    26 lines across 2 existing file(s), 0 new file(s)
      Lot.java         +7 -0
      Vehicle.java     +19 -0

Twenty-six lines against seven, and nineteen of them land in a file that already existed. That is the number the diff instrument can see. a-ban-fixed then passes BanTest 2/2 and detects the banned car after one call, exactly as b-ban does. The design is not wrong. It is nineteen lines of equals, hashCode and toString that you wrote by hand and now have to keep in step with the fields.

Sizes, for the record: a/ is 128 normalised lines and b/ is 115. The record saves thirteen before any requirement arrives.

The measurement that goes the wrong way

Here is the requirement that costs the record design something real.

Plates get misread. Put the colour the camera saw on the vehicle, so the operator can spot a misread when she looks at the log. The ban list stays as it is: she has no camera, so she still bans a plate and a type.

Both designs absorb it in one file, and the record absorbs it in fewer lines:

  a-ban-fixed -> a-colour        8 lines across 1 existing file(s), 0 new file(s)
      Vehicle.java     +8 -0
  b-ban -> b-colour        6 lines across 1 existing file(s), 0 new file(s)
      Vehicle.java     +5 -1

Now the suites:

a-colour      BaseTest 4/4   BanTest 2/2   ColourBanTest 2/2
b-colour      BaseTest 4/4   BanTest 2/2   ColourBanTest 1/2

The record's change is two lines cheaper and it broke the ban list. A record's equals compares every component, so adding colour changed what "the same vehicle" means. The console bans Vehicle("KA01AB1234", CAR), which the secondary constructor fills out as colour = "unknown". The gate builds Vehicle("KA01AB1234", CAR, "blue"). Those are two different values, and banned.contains is false again.

a-colour does not have that problem, because its equals was written by hand over registration and type. The new field is not part of equality unless somebody says so.

Read the middle column again. BanTest still passes 2/2 against b-colour. Both of its Vehicle objects come from the two-argument constructor, so both get "unknown" and both compare equal. The suite that already existed cannot see the regression at all. ColourBanTest is the same scenario with the colour the camera actually saw, and it is nine lines long.

That is the cost of generated equality, stated as a measurement. It is not selective, and it changes when the component list changes. The change is invisible to any test that does not vary the new component. when-not.md carries the threshold that follows from it.

The direction that did not go the wrong way

The clamp was the other candidate for a wrong-way result, and it came out the other way. The requirement:

A vehicle can be clamped for non-payment, and unclamped when they pay. Everything the lot prints has to show it, and the clamp comes off again.

  a -> a-clamp        26 lines across 2 existing file(s), 0 new file(s)
      Lot.java         +14 -2
      Vehicle.java     +10 -0
  b -> b-clamp        22 lines across 1 existing file(s), 0 new file(s)
      Lot.java         +20 -2

Twenty-two lines in one file against twenty-six across two. And a-clamp fails ClampTest 2/3:

a-clamp       BaseTest 4/4   ClampTest 2/3
b-clamp       BaseTest 4/4   ClampTest 3/3

The failing test parks one Vehicle object twice, which is what a fleet operator's software does when it holds a vehicle in memory and sends it through the gate again. a-clamp stores the clamp on the vehicle, so clamping the second stay clamps the first one too. b-clamp cannot make that mistake: the record has nowhere to put the flag, so the flag went where it belongs, on the thing with an identity.

I went looking for a requirement where mutability would win and found one where it loses. It is reported rather than swapped for a friendlier requirement, because the finding is real. A value object refuses to hold state that belongs to something else, and that refusal is sometimes the design review. The wrong-way result above is the colour, not this.

One line of separation

  b -> b-leaky        1 line across 1 existing file(s)
b-leaky       BaseTest 4/4   LeakTest 0/2

b-leaky/ is b/ with spotIds = List.copyOf(spotIds); deleted from Stay's compact constructor. Everything else is byte-identical, javac -Xlint:all says nothing, and BaseTest passes 4/4. It fails only where a caller keeps the list it passed in.

The second LeakTest assertion is the one worth reading:

        assertTrue(parked.contains(stay),
                "a component that can change changes hashCode, and the set looks in the wrong bucket");

A stay in a HashSet becomes unfindable when the caller appends to the list it was built from. The set still holds it. Immutable by construction is three requirements, and a record gives you two. Final components and no setters arrive free; the defensive copy of every mutable component is yours, and it is one line. A7 measures the accessor half of the same problem.

The alternatives, so the choice is a choice

Could a-ban/ be fixed without touching Vehicle? Yes, by keying the ban list on String registration plus VehicleType, or by looping with an explicit field comparison. Both work. Both leave the type unusable as a map key, a set member or an assertEquals argument. You pay the same tax at every call site instead of once in the type.

Is b/ free? No. It is thirteen lines smaller than a/ and its equality is not selective, which is what b-colour measures. A record decides that every component is part of the value, and you find out when the component list grows.

Would a smaller value type have avoided the colour problem? Yes, and that is the design answer. A Plate record wrapping the registration, banned as Set<Plate>, is unaffected by anything added to Vehicle. The cost is one more type and a wrapper at every call site, and when-not.md gives the threshold for when that is worth it.

What this set does not show

The four calls in a-ban/ are four calls in a tree of five small files. In a real lot the path from the barrier to somebody noticing runs through a request handler, an audit pipeline, a billing job and a support console. The number is not four. It is however many calls happen between a barred car parking and the operator asking why it is on the camera feed.

It also does not show the case where a value object is the wrong shape outright. That case exists, and it is when-not.md.


Worked source

The 9 files of the worked design

Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.

worked/src/LeakyStay.java20 lines

import java.util.List;
import java.util.Objects;

/**
 * Stay with one line taken out: the {@code List.copyOf}.
 *
 * Everything else is identical. The components are still final, there are still no setters, and
 * {@code javac -Xlint:all} still says nothing. Main.theMutableComponent shows what a caller can do
 * to it afterwards.
 */
public record LeakyStay(Ticket ticket, Vehicle vehicle, List<String> spotIds) {

    public LeakyStay {
        Objects.requireNonNull(ticket, "ticket");
        Objects.requireNonNull(vehicle, "vehicle");
        if (spotIds.isEmpty()) {
            throw new IllegalArgumentException("a stay occupies at least one spot");
        }
    }
}

worked/src/Lot.java91 lines

import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
 * A one-floor lot, cut down from corpus/parking-lot/reference/src so this directory stays readable:
 * one spot per vehicle, no tariff, no allocator seam. Footprint belongs to A1 and pricing to B1.
 *
 * It is here to be the thing the values are handed to. Note what it is not: it is not a record, it
 * has no {@code equals}, and every method changes it. Two lots with the same spots are two lots.
 */
public final class Lot {

    private final List<String> spotIds;
    private final Map<String, List<Occupancy>> occupants = new LinkedHashMap<>();
    private final Map<String, Stay> openStays = new LinkedHashMap<>();
    private final List<Vehicle> banned;
    private int nextTicket = 1;

    public Lot(int standardSpots, List<Vehicle> banned) {
        this.banned = List.copyOf(Objects.requireNonNull(banned, "banned"));
        List<String> ids = new ArrayList<>(standardSpots);
        for (int i = 1; i <= standardSpots; i++) {
            String id = "S" + i;
            ids.add(id);
            occupants.put(id, new ArrayList<>(2));
        }
        this.spotIds = List.copyOf(ids);
    }

    /**
     * Admits a vehicle, or refuses it because the operator put it on the ban list.
     *
     * The ban list was built somewhere else, from plates typed into a console. This gate built its
     * own Vehicle from a camera read. The two objects are never the same object, so
     * {@code banned.contains(vehicle)} is the whole ban feature or none of it.
     */
    public Ticket park(Vehicle vehicle, Instant at) {
        Objects.requireNonNull(vehicle, "vehicle");
        if (banned.contains(vehicle)) {
            throw new IllegalStateException(vehicle.registration() + " is banned from this lot");
        }
        String free = firstFreeSpot();
        String ticketId = "T" + nextTicket++;
        Ticket ticket = new Ticket(ticketId, free, at);
        Stay stay = new Stay(ticket, vehicle, List.of(free));
        openStays.put(ticketId, stay);
        occupants.get(free).add(new Occupancy(ticketId, vehicle.type()));
        return ticket;
    }

    /** Frees the spots this one stay holds, and nobody else's. */
    public void unpark(String ticketId) {
        Stay stay = openStays.remove(ticketId);
        if (stay == null) {
            throw new IllegalArgumentException("no open stay for ticket " + ticketId);
        }
        for (String spotId : stay.spotIds()) {
            occupants.get(spotId).removeIf(o -> o.ticketId().equals(ticketId));
        }
    }

    /** A snapshot. Mutating what comes back cannot corrupt the lot. That rule is A7's. */
    public List<Occupancy> occupantsOf(String spotId) {
        return List.copyOf(occupants.get(spotId));
    }

    public Stay stayFor(String ticketId) {
        return openStays.get(ticketId);
    }

    /** The audit line a real lot writes per entry, and the reason a bad value gets read out loud. */
    public String audit(Stay stay) {
        return "ticket=" + stay.ticket().ticketId()
                + " registration=" + stay.vehicle().registration()
                + " type=" + stay.vehicle().type()
                + " spots=" + stay.spotIds();
    }

    private String firstFreeSpot() {
        for (String id : spotIds) {
            if (occupants.get(id).isEmpty()) {
                return id;
            }
        }
        throw new IllegalStateException("the lot is full");
    }
}

worked/src/Occupancy.java15 lines

import java.util.Objects;

/**
 * corpus/parking-lot/reference/src/Occupancy.java, verbatim.
 *
 * Read the second paragraph of its own comment before the code: it names the reason this record
 * carries a ticket id rather than the Vehicle that is standing there.
 */
public record Occupancy(String ticketId, VehicleType type) {

    public Occupancy {
        Objects.requireNonNull(ticketId, "ticketId");
        Objects.requireNonNull(type, "type");
    }
}

worked/src/PlainVehicle.java27 lines

/**
 * The same two fields, written the way a C++ developer writes a value type on the first pass.
 *
 * Final class, final fields, no setters, and a constructor that takes both. By every C++ instinct
 * this is a value: it cannot change after construction and copying it is cheap. In Java it is
 * missing the half that decides whether a collection can find it again.
 *
 * Nothing about this class is careless. It is what "immutable" gets you on its own.
 */
public final class PlainVehicle {

    private final String registration;
    private final VehicleType type;

    public PlainVehicle(String registration, VehicleType type) {
        this.registration = registration;
        this.type = type;
    }

    public String registration() {
        return registration;
    }

    public VehicleType type() {
        return type;
    }
}

worked/src/Stay.java24 lines

import java.util.List;
import java.util.Objects;

/**
 * corpus/parking-lot/reference/src/Stay.java, verbatim.
 *
 * An entity that is also a record, which is the pairing most people do not expect. Two stays with
 * identical fields are still two different stays, so nothing looks a stay up by anything but its
 * ticket id. It is a record because it never changes after entry: the exit produces a receipt
 * rather than editing the stay.
 *
 * The {@code List.copyOf} line is the one thing a record does not give you for free.
 */
public record Stay(Ticket ticket, Vehicle vehicle, List<String> spotIds) {

    public Stay {
        Objects.requireNonNull(ticket, "ticket");
        Objects.requireNonNull(vehicle, "vehicle");
        spotIds = List.copyOf(spotIds);
        if (spotIds.isEmpty()) {
            throw new IllegalArgumentException("a stay occupies at least one spot");
        }
    }
}

worked/src/Ticket.java9 lines

import java.time.Instant;

/**
 * corpus/parking-lot/contract/Ticket.java, verbatim apart from the import.
 *
 * A value that carries an entity's identity. {@code ticketId} names the stay; the record itself is
 * a piece of paper, and two pieces of paper reading the same three things are the same ticket.
 */
public record Ticket(String ticketId, String spotId, Instant entryTime) {}

worked/src/Vehicle.java8 lines

/**
 * corpus/parking-lot/contract/Vehicle.java, verbatim. One line, and it is given to you.
 *
 * Two things it does give: every component is final, and two vehicles with the same registration
 * and type are equal. Two things it does not: nothing here refuses a null registration, and nothing
 * here refuses a blank one. Main.theA5Boundary prints what that costs.
 */
public record Vehicle(String registration, VehicleType type) {}

worked/src/VehicleType.java6 lines

/**
 * Copied byte for byte from corpus/parking-lot/contract/VehicleType.java.
 *
 * Here so the rest of this directory compiles against the same types the grader hands you.
 */
public enum VehicleType { MOTORBIKE, CAR, TRUCK }

worked/src/Main.java208 lines

import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

/**
 * Six blocks. Every line of output in worked/NOTES.md comes from running this.
 *
 * Compile and run from this directory:
 *   ..\..\..\..\.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
 *   ..\..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main
 */
public final class Main {

    private static final Instant NOON = Instant.parse("2026-08-19T12:00:00Z");
    private static final String PLATE = "KA01AB1234";

    public static void main(String[] args) {
        theBanList();
        theMapKey();
        theAssertion();
        theMutableComponent();
        twoBikesOnOnePlate();
        theA5Boundary();
    }

    /**
     * Block 1. The ban list is built in the operator console. The check happens at the gate. The
     * two Vehicle objects are never the same object.
     */
    private static void theBanList() {
        System.out.println("== 1. the ban list, built in one place and checked in another ==");

        List<Vehicle> bannedRecords = List.of(new Vehicle(PLATE, VehicleType.CAR));
        List<PlainVehicle> bannedPlain = List.of(new PlainVehicle(PLATE, VehicleType.CAR));
        Set<Vehicle> bannedRecordSet = new HashSet<>(bannedRecords);
        Set<PlainVehicle> bannedPlainSet = new HashSet<>(bannedPlain);

        Vehicle gateRead = new Vehicle(PLATE, VehicleType.CAR);
        PlainVehicle gateReadPlain = new PlainVehicle(PLATE, VehicleType.CAR);

        row("list.contains(gate read)   record", bannedRecords.contains(gateRead));
        row("list.contains(gate read)   class ", bannedPlain.contains(gateReadPlain));
        row("set.contains(gate read)    record", bannedRecordSet.contains(gateRead));
        row("set.contains(gate read)    class ", bannedPlainSet.contains(gateReadPlain));

        Lot lot = new Lot(3, bannedRecords);
        try {
            lot.park(gateRead, NOON);
            System.out.println("  lot.park(banned car)             parked");
        } catch (IllegalStateException refused) {
            System.out.println("  lot.park(banned car)             "
                    + refused.getClass().getSimpleName() + ": " + refused.getMessage());
        }
        System.out.println();
    }

    /** Block 2. The same question asked of a map, which is where prepaid balances live. */
    private static void theMapKey() {
        System.out.println("== 2. the same value as a map key ==");

        Map<Vehicle, Long> prepaidRecords = new HashMap<>();
        prepaidRecords.put(new Vehicle(PLATE, VehicleType.CAR), 4500L);
        Map<PlainVehicle, Long> prepaidPlain = new HashMap<>();
        prepaidPlain.put(new PlainVehicle(PLATE, VehicleType.CAR), 4500L);

        System.out.println("  prepaid.get(gate read)     record  "
                + prepaidRecords.get(new Vehicle(PLATE, VehicleType.CAR)));
        System.out.println("  prepaid.get(gate read)     class   "
                + prepaidPlain.get(new PlainVehicle(PLATE, VehicleType.CAR)));
        System.out.println();
    }

    /** Block 3. What every assertion in a graded suite does, whether or not it says so. */
    private static void theAssertion() {
        System.out.println("== 3. what a test assertion does ==");

        Lot lot = new Lot(3, List.of());
        Ticket ticket = lot.park(new Vehicle(PLATE, VehicleType.CAR), NOON);
        Stay stay = lot.stayFor(ticket.ticketId());

        Vehicle expected = new Vehicle(PLATE, VehicleType.CAR);
        PlainVehicle expectedPlain = new PlainVehicle(PLATE, VehicleType.CAR);
        PlainVehicle actualPlain = new PlainVehicle(PLATE, VehicleType.CAR);

        row("assertEquals(expected, stay.vehicle())  record", Objects.equals(expected, stay.vehicle()));
        row("assertEquals(expected, actual)          class ", Objects.equals(expectedPlain, actualPlain));
        row("assertEquals(ticket, same three fields) record",
                Objects.equals(ticket, new Ticket(ticket.ticketId(), "S1", NOON)));
        System.out.println();
    }

    /**
     * Block 4. Final components and no setters, and a component that is still alive after
     * construction. This is the third requirement a record does not meet for you.
     */
    private static void theMutableComponent() {
        System.out.println("== 4. final components, no setters, and a live list ==");

        List<String> callersList = new ArrayList<>(List.of("S1"));
        Ticket ticket = new Ticket("T1", "S1", NOON);
        Vehicle vehicle = new Vehicle(PLATE, VehicleType.CAR);

        Stay copied = new Stay(ticket, vehicle, callersList);
        LeakyStay shared = new LeakyStay(ticket, vehicle, callersList);

        System.out.println("  at construction, both read       " + copied.spotIds()
                + " and " + shared.spotIds());

        callersList.add("S9");
        System.out.println("  caller adds S9 to its own list");
        System.out.println("  Stay.spotIds()                   " + copied.spotIds());
        System.out.println("  LeakyStay.spotIds()              " + shared.spotIds());

        try {
            copied.spotIds().add("S9");
            System.out.println("  Stay.spotIds().add(\"S9\")         added");
        } catch (UnsupportedOperationException refused) {
            System.out.println("  Stay.spotIds().add(\"S9\")         "
                    + refused.getClass().getSimpleName());
        }
        shared.spotIds().add("S9");
        System.out.println("  LeakyStay.spotIds().add(\"S9\")    added, size is now "
                + shared.spotIds().size());

        System.out.println("  both stays now read              " + shared.spotIds());

        Set<LeakyStay> parked = new HashSet<>();
        LeakyStay filed = new LeakyStay(ticket, vehicle, callersList);
        parked.add(filed);
        row("set.contains(filed) before the change  ", parked.contains(filed));
        callersList.add("S12");
        row("set.contains(filed) after the change   ", parked.contains(filed));
        System.out.println("  the set still holds it           size=" + parked.size()
                + ", iterating finds it: " + parked.iterator().next().spotIds());
        System.out.println();
    }

    /**
     * Block 5. The cost of value equality, on one spot with two bikes. This is the case
     * corpus/parking-lot/reference/src/Occupancy.java names in its own comment.
     */
    private static void twoBikesOnOnePlate() {
        System.out.println("== 5. one spot, two bikes, both registered BIKE1 ==");

        Vehicle first = new Vehicle("BIKE1", VehicleType.MOTORBIKE);
        Vehicle second = new Vehicle("BIKE1", VehicleType.MOTORBIKE);

        List<Vehicle> byValue = new ArrayList<>(List.of(first, second));
        boolean removed = byValue.remove(second);
        System.out.println("  spot holds Vehicles, remove(second)  removed=" + removed
                + ", " + byValue.size() + " left, and the row that went was the first bike's: "
                + (byValue.get(0) == second));

        List<Occupancy> byIdentity = new ArrayList<>(List.of(
                new Occupancy("T1", VehicleType.MOTORBIKE),
                new Occupancy("T2", VehicleType.MOTORBIKE)));
        byIdentity.removeIf(o -> o.ticketId().equals("T2"));
        System.out.println("  spot holds Occupancy, vacate T2      left " + byIdentity);
        System.out.println();
    }

    /** Block 6. The line where A2 stops and A5 starts, on the corpus's own Vehicle. */
    private static void theA5Boundary() {
        System.out.println("== 6. what A2 did not buy you ==");

        Vehicle noPlate = new Vehicle(null, VehicleType.CAR);
        System.out.println("  new Vehicle(null, CAR)           built: " + noPlate);

        Vehicle blankPlate = new Vehicle("   ", VehicleType.CAR);
        Lot lot = new Lot(3, List.of(new Vehicle(PLATE, VehicleType.CAR)));
        Ticket ticket = lot.park(blankPlate, NOON);
        System.out.println("  lot.park(blank plate)            parked on " + ticket.spotId()
                + ", ticket " + ticket.ticketId());
        System.out.println("  lot.audit(that stay)             "
                + lot.audit(lot.stayFor(ticket.ticketId())));

        try {
            lot.park(new Vehicle(PLATE, null), NOON);
            System.out.println("  lot.park(null type)              parked");
        } catch (NullPointerException caught) {
            System.out.println("  lot.park(null type)              "
                    + caught.getClass().getSimpleName() + ": " + caught.getMessage());
            System.out.println("  first frame in this lesson's code  " + ourFrame(caught));
        }
        System.out.println();
    }

    /** The first frame that is not the JDK's, which is the file a reader has to open. */
    private static String ourFrame(Throwable thrown) {
        for (StackTraceElement frame : thrown.getStackTrace()) {
            if (frame.getClassLoaderName() != null) {
                return frame.toString();
            }
        }
        return "(none)";
    }

    private static void row(String label, boolean value) {
        System.out.println("  " + label + "  " + value);
    }

    private Main() {}
}

The faded stage is not here, on purpose

In the app, the third stage of a lesson hands you the worked design with a few lines replaced by // GAP: markers, then compiles your completion and runs a JUnit suite against it. That needs javac, and a static site has no compiler — so rather than show a control that cannot work, this page stops at the worked source.

Run the app for the drill: it is the download in the header, and it works offline once unpacked.

← A1 · Enum carrying state and behaviour — a name, a value, or a seam A3 · Sealed hierarchy for a closed set of variants — when to close the set →

← all lessons