Two gates
Validation at the boundary
Validation means rejecting bad input at a clear boundary, then trusting typed objects inside the domain. Example: VehicleType arrives as a string from the kiosk. Three methods each check null plate, empty plate, unknown type. One of them forgets. Validate at the controller or , then let Ticket assume it was given a real Vehicle.
Gate 1 — the edge
: missing fields, bad enums, plates that are blank. After that you have a Vehicle. This is the last place a bad string is allowed to exist. .
- Blank plate → IllegalArgumentException (or a named input error)
- Unknown type token → VehicleType.parse throws
- Controller maps ParkRequest → Vehicle
Gate 2 — the entity
requireNonNull on ids. A spot holds at most one car. close() checks status. These are invariants, not regex. close() does not also check that the plate matches a pattern.
- Ticket constructor rejects null Vehicle
- Spot.tryOccupy fails if occupied
- InvalidTransition on CLOSED→ACTIVE
Where the same null check dies
Do not sprinkle the same null checks through ParkingLot, Pricing, and the repository. If the factory throws on unknown type, the lot never sees a raw string. Pair this with input/output models and named errors. Value objects like Plate belong in entity vs value object.
Java: parse once, then trust the type
class VehicleFactory {
Vehicle from(String plate, String rawType) {
if (plate == null || plate.isBlank()) {
throw new IllegalArgumentException("plate");
}
VehicleType type = VehicleType.parse(rawType); // throws if unknown
return new Vehicle(new Plate(plate.trim()), type);
}
}
class Ticket {
Ticket(TicketId id, Vehicle vehicle, SpotId spot, Instant entry) {
this.id = Objects.requireNonNull(id);
this.vehicle = Objects.requireNonNull(vehicle);
this.spotId = Objects.requireNonNull(spot);
this.entry = Objects.requireNonNull(entry);
}
}The factory is the last string
After Vehicle exists, ParkingLot.park(Vehicle) does not check plate != null. That check already happened. If a test constructs Vehicle directly, it uses a legal Plate. Tests of the factory use the dirty strings. Tests of the lot use clean types. Validating once at construction means you never copy the same plate check into every method.
Java: VehicleType.parse
enum VehicleType {
CAR, BIKE, BUS, EV;
static VehicleType parse(String raw) {
if (raw == null) throw new IllegalArgumentException("type");
try {
return VehicleType.valueOf(raw.trim().toUpperCase());
} catch (IllegalArgumentException ex) {
throw new UnknownVehicleType(raw);
}
}
}Two mistakes
The same null check in every method
If park, exit, and fee all test plate != null, the real type is still String. Introduce Plate or Vehicle at the factory.
Entity parsing HTTP
Ticket.fromJson(map) puts the boundary in the middle of the domain. Validate outside. Construct inside with real types.
Security cousin, not a substitute
Authn, authz, and injection defenses live in API security. LLD validation is still: invalid strings are rejected at the boundary; invariants live on Ticket. Do not skip Plate because “the gateway already validated.” The lot must stay honest if a test or another process constructs objects.
What each layer still does
Kiosk / HTTP
missing JSON fields
│
▼
VehicleFactory / controller ← last raw String
blank plate, unknown type
│ Vehicle
▼
ParkingLot.park(Vehicle) ← allocation, capacity
│
▼
Ticket / Spot ← occupy once, close once
│
▼
TicketRepository ← persist; no plate regexInvariants are not regex
Plate format is input. “A spot holds one car” is an invariant. “Fee is never negative” is an invariant if that is the rule. Putting the regex on Ticket.close() means every close re-validates a plate that was already a Plate. Putting occupy rules only on the factory means a test that constructs Spot directly can double-park. Edge validation is for strings. Entity guards are for the life of the object.
An untyped process(Object) method usually validates inside the blob with a pile of ifs. You cannot tell which ifs are parsing and which are “lot is full.” Split them: factory for strings, park for capacity, Ticket.close for status. Three places, three kinds of failure, three tests.
Java: Plate rejects blank once
final class Plate {
private final String value;
Plate(String raw) {
if (raw == null || raw.isBlank()) throw new IllegalArgumentException("plate");
this.value = raw.trim().toUpperCase();
}
String value() { return value; }
@Override public boolean equals(Object o) {
return o instanceof Plate p && value.equals(p.value);
}
@Override public int hashCode() { return value.hashCode(); }
}Interview split
“Invalid strings are rejected in VehicleFactory. ParkingLot.park assumes a Vehicle. Ticket still refuses to close twice.” If they ask about SQL CHECK constraints, agree they can exist, and still keep Plate. Constraints in the database are a second gate, not a reason to skip the type.
Exercises
- Where is the last place a bad vehicle-type string is allowed? Name the class and the method.
- Ticket.close() still checks status after the factory succeeded. Why is that not duplicate validation?
- A teammate adds plate.matches("[A-Z0-9]+") inside PricingStrategy. Where should that rule move?
Worked scene: three methods, one forgotten check
park, fee, and notifySms each test plate != null. notifySms is written last and skips the test. A blank plate from a buggy kiosk sends an SMS with no plate and parks anyway. VehicleFactory.from would have thrown before park ran. The forgotten check cannot exist if the type does not exist. That is the whole argument for parse-once.
Ticket.close still checks ACTIVE. That is not a forgotten plate check; it is an invariant that remains true after a legal Vehicle was built. Mixing those two in one validate() on ParkingLot is how the lot becomes a god object that re-parses JSON and also runs the state machine.
class ParkingLot {
Ticket park(Vehicle vehicle) {
// vehicle is already legal — no plate regex here
return allocation.open(vehicle, clock.instant());
}
}How to explain this in an interview
Blank plate
Plate / VehicleFactory. Last raw string. Not PricingStrategy.
Unknown type token
VehicleType.parse. The lot never sees "carx".
Close twice
Ticket.close invariant. Not a regex. Not JSON.
Same null check thrice
You are missing a type. Introduce Plate or Vehicle.
In about a minute, say: “Invalid strings are rejected in VehicleFactory. park(Vehicle) assumes a Vehicle. Ticket.close still rejects CLOSED→ACTIVE. That is not duplicate validation — it is a different gate.”
Plate regex does not belong on PricingStrategy. SQL CHECK can exist and still not replace Plate. An untyped process(Object) method piles parsing and capacity into one blob of ifs; split them.
Java: close still guards after a legal Vehicle
Receipt close(Instant exit, PricingStrategy pricing) {
if (status != TicketStatus.ACTIVE) {
throw new InvalidTransition(status, TicketStatus.CLOSED);
}
status = TicketStatus.CLOSED;
return new Receipt(id, pricing.fee(this, exit));
}Takeaway
Parse once at the factory. Trust Vehicle inside the lot. Entities still reject illegal transitions — they do not re-parse JSON. If the same null check appears in three methods, you are missing a type.