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
Video chapters
- 0:00 One spot. Two arrivals.
- 0:46 Only the objects we need
- 1:17 Keep the changing rules separate
- 2:07 Walk through one entry
- 2:23 The gap between checking and claiming
- 3:30 A second race hides in the plate lookup
- 4:04 Pricing should be easy to test
- 4:37 Close once, then release
- 4:54 A timeout leaves the payment unknown
- 5:27 An atomic reference does not survive a restart
- 5:59 Tests should challenge the guarantee
- 6:32 An answer you can say in the interview
Video transcript
Two cars reach two gates at the same moment. There is one parking spot left. Both gates see it as free. How do you stop both drivers getting the same space? That is the most useful place to start a parking lot design interview.
Our guarantee is simple. A spot has at most one active vehicle. We will build the classes around that rule, follow one entry and exit, then handle the questions an interviewer is likely to ask. The code is Kotlin, but the design works just as well in Java.
Start with the physical layout: vehicle sizes, floors, and entry gates. Then agree on pricing. In this version, drivers arrive without reservations. On entry, we issue a ticket and update availability. On exit, we calculate the fee and release the spot. Payment processing stays a separate extension. That boundary keeps the first design manageable.
Start with Vehicle, ParkingSpot, and Ticket. A vehicle has a plate and a required size. A spot has an identifier, a floor, a size, and an occupant. A ticket connects the vehicle to its spot and records when it entered. These are different facts, so they deserve different objects.
Notice the spot identifier on the ticket. At exit, we can find the spot directly instead of searching every floor. The entry time lets us calculate the stay. A data class works well here because the ticket describes a value. It does not decide where a vehicle should park.
ParkingLot coordinates the operation. It receives an allocation strategy, a fee strategy, and a clock through its constructor. Allocation decides which suitable spot to claim. Pricing decides what a stay costs. The clock makes time controllable in tests. The lot keeps the records and puts those decisions in order.
Here is the allocation interface. It takes the spots and the vehicle, then returns a claimed spot or null. The important word is claimed. Returning a suggestion would leave a dangerous gap between choosing the spot and taking it. The interface makes the safe operation the easy one to call.
Nearest first sorts suitable spots by walking distance. Best fit sorts by size first, then distance. That keeps a motorcycle from taking the last space a truck needs. We can replace one policy with the other without rewriting ticket creation. This is where the Strategy pattern earns its place.
A car arrives. The lot checks whether this plate already has an open ticket. If it does, it returns that ticket. Otherwise, allocation finds and claims a suitable spot. The lot creates the ticket, saves it, and returns it to the gate. If no spot can be claimed, the result is full.
The problem appears when two gates run this code together. There is a check, followed by an assignment. Both threads can read free before either writes occupied. The map containing our spots might be thread safe, but these two operations are still separate. We need to protect the decision inside the spot itself.
Each spot owns an atomic reference to its occupant. Compare and set changes it from empty to this vehicle only if it is still empty. Exactly one competing claim succeeds. Checking compatibility happens first. The atomic operation protects ownership, which is the fact that must never be assigned to two vehicles.
Gate A wins the claim. Gate B does not overwrite it. Instead, allocation tries the next candidate. If every suitable candidate has been taken, it returns full. A free space count is only a hint for the display board. The successful claim is what gives a driver the space.
A lock around the whole lot can also be correct. I would not dismiss it. It may be a good first implementation for low traffic. A claim per spot allows unrelated arrivals to proceed independently, but coordinating tickets and recovery becomes more involved. Explain that tradeoff instead of treating an atomic class as magic.
What if the same plate is scanned twice? Two requests could claim two different spaces for one car. The plate lookup and ticket creation must also act as one operation. In the example, the concurrent map computes the missing ticket once for that plate. A full result leaves no entry behind.
This excerpt shows the order. Inside compute if absent, allocate the spot, issue a ticket, then save that ticket before publishing its identifier in the plate index. A second scan reuses the result. In production, persistent records need the same guarantee through a transaction or an equivalent conditional operation.
The fee strategy receives the vehicle and the duration. It returns money in minor units, such as paise or cents, using an integer. Ask how grace periods and rounding work. At forty rupees per started hour, a stay of ninety minutes costs eighty rupees. That rule belongs in pricing, not in the gate.
The injected clock lets a test create a ticket, advance ninety minutes, and check the fee immediately. There is no reason for that test to sleep. For a real stay, persisted entry timestamps must survive a restart, and the backend needs a clear policy for clock changes and negative durations.
At exit, find the open ticket and calculate the fee. Complete the agreed exit operation, close the ticket, and release the spot. Two scans must not release the same ticket twice. The small example uses an atomic removal. A production store must keep the ticket and occupancy consistent even if a later step fails.
Payment adds a different kind of uncertainty. The request times out, so the gate has no response. But that does not prove the charge failed. Starting a new payment could charge the driver twice. Our parking records alone cannot tell us whether money moved, so we need to track the original attempt.
If payment is in scope, I would save a payment attempt identifier and check that same attempt after a timeout. Keep the ticket pending until the server confirms the outcome. Repeating the operation must not create another charge. That is idempotency, recognising a repeated request as the same operation.
Another common question is a power cut. Our in memory objects are only an interview implementation. A real system saves tickets and occupancy in a database. Claiming a spot uses a conditional update, and saving the ticket belongs in the same transaction. Startup rebuilds its view from those saved records.
For a lost ticket, the plate index finds the open visit. Identity checks and any lost ticket fee are product rules to clarify. For a display board, read availability without treating the number as a reservation. Both features use the same records. We do not need a new pattern for every noun.
I would test two cars racing for one spot, a repeated plate scan, a repeated exit, and a vehicle too large for the remaining spaces. I would also test exact pricing boundaries and a failed persistent transaction. Each test should protect a rule we promised at the beginning.
Why not a singleton? Passing the lot as a dependency lets a test create several independent lots. Why not a State class for every spot? Free and occupied can be represented by the occupant here. More complex reservation or maintenance rules may justify richer states later. Start with the behaviour you actually need.
My design has a small coordinator, separate allocation and pricing rules, and a spot that can only be claimed once. A ticket connects the vehicle, spot, and entry time. Repeated scans reuse or close one visit. For production, I move the important state changes into persistent transactions. That is the core answer.
The main idea is that checking free and then assigning occupied leaves a gap where another arrival can interfere. Choosing and claiming belong in one operation. Once that connection is clear, the classes and the Kotlin code have a reason to exist. The full walkthrough and source are on Android Interview, linked with this video.
Design a parking lot that assigns a free space when a vehicle enters and charges a fee when it leaves.
The problem
A car enters at minute 600 and leaves at minute 690. At 40 rupees per started hour, the fee is 80 rupees. Leaving closes its ticket and makes its space available again.
Start with one lot, three space sizes and one hourly rate. A vehicle can use a space of its own size or larger. Select the first suitable space. There is no payment gateway, reservation system or grace period in this version.
How to explain the design
“I keep the parking spaces and active tickets. Each ticket records the vehicle, its space and entry time. To park a vehicle, I find a suitable space that has no active ticket. At exit, I calculate the fee and remove the ticket, which makes that space free.”
Spot describes a space. Ticket records a stay. ParkingLot handles entry and exit. Occupancy comes from active tickets, so there is no separate occupied flag to keep in sync.
Walk through a visit
- Check whether the plate already has a ticket. Return it on a repeated entry scan.
- Find a free space large enough for the vehicle. Return no ticket if the lot is full.
- Store a ticket with the space and entry time.
- At exit, round the stay up to whole hours and calculate the fee.
- Remove the ticket. A second exit using it is rejected.
Interview implementation
Entry and exit use the same lock. For this small version, a simple scan and one lock are enough to prevent two cars getting the same space.
Java
ParkingLot.java
package interview.parking;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class ParkingLot {
public enum Size { SMALL, MEDIUM, LARGE }
public record Spot(String id, Size size) {}
public record Ticket(int id, String plate, String spotId, long enteredAt) {}
private final List<Spot> spots;
private final long ratePerHour;
private final Map<Integer, Ticket> tickets = new HashMap<>();
private int nextId = 1;
public ParkingLot(List<Spot> spots, long ratePerHour) {
if (ratePerHour < 0 || spots.stream().map(Spot::id).distinct().count() != spots.size()) {
throw new IllegalArgumentException("Invalid rate or duplicate spot");
}
this.spots = List.copyOf(spots);
this.ratePerHour = ratePerHour;
}
public synchronized Ticket enter(String plate, Size size, long nowMinutes) {
Set<String> occupied = new HashSet<>();
for (Ticket ticket : tickets.values()) {
if (ticket.plate().equals(plate)) return ticket;
occupied.add(ticket.spotId());
}
for (Spot spot : spots) {
if (spot.size().ordinal() >= size.ordinal() && !occupied.contains(spot.id())) {
Ticket ticket = new Ticket(nextId++, plate, spot.id(), nowMinutes);
tickets.put(ticket.id(), ticket);
return ticket;
}
}
return null;
}
public synchronized long exit(int ticketId, long nowMinutes) {
Ticket ticket = tickets.get(ticketId);
if (ticket == null) throw new IllegalStateException("Ticket already closed or unknown");
if (nowMinutes < ticket.enteredAt()) throw new IllegalArgumentException("Invalid exit time");
long hours = (nowMinutes - ticket.enteredAt() + 59) / 60;
long fee = hours * ratePerHour;
tickets.remove(ticketId); // The spot is now free.
return fee;
}
}
package interview.parking;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class ParkingLot {
public enum Size { SMALL, MEDIUM, LARGE }
public record Spot(String id, Size size) {}
public record Ticket(int id, String plate, String spotId, long enteredAt) {}
private final List<Spot> spots;
private final long ratePerHour;
private final Map<Integer, Ticket> tickets = new HashMap<>();
private int nextId = 1;
public ParkingLot(List<Spot> spots, long ratePerHour) {
if (ratePerHour < 0 || spots.stream().map(Spot::id).distinct().count() != spots.size()) {
throw new IllegalArgumentException("Invalid rate or duplicate spot");
}
this.spots = List.copyOf(spots);
this.ratePerHour = ratePerHour;
}
public synchronized Ticket enter(String plate, Size size, long nowMinutes) {
Set<String> occupied = new HashSet<>();
for (Ticket ticket : tickets.values()) {
if (ticket.plate().equals(plate)) return ticket;
occupied.add(ticket.spotId());
}
for (Spot spot : spots) {
if (spot.size().ordinal() >= size.ordinal() && !occupied.contains(spot.id())) {
Ticket ticket = new Ticket(nextId++, plate, spot.id(), nowMinutes);
tickets.put(ticket.id(), ticket);
return ticket;
}
}
return null;
}
public synchronized long exit(int ticketId, long nowMinutes) {
Ticket ticket = tickets.get(ticketId);
if (ticket == null) throw new IllegalStateException("Ticket already closed or unknown");
if (nowMinutes < ticket.enteredAt()) throw new IllegalArgumentException("Invalid exit time");
long hours = (nowMinutes - ticket.enteredAt() + 59) / 60;
long fee = hours * ratePerHour;
tickets.remove(ticketId);
return fee;
}
}
Kotlin
ParkingLot.kt
package interview.parking
enum class Size { SMALL, MEDIUM, LARGE }
data class Spot(val id: String, val size: Size)
data class Ticket(val id: Int, val plate: String, val spotId: String, val enteredAt: Long)
class ParkingLot(spots: List<Spot>, private val ratePerHour: Long) {
private val spots = spots.toList()
private val tickets = mutableMapOf<Int, Ticket>()
private var nextId = 1
init {
require(ratePerHour >= 0 && spots.map { it.id }.distinct().size == spots.size)
}
@Synchronized
fun enter(plate: String, size: Size, nowMinutes: Long): Ticket? {
tickets.values.find { it.plate == plate }?.let { return it }
val occupied = tickets.values.map { it.spotId }.toSet()
val spot = spots.find { it.size >= size && it.id !in occupied } ?: return null
val ticket = Ticket(nextId++, plate, spot.id, nowMinutes)
tickets[ticket.id] = ticket
return ticket
}
@Synchronized
fun exit(ticketId: Int, nowMinutes: Long): Long {
val ticket = tickets[ticketId] ?: error("Ticket already closed or unknown")
require(nowMinutes >= ticket.enteredAt)
val hours = (nowMinutes - ticket.enteredAt + 59) / 60
val fee = hours * ratePerHour
tickets.remove(ticketId) // The spot is now free.
return fee
}
}
package interview.parking
enum class Size { SMALL, MEDIUM, LARGE }
data class Spot(val id: String, val size: Size)
data class Ticket(val id: Int, val plate: String, val spotId: String, val enteredAt: Long)
class ParkingLot(spots: List<Spot>, private val ratePerHour: Long) {
private val spots = spots.toList()
private val tickets = mutableMapOf<Int, Ticket>()
private var nextId = 1
init {
require(ratePerHour >= 0 && spots.map { it.id }.distinct().size == spots.size)
}
@Synchronized
fun enter(plate: String, size: Size, nowMinutes: Long): Ticket? {
tickets.values.find { it.plate == plate }?.let { return it }
val occupied = tickets.values.map { it.spotId }.toSet()
val spot = spots.find { it.size >= size && it.id !in occupied } ?: return null
val ticket = Ticket(nextId++, plate, spot.id, nowMinutes)
tickets[ticket.id] = ticket
return ticket
}
@Synchronized
fun exit(ticketId: Int, nowMinutes: Long): Long {
val ticket = tickets[ticketId] ?: error("Ticket already closed or unknown")
require(nowMinutes >= ticket.enteredAt)
val hours = (nowMinutes - ticket.enteredAt + 59) / 60
val fee = hours * ratePerHour
tickets.remove(ticketId)
return fee
}
}
Follow-up questions
Different prices?
“I would move fee calculation into a function, then choose the right rate for the ticket.” The parking logic still allocates and frees spots. A weekday rate or vehicle rate should only change pricing.
This helper keeps the current rule of charging for each started hour. At 20 per hour, 61 minutes costs 40. Choose the rate from the ticket's pricing policy when calling it.
Kotlin
fun fee(minutes: Long, ratePerHour: Long): Long {
require(minutes >= 0 && ratePerHour >= 0)
val hours = minutes / 60 + if (minutes % 60 == 0L) 0 else 1
return hours * ratePerHour
}Java
static long fee(long minutes, long ratePerHour) {
if (minutes < 0 || ratePerHour < 0) throw new IllegalArgumentException();
long hours = minutes / 60 + (minutes % 60 == 0 ? 0 : 1);
return hours * ratePerHour;
}A larger lot?
“I would keep free spots grouped by size, so entry does not scan every ticket and spot.” Take one spot from the smallest suitable group and put it back on exit. Also index active tickets by plate for quick repeated-entry checks. Update these indexes and the ticket map under the same lock so they cannot disagree.
Several servers or a restart?
“I would store spots and active tickets in a database.” Claim the free spot and create the ticket in one transaction. Enforce one active ticket per spot and per plate, so two servers cannot allocate the same space. Persistent tickets also let the system find a parked car after restarting.
What should I test?
“A full lot or a vehicle that fits no spot should get no ticket.” Repeating entry for the same plate should return its active ticket. Exiting should calculate the expected fee and make that spot available again. Exiting the same ticket twice should fail. Test 0, 60 and 61 minutes to check the rounding rule.
Extended implementation and optional features
This reference explores a larger scope. Use it after you can explain and write the interview version. Its extra types and features are not required for the scope above.
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) {}
package com.androidinterview.parkinglot.model;
import java.time.Duration;
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);
}
}
package com.androidinterview.parkinglot.model;
import java.util.concurrent.atomic.AtomicReference;
public final class ParkingSpot {
private final String id;
private final int floor;
private final SpotSize size;
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);
}
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();
}
}
package com.androidinterview.parkinglot.model;
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);
}
}
package com.androidinterview.parkinglot.model;
import java.time.Instant;
import java.util.UUID;
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) {}
package com.androidinterview.parkinglot.model;
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;
}
}
package com.androidinterview.parkinglot.model;
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()));
}
}
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;
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;
}
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);
}
public Optional<Ticket> openTicketFor(String plate) {
return Optional.ofNullable(ticketIdByPlate.get(plate)).map(openTickets::get);
}
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);
}
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));
}
}
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;
@FunctionalInterface
public interface FeeStrategy {
long feeFor(Vehicle vehicle, Duration stay);
static FeeStrategy hourly(Duration grace, Map<VehicleType, Long> ratePerHour) {
return (vehicle, stay) -> {
if (stay.compareTo(grace) <= 0) {
return 0L;
}
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();
}
}
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;
public interface SpotAllocationStrategy {
Optional<ParkingSpot> allocate(List<ParkingSpot> spots, Vehicle vehicle);
static SpotAllocationStrategy nearestFirst() {
return ordered(Comparator.comparingInt(ParkingSpot::distance));
}
static SpotAllocationStrategy bestFit() {
return ordered(Comparator.comparing(ParkingSpot::size).thenComparingInt(ParkingSpot::distance));
}
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)
}
package com.androidinterview.parkinglot.model
import java.util.concurrent.atomic.AtomicReference
class ParkingSpot(
val id: String,
val floor: Int,
val size: SpotSize,
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
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)
package com.androidinterview.parkinglot.model
import java.time.Duration
import java.time.Instant
import java.util.UUID
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)
}
}
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)
package com.androidinterview.parkinglot.model
enum class SpotSize { SMALL, MEDIUM, LARGE }
enum class VehicleType(val requiredSize: SpotSize) {
MOTORCYCLE(SpotSize.SMALL),
CAR(SpotSize.MEDIUM),
TRUCK(SpotSize.LARGE),
}
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()
}
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
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>()
private val ticketIdByPlate = ConcurrentHashMap<String, String?>()
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] }
}
fun openTicketFor(plate: String): Ticket? = ticketIdByPlate[plate]?.let { openTickets[it] }
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)
}
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 }))
package com.androidinterview.parkinglot.strategy
import com.androidinterview.parkinglot.model.ParkingSpot
import com.androidinterview.parkinglot.model.Vehicle
fun interface SpotAllocationStrategy {
fun allocate(spots: List<ParkingSpot>, vehicle: Vehicle): ParkingSpot?
}
private fun ordered(order: Comparator<ParkingSpot>) = SpotAllocationStrategy { spots, vehicle ->
spots.filter { it.isFree && it.fits(vehicle) }
.sortedWith(order)
.firstOrNull { it.claim(vehicle) }
}
val nearestFirst = ordered(compareBy { it.distance })
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)
}
}
package com.androidinterview.parkinglot.strategy
import java.time.Duration
import com.androidinterview.parkinglot.model.Vehicle
import com.androidinterview.parkinglot.model.VehicleType
fun interface FeeStrategy {
fun feeFor(vehicle: Vehicle, stay: Duration): Long
}
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 {
val hours = (stay.toMinutes().coerceAtLeast(1) + 59) / 60
hours * rates.getValue(vehicle.type)
}
}
Watch