diff --git a/README.md b/README.md
index 7375d0c..e2f8df7 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,8 @@ One module per article. This repository currently holds:
| Module | Article | Status |
|---|---|---|
-| [`collections`](collections/) | Top 40 Java Collections Interview Questions (HashMap Internals to Fail-Fast Iterators) | this commit |
+| [`collections`](collections/) | Top 40 Java Collections Interview Questions (HashMap Internals to Fail-Fast Iterators) | published |
+| [`lld-parking-lot`](lld-parking-lot/) | Low-Level Design Interview in Java: Parking Lot System, Step by Step | this commit |
## `collections` module
diff --git a/lld-parking-lot/README.md b/lld-parking-lot/README.md
new file mode 100644
index 0000000..b5f8752
--- /dev/null
+++ b/lld-parking-lot/README.md
@@ -0,0 +1,53 @@
+# lld-parking-lot
+
+A thread-safe parking lot in plain Java, built the way a low-level-design interview expects:
+requirements, entities, patterns, concurrency, extensions. Companion to
+[Low-Level Design Interview in Java: Parking Lot System, Step by Step](https://ankurm.com/low-level-design-interview-parking-lot-system-java/).
+
+No framework, no runtime dependencies. JUnit 6 is used for tests only.
+
+## Versions
+
+| Component | Version | Verified against |
+|---|---|---|
+| JDK | 25.0.4.1+1 (Temurin LTS); also compiled and run with `--release 21` on OpenJDK 21 | `java -version`; the walkthrough output is byte-identical on both |
+| JUnit Jupiter | 6.1.3 | `org.junit.jupiter:junit-jupiter` metadata on Maven Central |
+| Maven | 3.9.11 | `mvn -version` |
+
+## Run it
+
+```bash
+cd lld-parking-lot
+./scripts/run.sh # compiles with javac, prints the walkthrough (no Maven needed)
+mvn test # 10 tests, including two concurrency tests
+./scripts/run-all.sh # regenerates everything in output/
+```
+
+## Layout
+
+```
+src/main/java/com/ankurm/interviewlab/parking/
+ VehicleType, SpotSize enums; SpotSize.canHold(VehicleType) is the only "does it fit" rule
+ Vehicle, Ticket, Receipt immutable records
+ ParkingSpot occupancy is one AtomicReference compare-and-set
+ ParkingFloor ordered spots
+ AllocationStrategy Strategy: NearestFirstStrategy, BestFitStrategy
+ PricingStrategy Strategy: HourlyPricing (grace period, partial hours round up)
+ AvailabilityListener Observer: the entrance display board
+ ParkingException LotFull, AlreadyParked, UnknownTicket
+ ParkingLot the aggregate: park(), exit(), freeSpots()
+ Demo prints output/01-walkthrough.txt
+src/test/java/.../ParkingLotTest 10 tests; Clock is injected so nothing sleeps
+output/ captured real output
+```
+
+## Captured output
+
+| File | What it shows |
+|---|---|
+| `output/01-walkthrough.txt` | Enter, leave, fail three ways, and the two allocation strategies placing the same motorcycle differently |
+| `output/02-test-results.txt` | One line per test, generated from the Surefire XML report |
+
+## License
+
+MIT, see the repository root.
diff --git a/lld-parking-lot/output/01-walkthrough.txt b/lld-parking-lot/output/01-walkthrough.txt
new file mode 100644
index 0000000..235ea02
--- /dev/null
+++ b/lld-parking-lot/output/01-walkthrough.txt
@@ -0,0 +1,16 @@
+== 1. entering and leaving ==
+ board: {SMALL=1, MEDIUM=2, LARGE=1}
+ board: {SMALL=1, MEDIUM=1, LARGE=1}
+ board: {SMALL=1, MEDIUM=1, LARGE=0}
+bike -> F0-1, car -> F0-2, truck -> F0-3
+ board: {SMALL=1, MEDIUM=2, LARGE=0}
+car stayed 130 min, fee = 60
+
+== 2. failures ==
+second MH03-TRK : MH03-TRK is already inside the lot
+another truck : no free spot for TRUCK MH04-TRK
+reused ticket : unknown or already used ticket T-2
+
+== 3. the strategy decides where the motorcycle goes ==
+NearestFirstStrategy puts a motorcycle in F0-1
+BestFitStrategy puts a motorcycle in F1-1
diff --git a/lld-parking-lot/output/02-test-results.txt b/lld-parking-lot/output/02-test-results.txt
new file mode 100644
index 0000000..3f48cda
--- /dev/null
+++ b/lld-parking-lot/output/02-test-results.txt
@@ -0,0 +1,11 @@
+PASS truckIsRejectedWhenOnlyMediumSpotsAreFree
+PASS listenersSeeEveryChange
+PASS samePlateCannotBeInsideTwice
+PASS bestFitKeepsLargeSpotsFreeForTrucks
+PASS concurrentDuplicatePlateAdmitsExactlyOne
+PASS failedParkDoesNotLeaveThePlateReserved
+PASS nearestFirstTakesTheFirstFreeSpotThatFits
+PASS feeRoundsPartialHoursUpAndHasAGracePeriod
+PASS concurrentParkingNeverDoubleBooksASpot
+PASS exitFreesTheSpotAndTheTicketIsSingleUse
+10 tests, 0 failures
diff --git a/lld-parking-lot/pom.xml b/lld-parking-lot/pom.xml
new file mode 100644
index 0000000..521df91
--- /dev/null
+++ b/lld-parking-lot/pom.xml
@@ -0,0 +1,27 @@
+
+
+ 4.0.0
+
+ com.ankurm.interviewlab
+ lld-parking-lot
+ 1.0.0
+ lld-parking-lot
+ Low-level design interview: a thread-safe parking lot in plain Java
+
+
+ 21
+ UTF-8
+ 6.1.3
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+
diff --git a/lld-parking-lot/scripts/run-all.sh b/lld-parking-lot/scripts/run-all.sh
new file mode 100755
index 0000000..4e05fac
--- /dev/null
+++ b/lld-parking-lot/scripts/run-all.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Regenerates every transcript in output/. Needs JDK 21+ and Maven 3.9+.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+mkdir -p output
+./scripts/run.sh > output/01-walkthrough.txt 2>&1
+mvn -B -q test > /dev/null
+python3 - <<'PY' > output/02-test-results.txt
+import glob, xml.etree.ElementTree as ET
+total = fail = 0
+for f in sorted(glob.glob("target/surefire-reports/TEST-*.xml")):
+ for tc in ET.parse(f).getroot().iter("testcase"):
+ bad = tc.find("failure") is not None or tc.find("error") is not None
+ total += 1; fail += bad
+ print(("FAIL " if bad else "PASS ") + tc.get("name"))
+print(f"{total} tests, {fail} failures")
+PY
diff --git a/lld-parking-lot/scripts/run.sh b/lld-parking-lot/scripts/run.sh
new file mode 100755
index 0000000..26a0637
--- /dev/null
+++ b/lld-parking-lot/scripts/run.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# Compiles with plain javac (no Maven needed) and runs the walkthrough. Needs JDK 21 or newer.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+rm -rf target/plain && mkdir -p target/plain
+javac -d target/plain $(find src/main/java -name '*.java')
+java -cp target/plain com.ankurm.interviewlab.parking.Demo
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AllocationStrategy.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AllocationStrategy.java
new file mode 100644
index 0000000..e004b85
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AllocationStrategy.java
@@ -0,0 +1,12 @@
+package com.ankurm.interviewlab.parking;
+
+import java.util.List;
+
+/**
+ * Strategy pattern: decides the ORDER in which spots are offered to a vehicle.
+ * It never claims a spot itself, because claiming must be atomic and belongs to {@link ParkingSpot}.
+ * The lot walks the returned list and keeps the first spot that {@code tryOccupy} wins.
+ */
+public interface AllocationStrategy {
+ List candidates(List floors, Vehicle vehicle);
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AvailabilityListener.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AvailabilityListener.java
new file mode 100644
index 0000000..91028e7
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AvailabilityListener.java
@@ -0,0 +1,9 @@
+package com.ankurm.interviewlab.parking;
+
+import java.util.Map;
+
+/** Observer pattern: the display board at the entrance implements this. */
+@FunctionalInterface
+public interface AvailabilityListener {
+ void changed(Map freeBySize);
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/BestFitStrategy.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/BestFitStrategy.java
new file mode 100644
index 0000000..d97a293
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/BestFitStrategy.java
@@ -0,0 +1,17 @@
+package com.ankurm.interviewlab.parking;
+
+import java.util.Comparator;
+import java.util.List;
+
+/** Smallest spot that fits, nearest first among equals. Keeps large spots free for trucks. */
+public final class BestFitStrategy implements AllocationStrategy {
+ @Override
+ public List candidates(List floors, Vehicle vehicle) {
+ return floors.stream()
+ .flatMap(f -> f.spots().stream())
+ .filter(ParkingSpot::isFree)
+ .filter(s -> s.size().canHold(vehicle.type()))
+ .sorted(Comparator.comparing(ParkingSpot::size).thenComparingInt(ParkingSpot::floor))
+ .toList();
+ }
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Demo.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Demo.java
new file mode 100644
index 0000000..a9cb8a4
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Demo.java
@@ -0,0 +1,58 @@
+package com.ankurm.interviewlab.parking;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+
+/** Prints the whole story once; the transcript is committed as output/01-walkthrough.txt. */
+public final class Demo {
+
+ /** A clock we can move by hand, so "2 hours later" needs no sleeping. */
+ static final class ManualClock extends Clock {
+ private Instant now = Instant.parse("2026-10-22T09:00:00Z");
+ void advance(Duration d) { now = now.plus(d); }
+ @Override public Instant instant() { return now; }
+ @Override public java.time.ZoneId getZone() { return ZoneOffset.UTC; }
+ @Override public Clock withZone(java.time.ZoneId zone) { return this; }
+ }
+
+ public static void main(String[] args) {
+ System.out.println("== 1. entering and leaving ==");
+ var clock = new ManualClock();
+ var lot = new ParkingLot(
+ List.of(ParkingFloor.of(0, SpotSize.SMALL, SpotSize.MEDIUM, SpotSize.LARGE),
+ ParkingFloor.of(1, SpotSize.SMALL, SpotSize.MEDIUM)),
+ new BestFitStrategy(), HourlyPricing.standard(), clock);
+ lot.addListener(free -> System.out.println(" board: " + free));
+
+ Ticket bike = lot.park(new Vehicle("MH01-BIKE", VehicleType.MOTORCYCLE));
+ Ticket car = lot.park(new Vehicle("MH02-CAR", VehicleType.CAR));
+ Ticket truck = lot.park(new Vehicle("MH03-TRK", VehicleType.TRUCK));
+ System.out.println("bike -> " + bike.spotId() + ", car -> " + car.spotId() + ", truck -> " + truck.spotId());
+
+ clock.advance(Duration.ofHours(2).plusMinutes(10));
+ Receipt r = lot.exit(car.id());
+ System.out.println("car stayed " + r.duration().toMinutes() + " min, fee = " + r.fee());
+
+ System.out.println();
+ System.out.println("== 2. failures ==");
+ try { lot.park(new Vehicle("MH03-TRK", VehicleType.TRUCK)); }
+ catch (ParkingException e) { System.out.println("second MH03-TRK : " + e.getMessage()); }
+ try { lot.park(new Vehicle("MH04-TRK", VehicleType.TRUCK)); }
+ catch (ParkingException e) { System.out.println("another truck : " + e.getMessage()); }
+ try { lot.exit(car.id()); }
+ catch (ParkingException e) { System.out.println("reused ticket : " + e.getMessage()); }
+
+ System.out.println();
+ System.out.println("== 3. the strategy decides where the motorcycle goes ==");
+ for (AllocationStrategy s : List.of(new NearestFirstStrategy(), new BestFitStrategy())) {
+ var l = new ParkingLot(
+ List.of(ParkingFloor.of(0, SpotSize.LARGE), ParkingFloor.of(1, SpotSize.SMALL)),
+ s, HourlyPricing.standard(), clock);
+ System.out.println(s.getClass().getSimpleName() + " puts a motorcycle in "
+ + l.park(new Vehicle("MH05-BIKE", VehicleType.MOTORCYCLE)).spotId());
+ }
+ }
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/HourlyPricing.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/HourlyPricing.java
new file mode 100644
index 0000000..69121d0
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/HourlyPricing.java
@@ -0,0 +1,34 @@
+package com.ankurm.interviewlab.parking;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.util.Map;
+
+/**
+ * Per-hour rate by vehicle type, partial hours rounded UP, with a free grace period.
+ * Rates are whole rupees to keep the example free of rounding noise; money would be BigDecimal in production anyway.
+ */
+public final class HourlyPricing implements PricingStrategy {
+ private final Map ratePerHour;
+ private final Duration grace;
+
+ public HourlyPricing(Map ratePerHour, Duration grace) {
+ this.ratePerHour = Map.copyOf(ratePerHour);
+ this.grace = grace;
+ }
+
+ public static HourlyPricing standard() {
+ return new HourlyPricing(Map.of(
+ VehicleType.MOTORCYCLE, BigDecimal.valueOf(10),
+ VehicleType.CAR, BigDecimal.valueOf(20),
+ VehicleType.TRUCK, BigDecimal.valueOf(40)), Duration.ofMinutes(15));
+ }
+
+ @Override
+ public BigDecimal fee(Vehicle vehicle, Duration stayed) {
+ if (stayed.compareTo(grace) <= 0) return BigDecimal.ZERO;
+ long minutes = stayed.toMinutes() + (stayed.toSecondsPart() > 0 ? 1 : 0);
+ long hours = Math.max(1, (minutes + 59) / 60);
+ return ratePerHour.get(vehicle.type()).multiply(BigDecimal.valueOf(hours));
+ }
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/NearestFirstStrategy.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/NearestFirstStrategy.java
new file mode 100644
index 0000000..9c15c5a
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/NearestFirstStrategy.java
@@ -0,0 +1,15 @@
+package com.ankurm.interviewlab.parking;
+
+import java.util.List;
+
+/** Lowest floor, then lowest spot number. Fast for the driver, wasteful for big spots. */
+public final class NearestFirstStrategy implements AllocationStrategy {
+ @Override
+ public List candidates(List floors, Vehicle vehicle) {
+ return floors.stream()
+ .flatMap(f -> f.spots().stream())
+ .filter(ParkingSpot::isFree)
+ .filter(s -> s.size().canHold(vehicle.type()))
+ .toList();
+ }
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingException.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingException.java
new file mode 100644
index 0000000..77b146a
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingException.java
@@ -0,0 +1,18 @@
+package com.ankurm.interviewlab.parking;
+
+/** Base type so callers can catch one thing; the nested types say what actually went wrong. */
+public abstract class ParkingException extends RuntimeException {
+ protected ParkingException(String message) { super(message); }
+
+ public static final class LotFull extends ParkingException {
+ public LotFull(Vehicle v) { super("no free spot for " + v.type() + " " + v.plate()); }
+ }
+
+ public static final class AlreadyParked extends ParkingException {
+ public AlreadyParked(String plate) { super(plate + " is already inside the lot"); }
+ }
+
+ public static final class UnknownTicket extends ParkingException {
+ public UnknownTicket(String id) { super("unknown or already used ticket " + id); }
+ }
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingFloor.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingFloor.java
new file mode 100644
index 0000000..b7bd182
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingFloor.java
@@ -0,0 +1,19 @@
+package com.ankurm.interviewlab.parking;
+
+import java.util.List;
+
+/** A floor is just an ordered group of spots; the order is the "nearest to the entrance" order. */
+public record ParkingFloor(int number, List spots) {
+ public ParkingFloor {
+ spots = List.copyOf(spots);
+ }
+
+ /** Convenience factory: {@code ParkingFloor.of(0, SMALL, SMALL, MEDIUM, LARGE)} creates spots F0-1..F0-4. */
+ public static ParkingFloor of(int number, SpotSize... sizes) {
+ var spots = new java.util.ArrayList();
+ for (int i = 0; i < sizes.length; i++) {
+ spots.add(new ParkingSpot("F" + number + "-" + (i + 1), number, sizes[i]));
+ }
+ return new ParkingFloor(number, spots);
+ }
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingLot.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingLot.java
new file mode 100644
index 0000000..4fdf9dc
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingLot.java
@@ -0,0 +1,90 @@
+package com.ankurm.interviewlab.parking;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * The aggregate the interviewer is really asking about.
+ *
+ * Thread-safety without a big lock:
+ * - a spot is claimed with one compare-and-set ({@link ParkingSpot#tryOccupy});
+ * - "one plate inside at most once" is a concurrent set add;
+ * - tickets live in a ConcurrentHashMap, and removing one is what makes a ticket single-use.
+ */
+public final class ParkingLot {
+ private final List floors;
+ private final Map spotsById = new HashMap<>();
+ private final AllocationStrategy allocation;
+ private final PricingStrategy pricing;
+ private final Clock clock;
+
+ private final Set platesInside = ConcurrentHashMap.newKeySet();
+ private final Map activeTickets = new ConcurrentHashMap<>();
+ private final AtomicLong ticketSequence = new AtomicLong();
+ private final List listeners = new CopyOnWriteArrayList<>();
+
+ public ParkingLot(List floors, AllocationStrategy allocation, PricingStrategy pricing, Clock clock) {
+ this.floors = List.copyOf(floors);
+ this.allocation = allocation;
+ this.pricing = pricing;
+ this.clock = clock;
+ for (ParkingFloor f : floors) for (ParkingSpot s : f.spots()) spotsById.put(s.id(), s);
+ }
+
+ public void addListener(AvailabilityListener l) { listeners.add(l); }
+
+ public Ticket park(Vehicle vehicle) {
+ if (!platesInside.add(vehicle.plate())) throw new ParkingException.AlreadyParked(vehicle.plate());
+ for (ParkingSpot spot : allocation.candidates(floors, vehicle)) {
+ if (spot.tryOccupy(vehicle)) { // lost a race? try the next candidate
+ Ticket t = new Ticket("T-" + ticketSequence.incrementAndGet(), vehicle, spot.id(), clock.instant());
+ activeTickets.put(t.id(), t);
+ publish();
+ return t;
+ }
+ }
+ platesInside.remove(vehicle.plate()); // undo the plate reservation
+ throw new ParkingException.LotFull(vehicle);
+ }
+
+ public Receipt exit(String ticketId) {
+ Ticket t = activeTickets.remove(ticketId); // atomic: only one caller can get the ticket
+ if (t == null) throw new ParkingException.UnknownTicket(ticketId);
+ Instant now = clock.instant();
+ Duration stayed = Duration.between(t.entry(), now);
+ var receipt = new Receipt(t, now, stayed, pricing.fee(t.vehicle(), stayed));
+ spotsById.get(t.spotId()).release();
+ platesInside.remove(t.vehicle().plate());
+ publish();
+ return receipt;
+ }
+
+ public long freeSpots(SpotSize size) {
+ return floors.stream().flatMap(f -> f.spots().stream())
+ .filter(s -> s.size() == size && s.isFree()).count();
+ }
+
+ public Map freeBySize() {
+ Map m = new EnumMap<>(SpotSize.class);
+ for (SpotSize s : SpotSize.values()) m.put(s, freeSpots(s));
+ return m;
+ }
+
+ private void publish() {
+ if (listeners.isEmpty()) return;
+ Map snapshot = freeBySize();
+ for (AvailabilityListener l : listeners) l.changed(snapshot);
+ }
+
+ public List floors() { return new ArrayList<>(floors); }
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingSpot.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingSpot.java
new file mode 100644
index 0000000..89c03eb
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingSpot.java
@@ -0,0 +1,36 @@
+package com.ankurm.interviewlab.parking;
+
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * One physical spot. Occupancy is a single compare-and-set, which is the whole concurrency story:
+ * two cars racing for the same spot cannot both win, and no lock is needed.
+ */
+public final class ParkingSpot {
+ private final String id;
+ private final int floor;
+ private final SpotSize size;
+ private final AtomicReference occupant = new AtomicReference<>();
+
+ public ParkingSpot(String id, int floor, SpotSize size) {
+ this.id = id;
+ this.floor = floor;
+ this.size = size;
+ }
+
+ public String id() { return id; }
+ public int floor() { return floor; }
+ public SpotSize size() { return size; }
+ public boolean isFree() { return occupant.get() == null; }
+ public Optional occupant() { return Optional.ofNullable(occupant.get()); }
+
+ /** Atomically claims the spot. Returns false if another thread got there first or the vehicle does not fit. */
+ public boolean tryOccupy(Vehicle v) {
+ return size.canHold(v.type()) && occupant.compareAndSet(null, v);
+ }
+
+ public void release() { occupant.set(null); }
+
+ @Override public String toString() { return id + "(" + size + ")"; }
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/PricingStrategy.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/PricingStrategy.java
new file mode 100644
index 0000000..00d232a
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/PricingStrategy.java
@@ -0,0 +1,9 @@
+package com.ankurm.interviewlab.parking;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+
+/** Strategy pattern again: pricing rules change far more often than parking rules. */
+public interface PricingStrategy {
+ BigDecimal fee(Vehicle vehicle, Duration stayed);
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Receipt.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Receipt.java
new file mode 100644
index 0000000..a0aaf31
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Receipt.java
@@ -0,0 +1,8 @@
+package com.ankurm.interviewlab.parking;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.Instant;
+
+/** What the driver gets at the gate: how long they stayed and what they owe. */
+public record Receipt(Ticket ticket, Instant exit, Duration duration, BigDecimal fee) {}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/SpotSize.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/SpotSize.java
new file mode 100644
index 0000000..02d3c82
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/SpotSize.java
@@ -0,0 +1,11 @@
+package com.ankurm.interviewlab.parking;
+
+/** Physical size of a spot. Ordered small to large so "fits" is a single comparison. */
+public enum SpotSize {
+ SMALL, MEDIUM, LARGE;
+
+ /** A spot can hold a vehicle when it is at least as big as the vehicle's minimum spot. */
+ public boolean canHold(VehicleType type) {
+ return this.ordinal() >= type.minimumSpot().ordinal();
+ }
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Ticket.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Ticket.java
new file mode 100644
index 0000000..242c2d7
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Ticket.java
@@ -0,0 +1,6 @@
+package com.ankurm.interviewlab.parking;
+
+import java.time.Instant;
+
+/** Issued at entry, presented at exit. Immutable; the lot never mutates a ticket. */
+public record Ticket(String id, Vehicle vehicle, String spotId, Instant entry) {}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Vehicle.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Vehicle.java
new file mode 100644
index 0000000..1546157
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Vehicle.java
@@ -0,0 +1,12 @@
+package com.ankurm.interviewlab.parking;
+
+import java.util.Objects;
+
+/** Immutable. The plate is the identity: one plate can be inside the lot at most once. */
+public record Vehicle(String plate, VehicleType type) {
+ public Vehicle {
+ Objects.requireNonNull(plate, "plate");
+ Objects.requireNonNull(type, "type");
+ if (plate.isBlank()) throw new IllegalArgumentException("plate must not be blank");
+ }
+}
diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/VehicleType.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/VehicleType.java
new file mode 100644
index 0000000..cf09d0c
--- /dev/null
+++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/VehicleType.java
@@ -0,0 +1,14 @@
+package com.ankurm.interviewlab.parking;
+
+/** Vehicle categories and the smallest spot each one fits in. */
+public enum VehicleType {
+ MOTORCYCLE(SpotSize.SMALL),
+ CAR(SpotSize.MEDIUM),
+ TRUCK(SpotSize.LARGE);
+
+ private final SpotSize minimumSpot;
+
+ VehicleType(SpotSize minimumSpot) { this.minimumSpot = minimumSpot; }
+
+ public SpotSize minimumSpot() { return minimumSpot; }
+}
diff --git a/lld-parking-lot/src/test/java/com/ankurm/interviewlab/parking/ParkingLotTest.java b/lld-parking-lot/src/test/java/com/ankurm/interviewlab/parking/ParkingLotTest.java
new file mode 100644
index 0000000..e1474cd
--- /dev/null
+++ b/lld-parking-lot/src/test/java/com/ankurm/interviewlab/parking/ParkingLotTest.java
@@ -0,0 +1,169 @@
+package com.ankurm.interviewlab.parking;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.Test;
+
+class ParkingLotTest {
+
+ /** Hand-moved clock: tests never sleep. */
+ static final class ManualClock extends Clock {
+ private volatile Instant now = Instant.parse("2026-10-22T09:00:00Z");
+ void advance(Duration d) { now = now.plus(d); }
+ @Override public Instant instant() { return now; }
+ @Override public ZoneId getZone() { return ZoneOffset.UTC; }
+ @Override public Clock withZone(ZoneId zone) { return this; }
+ }
+
+ private static Vehicle car(String plate) { return new Vehicle(plate, VehicleType.CAR); }
+
+ private ParkingLot lot(AllocationStrategy s, ManualClock c, ParkingFloor... floors) {
+ return new ParkingLot(List.of(floors), s, HourlyPricing.standard(), c);
+ }
+
+ @Test
+ void bestFitKeepsLargeSpotsFreeForTrucks() {
+ var lot = lot(new BestFitStrategy(), new ManualClock(),
+ ParkingFloor.of(0, SpotSize.LARGE, SpotSize.MEDIUM));
+ assertEquals("F0-2", lot.park(car("A")).spotId()); // medium, not the nearer large
+ assertEquals(1, lot.freeSpots(SpotSize.LARGE));
+ }
+
+ @Test
+ void nearestFirstTakesTheFirstFreeSpotThatFits() {
+ var lot = lot(new NearestFirstStrategy(), new ManualClock(),
+ ParkingFloor.of(0, SpotSize.LARGE, SpotSize.MEDIUM));
+ assertEquals("F0-1", lot.park(car("A")).spotId()); // the large spot is nearer
+ }
+
+ @Test
+ void truckIsRejectedWhenOnlyMediumSpotsAreFree() {
+ var lot = lot(new BestFitStrategy(), new ManualClock(), ParkingFloor.of(0, SpotSize.MEDIUM));
+ assertThrows(ParkingException.LotFull.class, () -> lot.park(new Vehicle("T", VehicleType.TRUCK)));
+ }
+
+ @Test
+ void failedParkDoesNotLeaveThePlateReserved() {
+ var lot = lot(new BestFitStrategy(), new ManualClock(), ParkingFloor.of(0, SpotSize.MEDIUM));
+ var truck = new Vehicle("T", VehicleType.TRUCK);
+ assertThrows(ParkingException.LotFull.class, () -> lot.park(truck));
+ assertThrows(ParkingException.LotFull.class, () -> lot.park(truck)); // not AlreadyParked
+ }
+
+ @Test
+ void samePlateCannotBeInsideTwice() {
+ var lot = lot(new BestFitStrategy(), new ManualClock(), ParkingFloor.of(0, SpotSize.MEDIUM, SpotSize.MEDIUM));
+ lot.park(car("A"));
+ assertThrows(ParkingException.AlreadyParked.class, () -> lot.park(car("A")));
+ }
+
+ @Test
+ void feeRoundsPartialHoursUpAndHasAGracePeriod() {
+ var clock = new ManualClock();
+ var lot = lot(new BestFitStrategy(), clock, ParkingFloor.of(0, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM));
+ record Case(Duration stay, int expected) {}
+ for (var c : List.of(
+ new Case(Duration.ofMinutes(10), 0), // inside grace
+ new Case(Duration.ofMinutes(15), 0), // grace boundary is free
+ new Case(Duration.ofMinutes(16), 20), // 1 started hour
+ new Case(Duration.ofMinutes(60), 20), // exactly 1 hour
+ new Case(Duration.ofMinutes(61), 40))) { // 2 started hours
+ Ticket t = lot.park(car("P" + c.stay().toMinutes()));
+ clock.advance(c.stay());
+ assertEquals(0, BigDecimal.valueOf(c.expected()).compareTo(lot.exit(t.id()).fee()), "stay " + c.stay());
+ }
+ }
+
+ @Test
+ void exitFreesTheSpotAndTheTicketIsSingleUse() {
+ var lot = lot(new BestFitStrategy(), new ManualClock(), ParkingFloor.of(0, SpotSize.MEDIUM));
+ Ticket t = lot.park(car("A"));
+ assertEquals(0, lot.freeSpots(SpotSize.MEDIUM));
+ lot.exit(t.id());
+ assertEquals(1, lot.freeSpots(SpotSize.MEDIUM));
+ assertThrows(ParkingException.UnknownTicket.class, () -> lot.exit(t.id()));
+ assertDoesNotThrow(() -> lot.park(car("B"))); // spot is reusable
+ }
+
+ @Test
+ void listenersSeeEveryChange() {
+ var lot = lot(new BestFitStrategy(), new ManualClock(), ParkingFloor.of(0, SpotSize.MEDIUM, SpotSize.MEDIUM));
+ List seen = new ArrayList<>();
+ lot.addListener(free -> seen.add(free.get(SpotSize.MEDIUM)));
+ Ticket t = lot.park(car("A"));
+ lot.park(car("B"));
+ lot.exit(t.id());
+ assertEquals(List.of(1L, 0L, 1L), seen);
+ }
+
+ /** 64 threads race for 10 spots: exactly 10 win, and no two winners share a spot. */
+ @Test
+ void concurrentParkingNeverDoubleBooksASpot() throws Exception {
+ var lot = lot(new BestFitStrategy(), new ManualClock(),
+ ParkingFloor.of(0, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM),
+ ParkingFloor.of(1, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM));
+ int threads = 64;
+ var start = new CountDownLatch(1);
+ var parked = new AtomicInteger();
+ var full = new AtomicInteger();
+ Set spots = ConcurrentHashMap.newKeySet();
+ try (var pool = Executors.newFixedThreadPool(threads)) {
+ List> fs = new ArrayList<>();
+ for (int i = 0; i < threads; i++) {
+ final int n = i;
+ fs.add(pool.submit(() -> {
+ start.await();
+ try {
+ spots.add(lot.park(car("CAR-" + n)).spotId());
+ parked.incrementAndGet();
+ } catch (ParkingException.LotFull e) {
+ full.incrementAndGet();
+ }
+ return null;
+ }));
+ }
+ start.countDown();
+ for (Future> f : fs) f.get();
+ }
+ assertEquals(10, parked.get());
+ assertEquals(54, full.get());
+ assertEquals(10, spots.size(), "every winner got a different spot");
+ assertEquals(0, lot.freeSpots(SpotSize.MEDIUM));
+ }
+
+ /** The same plate from many threads: exactly one gets in. */
+ @Test
+ void concurrentDuplicatePlateAdmitsExactlyOne() throws Exception {
+ var lot = lot(new BestFitStrategy(), new ManualClock(),
+ ParkingFloor.of(0, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM));
+ var start = new CountDownLatch(1);
+ var ok = new AtomicInteger();
+ try (var pool = Executors.newFixedThreadPool(16)) {
+ List> fs = new ArrayList<>();
+ for (int i = 0; i < 16; i++) {
+ fs.add(pool.submit(() -> {
+ start.await();
+ try { lot.park(car("SAME")); ok.incrementAndGet(); }
+ catch (ParkingException.AlreadyParked ignored) { }
+ return null;
+ }));
+ }
+ start.countDown();
+ for (Future> f : fs) f.get();
+ }
+ assertEquals(1, ok.get());
+ assertEquals(3, lot.freeSpots(SpotSize.MEDIUM));
+ }
+}