Design a parking lot
Fifty minutes into the round, your code works. A car comes in, gets a spot, and pays on the way out. Then the interviewer says: "Weekends are a flat ₹150 a day now. Show me what changes."
If the honest answer is "most of it", the round is lost, and it was lost in minute ten, when you chose your classes. The parking lot is the most common first LLD question because it is small enough to finish and still has three decisions that separate a working answer from a good one: which spot a car gets, what happens when two gates ask at once, and what the fee method is allowed to know.
A cloakroom does not care what your coat is made of. It cares whether it fits on a hook or needs the big rail, which hook is free, and how long you left it. The token is the only thing that connects the coat going in with the coat coming out. A parking lot is the same machine with bigger coats.
What this round expects#
LLD rounds come in two shapes, and how much code you write depends on which one you are in. Ask at the start if nobody tells you.
| Design round on a whiteboard | Machine coding round | |
|---|---|---|
| Time | Usually 45 to 60 minutes | Usually 90 minutes to 2 hours |
| What you produce | The class diagram, the key interfaces, one flow walked through, the one hard method in code or pseudocode | Code that compiles and runs, with a small demo at the bottom. In memory storage, no UI |
| What gets scored | Where you drew the lines between classes, and whether you can defend each one | The same, plus whether it runs, whether it reads well, and how fast a follow up goes in |
| On this page | Steps 1 and 2, then Change it | All of it. The code in Step 3 is the whole program, about 130 lines |
Either way, the order of work is the same, and it is the order of this page: agree the requirements, find the objects, draw how they connect, then write code from the top down.
Step 1: Understand the problem#
Five minutes here saves twenty later. Each question below settles one line of the design, and the last two are cuts.
| You ask | They say | What it settles |
|---|---|---|
| Which vehicles, and which spot sizes? | Bikes, cars and trucks. Small, medium and large spots. | Two unions, VehicleType and SpotSize, and one rule for what fits where: a vehicle fits its own size or anything bigger. A bike can take any spot, a truck only a large one. |
| One floor or several? How many entry gates? | Three floors. Two entry gates and one exit. | Floor becomes a class. Two gates means two requests for a spot at the same moment, so claiming a spot has to be a single step. That is deep dive 2. |
| How is the fee worked out? | Per hour by vehicle type: ₹20 for a bike, ₹40 for a car, ₹100 for a truck. Part of an hour counts as an hour, and one hour is the minimum. | A PricingPolicy interface rather than an if inside exit(). Pricing is the rule the business changes most often, and the interviewer is going to change it. |
| Which spot does a driver get? | Your call. | A SpotFinder interface. "Your call" is an invitation: name two rules, pick one, and say what each costs. That is deep dive 1. |
| Do we take the payment? | No. The exit gate takes it. Tell it the amount. | A cut. exit() returns a receipt with the amount, and there are no payment classes. Saying so out loud is worth more than drawing a PaymentProcessor nobody asked for. |
| Does anything have to survive a restart? | Not for this round. | A cut. Maps in memory. Name the interface you would put in front of a database later, and move on. |
- Park a vehicle in a spot that fits it, and give the driver a ticket. The entry flow. One call, enter().
- Take the ticket back, work out the fee, free the spot. The exit flow. One call, exit().
- Choose the spot by a rule that can be swapped. The first thing the interviewer will poke at.
- Charge by a rule that can be swapped. The second thing.
- A new pricing rule or spot rule is a new class, not an edit to ParkingLot.
- Two gates can never be handed the same spot.
- Every rule can be tested without waiting for a real clock: the time is passed in.
- Payments, refunds and lost tickets.
- Booking a spot in advance. It is a different question, closer to movie ticket booking.
- A database. Everything lives in memory for the round.
- Display boards. Not in the first version, and they come back as a follow up in Change it.
Step 2: Find the objects and how they connect#
Read the problem back, and mark it up#
Say the problem back to the interviewer as one paragraph, then go through it for nouns and verbs. Nouns become classes or fields. Verbs become methods. Some words turn out to be scenery. Tap each phrase below, and guess before you do.
A has several . Each floor has in three . come in through an , where the driver gets a and is . At the exit, the driver based on the and , and the again. Later, the owners want .
The class diagram#
Figure 2 is what you would draw on the board, and it is the most important artefact of the round. Draw it before writing code, then talk through it box by box. If the round is whiteboard only, this figure and the interfaces under it are most of what you produce.
UML has six kinds of line, and an interviewer expects you to use the right one. This page uses five. Here is how to read them.
Three things in it are deliberate, and each is a question you should expect.
Composition for floors and spots, aggregation for the rules. A floor does not exist without its lot, and a spot does not exist without its floor, so those are filled diamonds. The finder and the pricing policy are handed to the lot from outside, and the same BestFit could serve two lots, so those are hollow.
Ticket is a box, but not a class in the code. It is data with no behaviour. The diagram shows what the system knows about. The code decides how to write it down, and in TypeScript plain data is an interface.
No Bike, Car or Truck boxes. There is one Vehicle with a type. Subclasses would only be worth it if a truck behaved differently from a car, and in this problem the only difference is size, which is data.
Step 3: Write it, from the top down#
Write the code in the order of the diagram: the vocabulary first, then the objects that hold state, then the rules, then the lot that ties them together. Every block below runs, and each one builds on the one before it, so together they are the whole program.
The vocabulary#
type VehicleType = 'bike' | 'car' | 'truck'
type SpotSize = 'small' | 'medium' | 'large'
interface Vehicle {
readonly plate: string
readonly type: VehicleType
}
// Sizes in order, so "fits" is a comparison.
const RANK: Record<SpotSize, number> = { small: 0, medium: 1, large: 2 }
// The smallest spot each vehicle fits in.
const NEEDS: Record<VehicleType, SpotSize> = {
bike: 'small',
car: 'medium',
truck: 'large',
}
const car: Vehicle = { plate: 'KA01AB1234', type: 'car' }
const van: Vehicle = { plate: 'X', type: 'van' }
// Error: Type '"van"' is not assignable to type 'VehicleType'Two Record tables carry the whole "what fits where" rule. That buys more than it looks.
When the interviewer adds electric scooters, you add 'scooter' to the union, and
NEEDS stops compiling until it has a row for scooters. The compiler hands you the list
of places to update.
The objects that hold state#
class ParkingSpot {
private vehicle: Vehicle | null = null
constructor(
readonly id: string,
readonly size: SpotSize,
readonly distance: number,
) {}
get isFree(): boolean {
return this.vehicle === null
}
fits(v: Vehicle): boolean {
return RANK[this.size] >= RANK[NEEDS[v.type]]
}
// Check and claim in one step, with nothing that can run in between.
tryPark(v: Vehicle): boolean {
if (!this.isFree || !this.fits(v)) return false
this.vehicle = v
return true
}
release(): void {
this.vehicle = null
}
}
class Floor {
constructor(
readonly level: number,
readonly spots: readonly ParkingSpot[],
) {}
freeCount(size: SpotSize): number {
return this.spots.filter((s) => s.isFree && s.size === size).length
}
}
const spot = new ParkingSpot('M1', 'medium', 12)
console.log(spot.tryPark(car)) // true
console.log(spot.tryPark(car)) // false
spot.vehicle = null // Error: Property 'vehicle' is privateThe spot is the only thing that can change whether it is taken. There is no park()
that trusts the caller to have checked isFree first, because a caller that forgets is
how two cars end up in one spot. tryPark checks and claims together and tells you
whether it worked.
The contracts#
If the round is a whiteboard round, this is the code you write in full. Everything else can be a signature and a sentence.
interface Ticket {
readonly id: string
readonly plate: string
readonly type: VehicleType
readonly spotId: string
readonly entryAt: Date
}
interface Receipt {
readonly ticketId: string
readonly minutes: number
readonly amount: number
}
interface SpotFinder {
find(spots: readonly ParkingSpot[], v: Vehicle): ParkingSpot | undefined
}
interface PricingPolicy {
fee(ticket: Ticket, exitAt: Date): number
}Look at what fee takes: the whole ticket and the exit time, not a number of minutes.
That one choice is deep dive 3, and it is the reason the weekend follow up at the top of
this page is a ten minute job instead of a rewrite.
The rules#
const fitting = (spots: readonly ParkingSpot[], v: Vehicle) =>
spots.filter((s) => s.isFree && s.fits(v))
class NearestFirst implements SpotFinder {
find(spots: readonly ParkingSpot[], v: Vehicle): ParkingSpot | undefined {
return fitting(spots, v).sort((a, b) => a.distance - b.distance)[0]
}
}
class BestFit implements SpotFinder {
find(spots: readonly ParkingSpot[], v: Vehicle): ParkingSpot | undefined {
return fitting(spots, v).sort(
(a, b) => RANK[a.size] - RANK[b.size] || a.distance - b.distance,
)[0]
}
}
const minutesBetween = (from: Date, to: Date): number =>
Math.round((to.getTime() - from.getTime()) / 60_000)
class HourlyPricing implements PricingPolicy {
constructor(private readonly perHour: Record<VehicleType, number>) {}
fee(ticket: Ticket, exitAt: Date): number {
const minutes = minutesBetween(ticket.entryAt, exitAt)
const hours = Math.max(1, Math.ceil(minutes / 60))
return hours * this.perHour[ticket.type]
}
}The lot#
interface Parked {
readonly ticket: Ticket
readonly spot: ParkingSpot
}
class ParkingLot {
private readonly active = new Map<string, Parked>()
private issued = 0
constructor(
private readonly floors: readonly Floor[],
private readonly finder: SpotFinder,
private readonly pricing: PricingPolicy,
) {}
enter(v: Vehicle, at: Date): Ticket | null {
const spots = this.floors.flatMap((f) => f.spots)
const spot = this.finder.find(spots, v)
if (!spot || !spot.tryPark(v)) return null
this.issued += 1
const ticket: Ticket = {
id: `T${this.issued}`,
plate: v.plate,
type: v.type,
spotId: spot.id,
entryAt: at,
}
this.active.set(ticket.id, { ticket, spot })
return ticket
}
exit(ticketId: string, at: Date): Receipt {
const parked = this.active.get(ticketId)
if (!parked) throw new Error(`No vehicle is parked on ${ticketId}`)
const amount = this.pricing.fee(parked.ticket, at)
parked.spot.release()
this.active.delete(ticketId)
const minutes = minutesBetween(parked.ticket.entryAt, at)
return { ticketId, minutes, amount }
}
}ParkingLot has no pricing rule and no spot rule in it. It finds, claims, records and
releases, and asks its two interfaces everything else. Notice that enter returns null
when the lot is full rather than throwing. A full lot on a Saturday is an ordinary
outcome, and exceptions are for things that should not happen, like a ticket that was
never issued.
Run it#
const at = (time: string) => new Date(`2026-10-24T${time}:00`)
const pricing = new HourlyPricing({ bike: 20, car: 40, truck: 100 })
function openLot(finder: SpotFinder): ParkingLot {
const ground = new Floor(0, [
new ParkingSpot('L1', 'large', 4),
new ParkingSpot('M1', 'medium', 12),
new ParkingSpot('M2', 'medium', 16),
new ParkingSpot('S1', 'small', 28),
])
return new ParkingLot([ground], finder, pricing)
}
const truck: Vehicle = { plate: 'MH12XY9876', type: 'truck' }
const lot = openLot(new BestFit())
const t1 = lot.enter(car, at('10:00'))
const t2 = lot.enter(truck, at('10:05'))
console.log(t1?.spotId, t2?.spotId) // M1 L1
if (t1) console.log(lot.exit(t1.id, at('12:10')))
// > { ticketId: 'T1', minutes: 130, amount: 120 }
const greedy = openLot(new NearestFirst())
greedy.enter(car, at('10:00'))
console.log(greedy.enter(truck, at('10:05'))) // nullThe last three lines are deep dive 1 in miniature. Same lot, same two vehicles, a different finder, and the truck is turned away while two medium spots stand empty.
Here are the two calls in order, with every object that takes part.
Two gates run enter() at the same moment, and both finders pick spot M1. What in the code above stops two cars being sent to M1, and would it still work if the lot ran on two servers?
Step 4: Deep dives#
Which spot: nearest first or best fit#
The obvious rule is the closest free spot that fits. It is also the rule that turns trucks away from a half empty lot. Large spots are usually the ones near the entrance, because trucks need room to turn, so a nearest first rule fills them with cars.
Try it below. Send two cars and then a truck with NearestFirst. Then empty the lot, switch to BestFit and send the same three.
With NearestFirst, the two cars take L1 and L2 and the truck is turned away with four medium spots and two small ones empty. BestFit gives each vehicle the smallest spot it fits, nearest first among those, so the cars go to M1 and M2 and the truck gets L1.
Neither rule is right in general. BestFit makes car drivers walk further, and a mall that sees one truck a month may prefer that. The answer the interviewer wants is that this is a choice with a cost, that you picked one, and that because SpotFinder is an interface, changing your mind later is one new class and one line where the lot is built.
Two gates, one spot#
In Java or Go, two gates are two threads, and the race is easy to see. In TypeScript on
Node.js it hides, because JavaScript runs one piece of synchronous code at a time.
Nothing can run between two lines unless one of them is an await. So the race appears
the moment someone adds one, for example to read the number plate from a camera
between finding the spot and claiming it.
const spots: (string | null)[] = [null, null]
const readPlate = () => Promise.resolve() // the gate camera
async function enter(plate: string): Promise<number> {
const i = spots.findIndex((s) => s === null)
await readPlate() // the other gate runs here
spots[i] = plate
return i
}
Promise.all([enter('KA01'), enter('MH12')]).then((got) => {
console.log(got) // [ 0, 0 ]
console.log(spots) // [ 'MH12', null ]
})Both gates found spot 0 free, both waited for the camera, both wrote. The first car's
claim was overwritten, and nothing failed. Move the await before the findIndex, or
better, keep finding and claiming in one synchronous method, and the race is gone.
const spots: (string | null)[] = [null, null]
const readPlate = () => Promise.resolve()
async function enter(plate: string): Promise<number> {
await readPlate()
const i = spots.findIndex((s) => s === null)
spots[i] = plate // found and claimed with no await in between
return i
}
Promise.all([enter('KA01'), enter('MH12')]).then((got) => {
console.log(got) // [ 0, 1 ]
})That is what tryPark is. And there is a second line of defence in enter: if a spot
was taken between find and tryPark, tryPark says no, and the lot returns null
instead of a ticket for a taken spot.
The same rule, check and claim in one step, has a different form at each scale.
| Where the gates run | What makes the claim one step |
|---|---|
| One Node.js process | A synchronous method with no await inside, like tryPark |
| One Java or Go process, many threads | A lock around find and claim, per floor or per spot |
| Several servers, one database | One conditional update: set the plate WHERE id = 'M1' AND plate IS NULL, then check that one row changed |
The last row is the same fix as seat inventory in IRCTC tatkal booking, and optimistic vs pessimistic locking covers the choice between the lock and the conditional update. You do not need to go there in an LLD round. You do need to say that the method that claims a spot is the one that has to be safe, and how.
What the fee method is allowed to know#
The first draft of this design, like most, had fee(type: VehicleType, minutes: number).
It is shorter and it passes every test for hourly pricing. Then the follow ups arrive.
- Weekend rates need the date.
minutesdoes not have one. - Monthly pass holders park free. That needs the plate.
typedoes not have one. - A night rate needs the time of entry. Gone too.
Each of those is a change to the interface, and so to every class that implements it and
to the lot that calls it. fee(ticket, exitAt) passes the facts and lets each policy
work out what it needs. A policy that only wants the duration computes it in one line.
When an interface takes a value you already worked out, such as a duration, a total or a flag, every implementation is stuck with your idea of what matters. Pass the record the value came from. It costs nothing now, and it is the difference between a follow up being a new class and being an edit to four files.
Change it#
This is where the round is decided. The follow up in an LLD round is rarely "ten times the traffic". It is a new business rule, and what is scored is how much of your design has to move to take it. Pick a follow up and watch the diagram: green is new, amber is edited, faded is untouched.
Read every follow up as text
Weekend rates. Saturday and Sunday are a flat ₹150 a day. Weekdays stay hourly. One new class and no edits. WeekendPricing is a PricingPolicy that holds two others and picks one by the date on the ticket. It only works because fee() takes the whole ticket and the exit time. If fee() had taken a number of minutes, as the first draft of this design did, the class could not tell a Saturday from a Monday and the interface, HourlyPricing and ParkingLot would all have had to change.
Free spots board. Put a board at each entrance that shows how many spots are free on each floor. Observer. The lot keeps a list of listeners and tells each one after every enter() and exit(). That is one honest edit to ParkingLot, and it is the last: a mobile app, an SMS alert or a dashboard is another LotListener, and the lot never learns what any of them are.
EV chargers. Some spots now have chargers. An electric car should get one if one is free. This one reaches the core, and it should. "Has a charger" is a new fact about a spot and "is electric" a new fact about a vehicle, so those two classes gain a field each. The rule that uses the facts is a new SpotFinder that tries charger spots for electric cars and hands everything else to a fallback finder. ParkingLot does not change.
Five lots in a city. The company runs five lots now. Send a driver to the nearest one with space. A new class above the lot, and one small method on ParkingLot so the city can ask it a question. Nothing else moves. Had ParkingLot been a singleton, which is what many first drafts do, this follow up would be a rewrite, because the whole design would assume there is exactly one lot.
Three of the four follow ups add a class and edit at most one. The EV one edits two, because it adds new facts about spots and vehicles, and facts belong on the objects they describe. A good design still changes. What you want is for each change to land in the one place that owns it.
"There is only one parking lot" is the most common reason given for making it a singleton, and it is a requirement about today. Tests need a fresh lot each time, a second site needs a second lot, and the city follow up above needs five. Build one instance where the program starts and pass it to whoever needs it.
If exit reads the clock itself, the only way to test a three hour stay is to wait
three hours, or to fake the global clock. Passing the time in, as enter(v, at) and
exit(id, at) do here, makes every pricing rule a plain function call in a test.
Whether you asked before drawing. Whether each class has one reason to change. Whether the rules that vary sit behind interfaces, and whether you can say why those two and not others. Whether the method that claims a spot is safe with two gates. And when the follow up comes, whether you can point at the diagram and say "this is a new class here, and nothing else moves".
Trade-offs#
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| BestFit over NearestFirst | Large spots stay free for the vehicles that need them, so trucks are not turned away from a lot with empty spots. | Car drivers walk further, because the nearest spots are often the large ones. | Mixed traffic with a real share of big vehicles. With almost no trucks, nearest first is the kinder rule. |
| A union for vehicle type, not subclasses | One Vehicle shape, and the compiler lists every table to update when a type is added. | If vehicles later behave differently, not only in size, you will move to classes. | While the only difference between vehicles is data. That is true for this whole question. |
| fee(ticket, exitAt) over fee(type, minutes) | Weekend rates, passes and night rates are each one new class, with no edits. | Every policy depends on the Ticket shape, so a change to Ticket touches them all. | Almost always for a pricing rule, which is the part of any booking system that changes most. |
| enter() returns null when full | The caller has to handle a full lot, and TypeScript will not let it forget. | A null says nothing about why. Several reasons would want a result type instead. | When there is one ordinary way to fail. If you need "full" and "already inside", return { ok: false, reason }. |
Interview replay#
type VehicleType = 'bike' | 'car' | 'truck'Record<VehicleType, SpotSize>constructor(readonly id: string) {}get isFree(): booleanVehicle | nullParkingSpot | undefinednew Map<string, Parked>()t1?.spotId60_000async function, await, Promise.allCheckpoint#
1. With NearestFirst, two cars and then a truck arrive at a lot whose two large spots are nearest the entrance. What happens to the truck, and why?
2. Why does ParkingSpot have tryPark() instead of a park() that the lot calls after checking isFree?
3. Monthly pass holders now park free. With fee(ticket, exitAt), what is the smallest change?
- Two interfaces: SpotFinder and PricingPolicy. They are the two rules the business changes, so they are the two things you make swappable.
- Check and claim in one step. No
awaitinside it in Node, a lock in a threaded language, a conditional update in a database. - Pass time in.
enter(v, at)andexit(id, at). A method that reads the clock cannot be tested for a three hour stay. - Pass facts, not answers.
fee(ticket, exitAt), notfee(type, minutes), or every follow up becomes an interface change.
I would ask about vehicles, floors, gates and pricing, and whether they want running code. Then the objects: a ParkingLot made of Floors made of ParkingSpots, a Vehicle with a type, and a Ticket that carries the plate, type, spot and entry time. Vehicle type and spot size are unions, not subclasses, because the only difference between a car and a truck here is size. The two rules the business will change, which spot a car gets and what it pays, go behind SpotFinder and PricingPolicy interfaces that the lot is given when it is built. I would use best fit, so cars do not fill the large spots trucks need, and say that it makes car drivers walk further. A spot claims itself with tryPark, which checks and claims in one step, so two gates cannot get the same spot, and across servers that becomes a conditional update. Time is passed in, so pricing is testable, and fee takes the whole ticket, so weekend rates or monthly passes are each one new class with nothing else edited.