Low Level Design (LLD) Interview Questions
Design a Parking Lot
Tier: EssentialDifficulty: EasyAsked of: Junior, MidAsked at: Amazon, Google, Microsoft, Adobe, Uber, Grab, Gojek, Swiggy
A parking lot is a small allocation system with a fee attached. The entities are easy, so nobody is testing whether you can name a class Vehicle. They are testing whether you put the rules that change in one place and the rules that do not in another, and whether you notice that two cars can reach the last bay at the same moment.
This is the most asked low level design question there is, which cuts both ways. The interviewer has heard the standard answer many times, so the marks are in the parts most candidates skip. Those are the clarifying questions, where allocation policy lives, and what happens under contention.
What to clarify first
Spend two or three minutes here. It is graded, and it removes most of the scope you would otherwise have to design.
- How many vehicle types, and do bays have sizes. This is the difference between a list of bays and a fitting rule. Motorcycle, car and truck is the usual answer.
- One lot or many. Almost always one, which kills a whole layer of classes.
- Multiple floors. Usually yes, and it is cheap, so take it.
- How is a car charged. Per hour, per day, a free first fifteen minutes, different rates by vehicle type. Ask, because pricing is the thing they will change on you halfway through.
- Is payment in scope. Normally you take a fee amount and stop there. Card processing is a different interview.
- Is a bay ever reserved in advance, for electric charging or for staff. If yes, allocation gets a filter and the design should have room for it.
- How does the car get identified at exit. A printed ticket, a plate reader, or both. This decides whether the ticket or the plate is the key.
- Do we need a live count of free bays at the entrance. Cheap to add, and it forces the useful conversation about a number that is out of date the moment it is read.
Then say what you are leaving out. Payment gateways, valet, season passes, number plate recognition. An explicit out of scope list reads as control rather than as omission.
The classes
Group them by what they are for. Things that hold state, rules that vary, and the one class the outside world talks to.
The model, what the lot is made of.
- VehicleType is an enum, and it carries the bay size it needs. Putting the size on the type means no other class ever writes a switch over vehicle types. Adding a bus is one line in one enum.
- Vehicle is a value object of a plate and a type. It is a record in Java and a data class in Kotlin because it has no behaviour and no identity beyond its fields.
- SpotSize is an ordered enum, small to medium to large. The ordering is the fitting rule, so a bay accepts anything needing its own size or less.
- ParkingSpot owns one fact, whether it is occupied and by whom. It is the only class allowed to change that, and the only way to take a bay is one atomic call named claim. Everything about the race for the last space is solved inside this small class, which is why it matters more than its size suggests.
- Ticket is the proof that a bay belongs to a driver. It carries the bay id, so exit never searches the lot for the car, and the entry time, so the fee is a pure function of the ticket and the clock.
- ParkingReceipt is what the exit gate hands back, the amount and the stay it was based on.
There is no Floor class, and that is deliberate. A bay already knows its floor number, so a floor object would hold a list and forward every call to it. The bay carries its floor for the ticket and the sign, and allocation never reads it, because the walk distance already says how far away a bay is. Say that out loud, because knowing which classes not to create is part of what is being marked.
The rules, the parts that vary.
- SpotAllocationStrategy decides which free bay a vehicle gets, and claims it. This exists because it is the most likely thing to change and because the interviewer will ask for a different rule as a follow up. Two implementations ship, nearest to the entrance and best fit.
- FeeStrategy turns a vehicle and a duration into an amount. Same reasoning. A new price list is a new lambda rather than an edit to the lot.
The front door.
- ParkingLot is the only class the gates and kiosks talk to. It owns the bays and the open tickets, and it owns no rules at all. Every rule is a strategy it was handed at construction, which is what makes the design easy to change and easy to test with a fake clock.
The relationships are short to say out loud. A lot owns bays, a bay may hold a vehicle, a ticket points at a bay. A lot uses one allocation strategy and one fee strategy and knows neither by name.
How a car actually parks
This is the paragraph to say at the whiteboard. Walk one car through the objects and the design explains itself.
A car pulls up at the barrier. The gate calls enter on the lot with a Vehicle. The lot first claims the plate in its plate index, in one atomic step, because a driver who scans twice should get the ticket they already hold, not a second bay.
Inside that claim the lot hands the vehicle and its bays to the allocation strategy. The strategy filters to free bays that can hold this vehicle, sorts them by whatever it considers best, and walks that shortlist claiming bays until one claim succeeds. It returns the bay it won, or nothing if the lot is full for that size. Sorting a few thousand bays per car is microseconds. If it ever mattered you would keep a free list per size, and the claim would still be the decision.
The lot issues a ticket for that bay, stores it under the ticket id, and the plate index ends up pointing at it. If there was no bay, nothing is recorded against the plate, so the next scan tries again. The barrier lifts.
At exit the driver presents the ticket. The lot removes the ticket from its open map, and that removal is the thing that makes exit safe to repeat. It looks up the bay from the id on the ticket, asks the fee strategy for an amount using the duration, and frees the bay.
Notice what the lot never does. It never decides where a car goes and it never decides what it costs. It sequences the steps and owns the bookkeeping. That separation is most of the answer.
Patterns actually used
Strategy, twice, and both are load bearing. Allocation and pricing are the two rules a real car park changes without changing anything else. Without the seam, the follow up question, now put trucks at the far end and charge a flat overnight rate, means editing the class that also issues tickets. With it, the answer is a new class and one line at construction.
One detail is worth saying out loud. The allocation strategy claims the bay as well as choosing it. If it only returned a suggestion, every caller would have to check whether it was still free and then take it, and that gap is a race. Keeping choose and claim in one call means the caller cannot get it wrong.
Deliberately not used, and this half of the answer is worth as many marks.
- No singleton, even though most write ups make the lot one. It buys nothing and costs testability, because a test wants three lots and a fake clock. If the application needs exactly one instance, that is a job for whatever wires it up.
- No factory. The usual justification is creating bays by size, which is a constructor call with an enum in it. Adding a class to avoid a two arm switch reads as recitation.
- No state pattern. A bay is free or taken, and that is a field. State earns its keep when the same operation means different things in different states, which is true of a vending machine and not of a parking bay.
- No observer. A display board that subscribes to bay events sounds good and is not needed. Counting a few thousand bays when the sign refreshes is instant, and a cached counter is one more thing that can drift out of step with the truth. If the follow up is a live feed to a hundred screens, then add the listener and say why it now earns its place.
The implementation
Both languages carry the same design, and both are short enough to write by hand. The Kotlin is written as Kotlin, so the strategies are fun interfaces you hand a lambda, the values are data classes, and a full lot returns null rather than an Optional.
Java
com.androidinterview.parkinglot.model.ParkingReceipt.java
package com.androidinterview.parkinglot.model;
import java.time.Duration;
// What the exit gate hands back. The amount is a long of minor units, never a
// double, because money in floating point is a rounding bug in waiting.
public record ParkingReceipt(String ticketId, Duration stay, long amountMinor) {}
com.androidinterview.parkinglot.model.ParkingSpot.java
package com.androidinterview.parkinglot.model;
import java.util.concurrent.atomic.AtomicReference;
// One bay. It owns exactly one fact, whether it is occupied and by whom.
//
// The occupant is an AtomicReference because two cars can reach the last bay
// at the same moment. Claiming is a compare and set from empty, so one call
// wins and the other is told to look elsewhere. A real system would hold this
// in a database row and claim it with a conditional update instead.
public final class ParkingSpot {
private final String id;
private final int floor;
private final SpotSize size;
// Walking distance from the entrance in metres, used by nearest first.
private final int distance;
private final AtomicReference<Vehicle> occupant = new AtomicReference<>();
public ParkingSpot(String id, int floor, SpotSize size, int distance) {
this.id = id;
this.floor = floor;
this.size = size;
this.distance = distance;
}
public String id() {
return id;
}
public int floor() {
return floor;
}
public SpotSize size() {
return size;
}
public int distance() {
return distance;
}
public boolean isFree() {
return occupant.get() == null;
}
public boolean fits(Vehicle vehicle) {
return size.accepts(vehicle.type().requiredSize);
}
// The only way to take a bay. True for exactly one of two racing callers.
public boolean claim(Vehicle vehicle) {
return fits(vehicle) && occupant.compareAndSet(null, vehicle);
}
public void release() {
occupant.set(null);
}
}
com.androidinterview.parkinglot.model.SpotSize.java
package com.androidinterview.parkinglot.model;
// Ordered small to large. The declaration order is the fitting rule, so a bay
// takes any vehicle needing its own size or less.
public enum SpotSize {
SMALL,
MEDIUM,
LARGE;
public boolean accepts(SpotSize required) {
return ordinal() >= required.ordinal();
}
}
com.androidinterview.parkinglot.model.Ticket.java
package com.androidinterview.parkinglot.model;
import java.time.Instant;
import java.util.UUID;
// The proof that a bay belongs to this driver. It carries the bay id so exit
// never searches the lot, and the entry time so the fee is a pure function of
// the ticket and the clock.
public record Ticket(String id, Vehicle vehicle, String spotId, Instant issuedAt) {
public static Ticket issue(Vehicle vehicle, ParkingSpot spot, Instant now) {
return new Ticket(UUID.randomUUID().toString(), vehicle, spot.id(), now);
}
}
com.androidinterview.parkinglot.model.Vehicle.java
package com.androidinterview.parkinglot.model;
// A value, so a record. The plate is the identity, and it is also how we spot
// a driver scanning in twice.
public record Vehicle(String plate, VehicleType type) {}
com.androidinterview.parkinglot.model.VehicleType.java
package com.androidinterview.parkinglot.model;
// The type carries the bay size it needs, so nothing else ever switches over
// vehicle types. Adding a bus is one line here and nothing anywhere else.
public enum VehicleType {
MOTORCYCLE(SpotSize.SMALL),
CAR(SpotSize.MEDIUM),
TRUCK(SpotSize.LARGE);
public final SpotSize requiredSize;
VehicleType(SpotSize requiredSize) {
this.requiredSize = requiredSize;
}
}
com.androidinterview.parkinglot.service.ParkingLot.java
package com.androidinterview.parkinglot.service;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import com.androidinterview.parkinglot.model.ParkingReceipt;
import com.androidinterview.parkinglot.model.ParkingSpot;
import com.androidinterview.parkinglot.model.SpotSize;
import com.androidinterview.parkinglot.model.Ticket;
import com.androidinterview.parkinglot.model.Vehicle;
import com.androidinterview.parkinglot.strategy.FeeStrategy;
import com.androidinterview.parkinglot.strategy.SpotAllocationStrategy;
// The one door into the system. Gates, kiosks and the app talk to this class
// and nothing else, which is what leaves allocation and pricing free to change.
//
// It owns the bays and the open tickets. It owns no rules at all.
public final class ParkingLot {
private final List<ParkingSpot> spots;
private final Map<String, ParkingSpot> spotsById;
private final SpotAllocationStrategy allocation;
private final FeeStrategy fees;
private final Clock clock;
private final Map<String, Ticket> openTickets = new ConcurrentHashMap<>();
private final Map<String, String> ticketIdByPlate = new ConcurrentHashMap<>();
public ParkingLot(
List<ParkingSpot> spots, SpotAllocationStrategy allocation, FeeStrategy fees, Clock clock) {
this.spots = List.copyOf(spots);
this.spotsById = this.spots.stream().collect(Collectors.toMap(ParkingSpot::id, spot -> spot));
this.allocation = allocation;
this.fees = fees;
this.clock = clock;
}
// Entry. Empty means the lot is full for this size of vehicle, which is an
// ordinary Tuesday and not an exceptional case.
//
// The plate is claimed first, and the allocation runs inside that claim.
// computeIfAbsent runs the function at most once per absent key, so two
// barriers reading the same plate at the same moment produce one bay and
// one ticket, and the second reader gets the ticket the first one issued.
// A get followed by a put would be check then act, and the loser's bay
// would be orphaned for good. A full lot returns null from the function, so
// nothing is recorded against the plate and the next attempt tries again.
public Optional<Ticket> enter(Vehicle vehicle) {
String ticketId = ticketIdByPlate.computeIfAbsent(vehicle.plate(), plate ->
allocation.allocate(spots, vehicle)
.map(spot -> {
Ticket ticket = Ticket.issue(vehicle, spot, clock.instant());
openTickets.put(ticket.id(), ticket);
return ticket.id();
})
.orElse(null));
return Optional.ofNullable(ticketId).map(openTickets::get);
}
// The lost ticket path. The plate index is what makes it possible.
public Optional<Ticket> openTicketFor(String plate) {
return Optional.ofNullable(ticketIdByPlate.get(plate)).map(openTickets::get);
}
// Exit. Removing the ticket comes first and is atomic, so of two barriers
// scanning the same ticket only one gets a value and the other is told it
// is already closed. Freeing the bay before that would let a double scan
// hand the same space to two drivers, so the bay is released last.
public ParkingReceipt exit(String ticketId) {
Ticket ticket = openTickets.remove(ticketId);
if (ticket == null) {
throw new IllegalStateException("no open ticket " + ticketId);
}
ticketIdByPlate.remove(ticket.vehicle().plate());
Duration stay = Duration.between(ticket.issuedAt(), clock.instant());
long amount = fees.feeFor(ticket.vehicle(), stay);
spotsById.get(ticket.spotId()).release();
return new ParkingReceipt(ticket.id(), stay, amount);
}
// What the sign at the entrance shows. Counted on demand, because a lot
// has a few thousand bays and a cached counter is one more thing to keep
// correct for no gain.
public Map<SpotSize, Long> availability() {
return spots.stream()
.filter(ParkingSpot::isFree)
.collect(Collectors.groupingBy(ParkingSpot::size, Collectors.counting()));
}
}
com.androidinterview.parkinglot.strategy.FeeStrategy.java
package com.androidinterview.parkinglot.strategy;
import java.time.Duration;
import java.util.Map;
import com.androidinterview.parkinglot.model.Vehicle;
import com.androidinterview.parkinglot.model.VehicleType;
// The other rule that changes, and the one an interviewer will change on you
// halfway through. A new price list is a new lambda, not an edit to the lot.
@FunctionalInterface
public interface FeeStrategy {
long feeFor(Vehicle vehicle, Duration stay);
// What a real car park does. A free grace period so a wrong turn is not
// charged, then whole hours rounded up at a rate per vehicle type.
static FeeStrategy hourly(Duration grace, Map<VehicleType, Long> ratePerHour) {
return (vehicle, stay) -> {
if (stay.compareTo(grace) <= 0) {
return 0L;
}
// Integer ceiling division keeps floating point away from money.
// toMinutes drops the seconds first, so two hours and one second
// bills two hours. That rounds down in the customer's favour, on
// purpose.
long hours = (Math.max(1, stay.toMinutes()) + 59) / 60;
return hours * ratePerHour.get(vehicle.type());
};
}
static FeeStrategy standard() {
return hourly(
Duration.ofMinutes(15),
Map.of(
VehicleType.MOTORCYCLE, 2000L,
VehicleType.CAR, 4000L,
VehicleType.TRUCK, 8000L));
}
}
com.androidinterview.parkinglot.strategy.SpotAllocationStrategy.java
package com.androidinterview.parkinglot.strategy;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import com.androidinterview.parkinglot.model.ParkingSpot;
import com.androidinterview.parkinglot.model.Vehicle;
// Where a car goes is the rule most likely to change, so it lives behind an
// interface with more than one real implementation.
//
// The strategy claims the bay as well as choosing it. If it only returned a
// suggestion, every caller would have to check the bay was still free and then
// take it, and that gap is the race.
public interface SpotAllocationStrategy {
Optional<ParkingSpot> allocate(List<ParkingSpot> spots, Vehicle vehicle);
// The default. Shortest walk for the driver.
static SpotAllocationStrategy nearestFirst() {
return ordered(Comparator.comparingInt(ParkingSpot::distance));
}
// Smallest bay that holds the car, so a motorcycle never eats the last
// truck bay. Same seam, different sort.
static SpotAllocationStrategy bestFit() {
return ordered(Comparator.comparing(ParkingSpot::size).thenComparingInt(ParkingSpot::distance));
}
// Sort the free bays, then walk them claiming until one claim wins. A bay
// taken between the sort and the claim simply fails and we move on.
// Sorting a few thousand bays per car is microseconds. If it ever mattered
// you would keep a free list per size, and the claim would still decide.
private static SpotAllocationStrategy ordered(Comparator<ParkingSpot> order) {
return (spots, vehicle) -> spots.stream()
.filter(spot -> spot.isFree() && spot.fits(vehicle))
.sorted(order)
.filter(spot -> spot.claim(vehicle))
.findFirst();
}
}
Kotlin
com.androidinterview.parkinglot.model.ParkingSpot.kt
package com.androidinterview.parkinglot.model
import java.util.concurrent.atomic.AtomicReference
// One bay. It owns exactly one fact, whether it is occupied and by whom.
//
// The occupant is an AtomicReference because two cars can reach the last bay
// at the same moment. Claiming is a compare and set from empty, so one call
// wins and the other looks elsewhere. A real system would hold this in a
// database row and claim it with a conditional update instead.
class ParkingSpot(
val id: String,
val floor: Int,
val size: SpotSize,
// Walking distance from the entrance in metres, used by nearest first.
val distance: Int,
) {
private val occupant = AtomicReference<Vehicle?>(null)
val isFree: Boolean get() = occupant.get() == null
fun fits(vehicle: Vehicle): Boolean = size >= vehicle.type.requiredSize
// The only way to take a bay. True for exactly one of two racing callers.
fun claim(vehicle: Vehicle): Boolean = fits(vehicle) && occupant.compareAndSet(null, vehicle)
fun release() = occupant.set(null)
}
com.androidinterview.parkinglot.model.Ticket.kt
package com.androidinterview.parkinglot.model
import java.time.Duration
import java.time.Instant
import java.util.UUID
// The proof that a bay belongs to this driver. It carries the bay id so exit
// never searches the lot, and the entry time so the fee is a pure function of
// the ticket and the clock.
data class Ticket(val id: String, val vehicle: Vehicle, val spotId: String, val issuedAt: Instant) {
companion object {
fun issue(vehicle: Vehicle, spot: ParkingSpot, now: Instant) =
Ticket(UUID.randomUUID().toString(), vehicle, spot.id, now)
}
}
// Money is a Long of minor units, never a Double, because floating point money
// is a rounding bug that has not happened yet.
data class ParkingReceipt(val ticketId: String, val stay: Duration, val amountMinor: Long)
com.androidinterview.parkinglot.model.Vehicle.kt
package com.androidinterview.parkinglot.model
// Enums compare by declaration order, and that ordering is the whole fitting
// rule. A bay takes any vehicle needing its own size or less.
enum class SpotSize { SMALL, MEDIUM, LARGE }
// The type carries the bay size it needs, so nothing else ever switches over
// vehicle types. Adding a bus is one line here and nothing anywhere else.
enum class VehicleType(val requiredSize: SpotSize) {
MOTORCYCLE(SpotSize.SMALL),
CAR(SpotSize.MEDIUM),
TRUCK(SpotSize.LARGE),
}
// A value, so a data class. equals and hashCode come free, which is why the
// plate can be a map key without writing anything.
data class Vehicle(val plate: String, val type: VehicleType)
com.androidinterview.parkinglot.service.ParkingLot.kt
package com.androidinterview.parkinglot.service
import java.time.Clock
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import com.androidinterview.parkinglot.model.ParkingReceipt
import com.androidinterview.parkinglot.model.ParkingSpot
import com.androidinterview.parkinglot.model.SpotSize
import com.androidinterview.parkinglot.model.Ticket
import com.androidinterview.parkinglot.model.Vehicle
import com.androidinterview.parkinglot.strategy.FeeStrategy
import com.androidinterview.parkinglot.strategy.SpotAllocationStrategy
// The one door into the system. Gates, kiosks and the app talk to this and to
// nothing else, which is what leaves allocation and pricing free to change.
// It owns the bays and the open tickets. It owns no rules.
//
// The clock has no default on purpose. Whoever wires the lot up chooses it,
// and a test hands in a fixed one.
class ParkingLot(
private val spots: List<ParkingSpot>,
private val allocation: SpotAllocationStrategy,
private val fees: FeeStrategy,
private val clock: Clock,
) {
private val spotsById = spots.associateBy { it.id }
private val openTickets = ConcurrentHashMap<String, Ticket>()
// The value type is nullable only so the computeIfAbsent lambda in enter
// can answer no bay. The map never holds a null.
private val ticketIdByPlate = ConcurrentHashMap<String, String?>()
// Entry. Null means full for this size of vehicle, an ordinary Tuesday.
//
// The plate is claimed first and the allocation runs inside that claim.
// computeIfAbsent runs the lambda at most once per absent key, so two
// barriers reading one plate at the same moment produce one bay and one
// ticket, and the second reader gets the ticket the first one issued. A
// get then a put would be check then act, and the loser's bay would be
// orphaned for good. A full lot returns null from the lambda, so nothing is
// recorded against the plate and the next attempt tries again.
fun enter(vehicle: Vehicle): Ticket? {
val ticketId = ticketIdByPlate.computeIfAbsent(vehicle.plate) {
allocation.allocate(spots, vehicle)?.let { spot ->
Ticket.issue(vehicle, spot, clock.instant()).also { openTickets[it.id] = it }.id
}
}
return ticketId?.let { openTickets[it] }
}
// The lost ticket path. The plate index is what makes it possible.
fun openTicketFor(plate: String): Ticket? = ticketIdByPlate[plate]?.let { openTickets[it] }
// Exit. The remove comes first and is atomic, so of two barriers scanning
// the same ticket only one gets a value. Freeing the bay first would let a
// double scan hand the same space to two drivers, so the bay is released
// last.
fun exit(ticketId: String): ParkingReceipt {
val ticket = checkNotNull(openTickets.remove(ticketId)) { "no open ticket $ticketId" }
ticketIdByPlate.remove(ticket.vehicle.plate)
val stay = Duration.between(ticket.issuedAt, clock.instant())
val amount = fees.feeFor(ticket.vehicle, stay)
spotsById.getValue(ticket.spotId).release()
return ParkingReceipt(ticket.id, stay, amount)
}
// What the sign shows. Counted on demand, because a cached counter is one
// more thing to keep correct for no gain.
fun availability(): Map<SpotSize, Int> =
spots.filter { it.isFree }.groupingBy { it.size }.eachCount()
}
com.androidinterview.parkinglot.strategy.Allocation.kt
package com.androidinterview.parkinglot.strategy
import com.androidinterview.parkinglot.model.ParkingSpot
import com.androidinterview.parkinglot.model.Vehicle
// One method, so a fun interface, and every policy below is a lambda instead
// of a class. Null means full for this vehicle, which is a real answer in
// Kotlin and needs no Optional.
//
// The strategy claims the bay as well as choosing it. Returning a suggestion
// would leave every caller to check then take, and that gap is the race.
fun interface SpotAllocationStrategy {
fun allocate(spots: List<ParkingSpot>, vehicle: Vehicle): ParkingSpot?
}
// Sort the free bays, then take the first one we actually win. A bay claimed
// by someone else between the sort and the claim just fails compareAndSet.
// Sorting a few thousand bays per car is microseconds. If it ever mattered you
// would keep a free list per size, and the claim would still decide.
private fun ordered(order: Comparator<ParkingSpot>) = SpotAllocationStrategy { spots, vehicle ->
spots.filter { it.isFree && it.fits(vehicle) }
.sortedWith(order)
.firstOrNull { it.claim(vehicle) }
}
// The default, shortest walk for the driver.
val nearestFirst = ordered(compareBy { it.distance })
// Smallest bay that holds the car, so a motorcycle never eats the last truck
// bay. Same seam, different sort.
val bestFit = ordered(compareBy({ it.size }, { it.distance }))
com.androidinterview.parkinglot.strategy.Fees.kt
package com.androidinterview.parkinglot.strategy
import java.time.Duration
import com.androidinterview.parkinglot.model.Vehicle
import com.androidinterview.parkinglot.model.VehicleType
// The rule an interviewer will change on you halfway through. Behind a fun
// interface, a new price list is a new lambda and the lot is untouched.
fun interface FeeStrategy {
fun feeFor(vehicle: Vehicle, stay: Duration): Long
}
// What a real car park does. A free grace period so a wrong turn is not
// charged, then whole hours rounded up at a rate per vehicle type.
fun hourly(
grace: Duration = Duration.ofMinutes(15),
rates: Map<VehicleType, Long> = mapOf(
VehicleType.MOTORCYCLE to 2_000L,
VehicleType.CAR to 4_000L,
VehicleType.TRUCK to 8_000L,
),
) = FeeStrategy { vehicle, stay ->
if (stay <= grace) {
0L
} else {
// Integer ceiling division keeps floating point away from money.
// toMinutes drops the seconds first, so two hours and one second bills
// two hours. That rounds down in the customer's favour, on purpose.
val hours = (stay.toMinutes().coerceAtLeast(1) + 59) / 60
hours * rates.getValue(vehicle.type)
}
}
Concurrency and edge cases
Two cars racing for the last space. This is the question the problem exists for, so describe the race before showing any code. Two barriers run enter at the same moment. Both scan the free bays, both see bay B2 free, both are told to take it. If taking a bay is a plain field write, the second write silently overwrites the first and two drivers are sent to the same space.
The fix is that a bay is never assigned, only claimed. The occupant is an atomic reference and claiming is a compare and set from empty. Exactly one of the two calls returns true. The loser does not fail, it simply moves to the next bay on its shortlist, and only reports the lot full when the whole shortlist is exhausted.
Say why this beats the obvious alternative. You could put a lock around the whole lot and be correct, but then every entry serialises against every other entry across every floor, and a busy Saturday queues at the barrier. The atomic claim contends only on the one bay two cars actually wanted.
The count on the sign is always a little out of date. It is read at one moment and acted on at another, which is fine as long as nothing trusts it. The sign is advisory and the claim is the decision, so a driver can read two free and still be sent to the next floor.
A double scan at entry. The plate index is claimed before any bay is, and the allocation runs inside that claim, so two barriers reading one plate at the same moment run it once. The second reader gets the ticket the first one issued, and a driver who taps twice never holds two bays. Checking the plate and then writing it as two steps would be the same check then act race as the bays, and the loser's bay would be orphaned for good.
A double scan at exit. Removing the ticket first and only then freeing the bay means two barriers cannot both process the same exit. The map removal is atomic, one caller gets the ticket, the other is told it is already closed.
A lost ticket. The plate index is what saves you. Look up the open ticket by plate, which is openTicketFor on the lot, charge a lost ticket fee, and the design needs no new class.
A fee quoted at a kiosk and paid ten minutes later is wrong by then. Either charge at the gate from the same strategy, or give a paid ticket a short grace window before the meter restarts. Say which you chose.
A car that never leaves. Tickets stay open forever and the bay is dead inventory. Real lots run a sweep over open tickets older than a day. Worth one sentence, because it shows you thought past the happy path.
A power cut. Every bay state is in memory. Say that the ticket store would be a database in production and that the bay occupancy can be rebuilt from open tickets on startup, which is the honest answer to what recovery looks like.
Watch