Most LLD tutorials show you "the answer" and ask you to memorize it. We're going to do something different. We'll build this parking lot system 7 times โ each time adding ONE constraintA real-world requirement that forces your code to evolve. Each constraint is something the BUSINESS needs, not a technical exercise. "Cars need different sized spots" is a constraint. "Use the Strategy pattern" is not โ that's a solution you DISCOVER. that breaks your previous code. You'll feel the pain, discover the fix, and understand WHY the design exists โ not just what it looks like.
By Level 7, you'll have a complete, production-grade parking lot system. But more importantly, you'll have a set of reusable thinking tools that work for ANY system โ elevators, chess, Uber, anything. These are transferable skillsThe techniques you learn here (What Varies?, What If?, CREATES) work because they target the STRUCTURE of problems, not the domain. Every system has entities, varying algorithms, concurrency risks, and edge cases โ regardless of whether it parks cars or delivers food..
The Constraint Game โ 7 Levels
L0: Park a Car
L1: Vehicle Sizes
L2: Pricing
L3: Multiple Gates
L4: Tickets & Flow
L5: Edge Cases
L6: Testability
L7: Scale It
The System Grows โ Level by Level
Each level adds one constraint. Here's a thumbnail of how the class diagram expands from 2 boxes to a full distributed design:
Each section has its own full diagram. This teaser just shows the direction of travel.
System: A production-gradeProduction-grade means the design handles the messy real-world cases that tutorials skip: thread-safety, error paths, edge cases, testability, and extensibility. It's the difference between code that passes unit tests and code that survives in a live system with 10 concurrent users. multi-level parking lot with different vehicle sizes, multiple pricing strategies, concurrent entry gates, tickets, and full error handling.
Patterns You'll Discover:StrategyEncapsulates interchangeable algorithms (pricing types) behind a common interface. You'll DISCOVER this pattern in Level 2 by asking "what varies?" โ not by being told to use it., Result PatternReturns success/failure with data instead of throwing exceptions. ParkingResult<T> carries either the value or an error message โ making error handling explicit and composable. You'll discover this in Level 5., Singleton (DI)Ensures a single instance of ParkingLotService โ but through Dependency Injection, not the classic static Singleton. This makes it testable. You'll discover WHY in Level 6., DecoratorWraps an existing pricing strategy with additional behavior (like weekend multipliers) without modifying the original. It emerges naturally when pricing rules get layered.
Skills You'll Build: Real-world walkthrough, Noun Technique for entities, "What Varies?" for pattern detection, thread-safety instincts, What If? edge case framework, CREATES interview structureA 6-step interview framework: Clarify โ Requirements โ Entities โ Algorithms โ Thread-safety โ Edge cases โ Scale. It gives you a systematic path through any LLD problem so you never stall or forget a key dimension., reusable thinking toolsThe frameworks you internalize here โ Noun Technique, What Varies?, CREATES, What If? โ are domain-agnostic. They work on parking lots, elevators, chess engines, ride-sharing systems. You're not learning a parking lot design; you're upgrading your problem-solving firmware. that transfer to any system.
Time: ~90 minutes (with Think First challenges) or ~45 minutes (speed reading)
Section 2
Before You Code โ See the Real World
Think First #1
Close your eyes and walk through a parking lot in your mind โ from the moment you approach the entrance to the moment you drive away. List every thing (noun) you interact with and every action (verb) that happens.
60 seconds โ try it before revealing.
Reveal Answer
Here's what most people discover โ see the walkthrough below. If you got 5+ nouns and 3+ actions, you're already thinking like a designer. The real world is your first diagram.
Senior engineers don't start with classes. They start by observing the real system. Let's walk through a parking lot โ not as a developer, but as a user.
Stage 1: Approach the Entrance
What You SEE
An entry gate with a barrier arm. A sign showing "OPEN" or "FULL". Maybe a display with available spots per floor.
What You DO
Press a button or scan a card. Receive a ticket with a timestamp. The barrier opens.
Behind the Scenes
The system records your entry timeThis is the start of the billing clock. The system needs to store this precisely โ DateTimeOffset.UtcNow in .NET, not DateTime.Now, to avoid timezone and DST bugs., assigns a ticket IDA unique identifier (typically a GUID) that links the entry event to the exit event. Without it, the system can't calculate your fee. In our code: Guid.NewGuid().ToString() โ globally unique, no coordination needed between gates., and decrements the available count.
Design insight: We already found 5 nouns: Gate, Ticket, Floor, Display, Barrier. And 3 operations: issue ticket, record time, update count. We haven't written a line of code.
Stage 2: Find a Spot & Park
What You SEE
Rows of parking spots โ some compactSmaller spots for motorcycles and small cars. The system needs to know which vehicles fit in which spots โ a motorcycle can park in a compact spot, but a truck can't., some regular, some large. Colored indicators (green = available, red = taken). Floor numbers and section labels.
What You DO
Drive to an available spot that fits your vehicle. Pull in. Done.
Behind the Scenes
The system matches vehicle size โ spot size. A motorcycle fits in compact. A truck needs large. The spot status flips from Available โ Occupied.
Design insight: New nouns: Vehicle, ParkingSpot, VehicleType, SpotSize. Key operation: match vehicle to spot. And a critical observation โ spot sizes and vehicle types are categories, not things with behavior. That matters for our code โ categories โ enumsIn C#, if a type only IDENTIFIES something (motorcycle vs car) but doesn't DO anything different, use an enum. If different types have different BEHAVIOR (different methods), use a class hierarchy. This distinction prevents the common mistake of building deep inheritance trees for pure data..
Stage 3: Time Passes
What You SEE
Nothing changes from your perspective. Your car sits in the spot.
What You DO
Nothing. You're shopping or at the office.
Behind the Scenes
The billing clock is ticking. Different pricing strategiesHourly parking charges per hour. Flat-rate charges a fixed daily fee. Tiered pricing might charge $5 for the first hour, then $3/hour after. The parking lot can switch between these โ or even combine them (weekday hourly, weekend flat-rate). calculate differently: hourly, flat-rate, tiered. The same duration produces different fees depending on the strategy.
Design insight: The word "different" is a design alarmWhen you hear "different types of X" or "multiple ways to Y" in requirements, your pattern radar should activate. "Different" often signals a Strategy candidate โ algorithms that vary independently from the rest of the system.. Multiple algorithms for the same operation (fee calculation) = the fee algorithm varies independently. File that instinct.
Stage 4: Exit & Pay
What You SEE
An exit gate. A payment machine (or you pay with an app). A receipt.
What You DO
Insert your ticket. Pay the fee. The barrier opens. You drive out.
Behind the Scenes
The system calculates exit time - entry time โ duration โ fee. Processes payment. Releases the spot (Occupied โ Available). Updates the display count. Archives the ticket.
Design insight: New noun: Payment. Key data flow: Ticket โ Duration โ Fee โ Payment โ Receipt. And a concurrency riskWhat if two cars try to exit at the same time through different gates? Both might try to release spots and update counts simultaneously. Without thread safety, you could end up with negative available counts or corrupted data. โ what if two cars exit simultaneously through different gates?
What We Discovered โ Without Writing a Single Line of Code
VehicleType (motorcycle, car, van, truck), SpotSize (compact, regular, large)
Operations (verbs)
Enter, FindSpot, Park, CalculateFee, Pay, Exit, UpdateDisplay
State changes
Spot: Available โ Occupied. Ticket: Active โ Paid โ Archived โ these are state transitionsWhen an entity's behavior or properties change based on an event. Each transition has a trigger (car enters โ spot becomes Occupied) and may have guards (can't occupy an already-occupied spot). Tracking these helps find edge cases.
Variable algorithm
Fee calculation (hourly / flat-rate / tiered) โ same interface, different behavior
Before touching code, walk through the PHYSICAL system. List every noun (โ entity), every action (โ method), every rule (โ constraint). The real world is your first diagram. Senior engineers start here โ and they discover 80% of the design before opening an IDE.
Now let's turn these observations into code โ one constraint at a time.
Level 0 ๐ข EASY
Park a Car, Get It Back
Constraint: "We have a parking lot. Cars come in, park in a spot, leave, and pay a fee."
This is the stupidest possible version. No vehicle types, no pricing strategies, no concurrency. Just: park a car, return a fee. Let's see how simple it can be.
Think First #2
What's the absolute minimum code to park a car in a spot and calculate a fixed fee? Think: what data structure holds the spots? What method parks? What method calculates the fee?
60 seconds โ try it.
Reveal Answer
A Dictionary<string, bool> for spots (spot ID โ occupied?), a Park() method that finds an empty spot, and a CalculateFee() that returns a hardcoded rate ร hours. ~25 lines total. See the code below.
ParkingLot.cs โ Level 0
namespace ParkingLot.Level0;
public class ParkingLotService
{
private readonly Dictionary<string, bool> _spots = new(); // spotId โ isOccupied
public ParkingLotService(int capacity)
{
for (int i = 1; i <= capacity; i++)
_spots[$"S-{i}"] = false; // all spots start empty
}
public string? Park(string licensePlate)
{
// Find first available spot
var spot = _spots.FirstOrDefault(s => !s.Value);
if (spot.Key is null) return null; // lot is full
_spots[spot.Key] = true;
return spot.Key; // return the spot ID
}
public void Leave(string spotId)
{
_spots[spotId] = false; // mark spot as available
}
public decimal CalculateFee(int hours)
{
return hours * 5.00m; // flat $5/hour
}
}
That's it. 25 lines. It works for a toy parking lot. But watch what happens when reality shows up.
Growing Diagram โ After Level 0
Class Diagram โ Level 0
Before This Level
You see "parking lot" and think "where do I even start?"
After This Level
You know to start with the simplest possible version โ then add constraints.
Level 1 ๐ข EASY
Different Vehicles Need Different Spots
New Constraint: "Motorcycles, cars, vans, and trucks need different sized spots. A motorcycle fits in compact, a car fits in regular, a truck needs large."
What Breaks?
Our Level 0 Dictionary<string, bool> has no concept of SIZE. Every spot is identical. A motorcycle and a truck both park in the same kind of spot. We can't match vehicles to appropriately-sized spots.
Think First #3
Vehicle types (motorcycle, car, van, truck) and spot sizes (compact, regular, large) are categories. Should they be classes with inheritance, enums, or records? What's the deciding factor?
60 seconds โ think about what behavior (if any) these types have.
Reveal Answer
Enums. Vehicle types and spot sizes have no behavior โ they're pure categories. A motorcycle doesn't "do" anything differently from a car in terms of code behavior. They differ in identity (size), not in actions. That's exactly what enums are for. Classes would be over-engineering โ you'd have empty subclasses with no methods.
Your Internal Monologue
"OK, different vehicle sizes... I could make a Vehicle base class with Motorcycle, Car, Truck subclasses. But... what methods would they override? Nothing. They don't behave differently โ they just are different sizes."
"Actually, that's the key question: do they have behavior, or are they just categories? A motorcycle doesn't park() differently. It just needs a different sized spot. That's data, not behavior."
"Enums. VehicleType and SpotSize as enums. Then a mapping: motorcycle โ compact, car โ regular, truck โ large. Clean, simple, no empty inheritance hierarchies."
What Would You Do?
ClassHierarchy.cs
public abstract class Vehicle
{
public string LicensePlate { get; }
public abstract SpotSize RequiredSize { get; }
protected Vehicle(string plate) => LicensePlate = plate;
}
public class Motorcycle : Vehicle
{
public Motorcycle(string plate) : base(plate) { }
public override SpotSize RequiredSize => SpotSize.Compact;
}
public class Car : Vehicle
{
public Car(string plate) : base(plate) { }
public override SpotSize RequiredSize => SpotSize.Regular;
}
public class Truck : Vehicle
{
public Truck(string plate) : base(plate) { }
public override SpotSize RequiredSize => SpotSize.Large;
}
// 4 classes... and the only difference is ONE property value.
Consequence: 4 classes with no behavioral difference. Each one overrides a single property to return a constant. Adding a "Van" type means a new file, a new class โ for one enum value. This is over-engineeringUsing heavyweight abstractions (classes, inheritance) for lightweight problems (pure categories). Classes shine when types have different BEHAVIOR โ different method implementations. When the only difference is a data value, an enum is the right tool..
When IS this fine? When vehicle types genuinely have different behavior โ like if a Motorcycle can split-park (share a spot), or an ElectricVehicle has charging logic. Then behavior justifies the class.
EnumsAndMapping.cs
public enum VehicleType { Motorcycle, Car, Van, Truck }
public enum SpotSize { Compact, Regular, Large }
// One-line mapping โ done.
public static class SpotSizeMapping
{
private static readonly FrozenDictionary<VehicleType, SpotSize> Map =
new Dictionary<VehicleType, SpotSize>
{
[VehicleType.Motorcycle] = SpotSize.Compact,
[VehicleType.Car] = SpotSize.Regular,
[VehicleType.Van] = SpotSize.Large,
[VehicleType.Truck] = SpotSize.Large,
}.ToFrozenDictionary();
public static SpotSize For(VehicleType type) => Map[type];
}
Why it wins: Adding a new vehicle type = add one enum value + one dictionary entry. No new classes. No inheritance. The mapping is a FrozenDictionaryA .NET 8 immutable, read-optimized dictionary. It's created once and never modified, which makes lookups faster than regular Dictionary. Perfect for static mappings like VehicleType โ SpotSize that are set at compile time and never change. โ immutable, thread-safe, fast.
What it costs: If vehicle types ever need truly different behavior (not just data), you'd need to refactor to classes. But YAGNI โ don't solve problems you don't have.
Decision Compass:
"Does it have different behavior? โ class hierarchy. Pure data/category? โ enum + record."
What Changed (Level 0 โ Level 1)
ParkingLot.cs โ Level 1
using System.Collections.Frozen;
namespace ParkingLot.Level1;
public enum VehicleType { Motorcycle, Car, Van, Truck }
public enum SpotSize { Compact, Regular, Large }
public static class SpotSizeMapping
{
private static readonly FrozenDictionary<VehicleType, SpotSize> Map =
new Dictionary<VehicleType, SpotSize>
{
[VehicleType.Motorcycle] = SpotSize.Compact,
[VehicleType.Car] = SpotSize.Regular,
[VehicleType.Van] = SpotSize.Large,
[VehicleType.Truck] = SpotSize.Large,
}.ToFrozenDictionary();
public static SpotSize For(VehicleType type) => Map[type];
}
public record ParkingSpot(string Id, SpotSize Size)
{
public bool IsOccupied { get; set; }
}
public class ParkingLotService
{
private readonly List<ParkingSpot> _spots;
public ParkingLotService(IEnumerable<ParkingSpot> spots)
=> _spots = spots.ToList();
public ParkingSpot? Park(VehicleType vehicleType)
{
var requiredSize = SpotSizeMapping.For(vehicleType);
var spot = _spots.FirstOrDefault(s => !s.IsOccupied && s.Size == requiredSize);
if (spot is null) return null;
spot.IsOccupied = true;
return spot;
}
public void Leave(string spotId)
{
var spot = _spots.First(s => s.Id == spotId);
spot.IsOccupied = false;
}
public decimal CalculateFee(int hours) => hours * 5.00m;
}
Growing Diagram โ After Level 1
Class Diagram โ Level 1
Before This Level
You see "different vehicle types" and think "class inheritance hierarchy."
After This Level
You first ask: "Do they have different behavior, or are they just categories?" Categories โ enums. Behavior โ classes.
๐ "The Categories Without Behavior Smell"
When you see types that differ in identity but NOT in actions โ use enums, not class hierarchies. Save classes for when types genuinely override behavior.
Level 2 ๐ก MEDIUM
Multiple Pricing Types
New Constraint: "The lot supports hourly pricing, flat daily rate, AND tiered pricing (first hour at one rate, subsequent hours at another). Management wants to switch between them without changing code."
What Breaks?
Our Level 1 has return hours * 5.00m. Three pricing types? That's three if/else branches stuffed into one method. Adding a 4th type means modifying this method. And a 5th. This is the method that becomes 200 lines and nobody wants to touch.
Think First #4
Design it so adding a NEW pricing type requires ZERO changes to existing code. How would you structure this?
60 seconds โ think about what VARIES independently.
Reveal Answer
Extract the pricing algorithm into an interface (IPricingStrategy) with one method: Calculate(TimeSpan duration). Each pricing type becomes its own class implementing this interface. Adding a 4th type = adding one new class. Zero changes to ParkingLotService. This is the Strategy pattern โ and you just discovered it by asking "what varies?"
Your Internal Monologue
"Multiple pricing types... I could use a switch. switch(pricingType) { case Hourly: ... case FlatRate: ... }. Works for 2 types. But the 3rd? I'd add another case. And the 4th. This switch becomes the method that nobody wants to touch..."
"Wait โ what's the actual problem? The algorithm varies. The rest of the system doesn't care HOW the fee is calculated โ it just needs a decimal back. So... what if each pricing type was its own thing? Its own class? Same interface, different behavior."
"That way adding a 4th type = new class. Zero changes to existing code. The service just calls _pricing.Calculate(duration) and doesn't know or care which strategy it is."
"That's Strategy. I didn't try to use a pattern โ I just asked 'what varies independently?' and ended up here."
Consequence: Every new pricing type modifies this method. That's an OCP violationThe Open/Closed Principle says code should be open for extension but closed for modification. Adding behavior should mean adding new code (a new class), not changing existing code (modifying a method). When you modify existing code, you risk breaking what already works.. Testing? One monolithic method with 6+ branches. Good luck.
When IS this fine? 2 types that will NEVER grow. Quick scripts. Prototypes. If you're sure there will only ever be 2, a switch is simpler.
InheritanceApproach.cs
public abstract class ParkingLotService
{
// ... all parking logic ...
public abstract decimal CalculateFee(TimeSpan duration);
}
public class HourlyParkingLot : ParkingLotService
{
public override decimal CalculateFee(TimeSpan duration)
=> (decimal)Math.Ceiling(duration.TotalHours) * 5.00m;
}
public class FlatRateParkingLot : ParkingLotService
{
public override decimal CalculateFee(TimeSpan duration) => 20.00m;
}
// The pricing is ONE concern. Making the ENTIRE service a subclass
// for ONE varying behavior is a shotgun approach.
Consequence: The entire service becomes a subclass for ONE varying behavior. What if pricing AND spot assignment both vary? Two inheritance hierarchies? Diamond problemWhen a class needs to inherit from two different base classes that share a common ancestor. C# doesn't support multiple inheritance for classes, so you can't have HourlyNearestParkingLot inheriting from both HourlyParkingLot and NearestParkingLot. This is why composition (Strategy) beats inheritance for varying behaviors. in waiting.
When IS this fine? When the ENTIRE class behavior changes (like Template MethodA pattern where the base class defines the skeleton of an algorithm, and subclasses override specific steps. Works when the overall workflow is fixed but individual steps vary. Unlike Strategy, the varying behavior is inherited, not injected.) โ not when a single algorithm varies.
StrategyApproach.cs
public interface IPricingStrategy
{
decimal Calculate(TimeSpan duration);
}
public sealed class HourlyPricing : IPricingStrategy
{
public decimal Calculate(TimeSpan duration)
=> (decimal)Math.Ceiling(duration.TotalHours) * 5.00m;
}
public sealed class FlatRatePricing : IPricingStrategy
{
public decimal Calculate(TimeSpan duration) => 20.00m;
}
public sealed class TieredPricing(decimal firstHourRate, decimal additionalRate) : IPricingStrategy
{
public decimal Calculate(TimeSpan duration)
{
var hours = (decimal)Math.Ceiling(duration.TotalHours);
return hours <= 1 ? firstHourRate : firstHourRate + (hours - 1) * additionalRate;
}
}
// In ParkingLotService โ ONE line change:
public decimal CalculateFee(TimeSpan duration) => _pricing.Calculate(duration);
Why it wins: Each pricing type owns its own logic. Adding "weekend pricing" = one new class. ParkingLotService never changes. Each strategy is independently testable. The service doesn't know or care which strategy it uses.
What it costs: More files. One interface + N classes instead of one switch. Worth it when N grows beyond 2 โ and in an interview, it ALWAYS grows.
Decision Compass:
"Will the algorithm change independently? โ Strategy. Truly fixed with only 2 options? โ inline switch is fine."
View Complete Code (Level 2)
ParkingLot.cs โ Level 2 (Complete)
using System.Collections.Frozen;
namespace ParkingLot.Level2;
public enum VehicleType { Motorcycle, Car, Van, Truck }
public enum SpotSize { Compact, Regular, Large }
public static class SpotSizeMapping
{
private static readonly FrozenDictionary<VehicleType, SpotSize> Map =
new Dictionary<VehicleType, SpotSize>
{
[VehicleType.Motorcycle] = SpotSize.Compact,
[VehicleType.Car] = SpotSize.Regular,
[VehicleType.Van] = SpotSize.Large,
[VehicleType.Truck] = SpotSize.Large,
}.ToFrozenDictionary();
public static SpotSize For(VehicleType type) => Map[type];
}
// --- Pricing Strategies (NEW in Level 2) ---
public interface IPricingStrategy
{
decimal Calculate(TimeSpan duration);
}
public sealed class HourlyPricing(decimal ratePerHour = 5.00m) : IPricingStrategy
{
public decimal Calculate(TimeSpan duration)
=> (decimal)Math.Ceiling(duration.TotalHours) * ratePerHour;
}
public sealed class FlatRatePricing(decimal dailyRate = 20.00m) : IPricingStrategy
{
public decimal Calculate(TimeSpan duration) => dailyRate;
}
public sealed class TieredPricing(
decimal firstHourRate = 5.00m,
decimal additionalRate = 3.00m) : IPricingStrategy
{
public decimal Calculate(TimeSpan duration)
{
var hours = (decimal)Math.Ceiling(duration.TotalHours);
return hours <= 1 ? firstHourRate : firstHourRate + (hours - 1) * additionalRate;
}
}
public record ParkingSpot(string Id, SpotSize Size)
{
public bool IsOccupied { get; set; }
}
public class ParkingLotService
{
private readonly List<ParkingSpot> _spots;
private readonly IPricingStrategy _pricing; // Level 2: injected strategy
public ParkingLotService(IEnumerable<ParkingSpot> spots, IPricingStrategy pricing)
{
_spots = spots.ToList();
_pricing = pricing;
}
public ParkingSpot? Park(VehicleType vehicleType)
{
var requiredSize = SpotSizeMapping.For(vehicleType);
var spot = _spots.FirstOrDefault(s => !s.IsOccupied && s.Size == requiredSize);
if (spot is null) return null;
spot.IsOccupied = true;
return spot;
}
public void Leave(string spotId)
{
var spot = _spots.First(s => s.Id == spotId);
spot.IsOccupied = false;
}
public decimal CalculateFee(TimeSpan duration)
=> _pricing.Calculate(duration); // Level 2: delegate to strategy
}
Growing Diagram โ After Level 2
Class Diagram โ Level 2
Before This Level
You see "multiple pricing types" and think "switch statement."
After This Level
You smell "multiple algorithms, same interface" and instinctively reach for Strategy.
๐ "The Multiple Algorithms Smell"
When you see 3+ ways to do the same thing, each chosen at runtime โ that's Strategy. Extract the algorithm into an interface. Each variant = one class. The context doesn't know or care which one it's using.
Transfer: Same technique works in an Elevator System โ scheduling algorithms (FCFS, SCAN, LOOK) each implement ISchedulingStrategy. The elevator doesn't care which scheduler it uses. Strategy everywhere.
Level 3 ๐ก MEDIUM
Multiple Entry Gates โ Your Code Just Broke
New Constraint: "The parking lot has 3 entry gates and 2 exit gates. Cars can enter and exit simultaneously from different gates."
What Breaks?
Our Level 2 Park() method does this: (1) find an empty spot, (2) mark it occupied. That's two steps. With one gate, fine. With three gates operating simultaneously? Two gates both find the same empty spot at Step 1. Both mark it occupied at Step 2. Two cars, one spot. That's a race conditionWhen the outcome of an operation depends on the timing of events. Thread A reads "spot is free" โ Thread B reads "spot is free" โ Thread A parks โ Thread B parks in the same spot. The result depends on who runs first โ and in production, that's unpredictable..
Think First #5
The problem is that "find spot" and "mark occupied" happen in two steps. Between them, another thread can sneak in. How would you make this operation atomic (all-or-nothing)?
60 seconds โ think about what C# tools prevent two threads from running the same code simultaneously.
Reveal Answer
Use a lock around the compound operation (find + mark). While one thread is inside the lock, all other threads wait. This makes the two-step operation atomic. Also switch from List<ParkingSpot> to ConcurrentDictionary for the spots collection โ individual reads/writes become thread-safe even outside the lock.
The Race Condition โ See It Happen
Race Condition โ Two Gates, One Spot
Your Internal Monologue
"Multiple gates... that means multiple threads. My Park() does find-then-occupy โ two steps. Between those two steps, another thread can sneak in and grab the same spot. Classic TOCTOUTime Of Check vs Time Of Use. You CHECK that a spot is free (time of check) and then USE it by marking it occupied (time of use). Between check and use, the state can change. The fix: make check-and-use happen as one atomic operation. bug..."
"I need to make find-and-occupy atomic. In C#, that's a lock. While one thread is inside the lock, others wait. Simple, correct, and good enough for a parking lot."
"Also โ my List<ParkingSpot> isn't thread-safe for concurrent reads and writes. I should use ConcurrentDictionary for the spots collection. The lock handles the compound operation, and ConcurrentDictionary handles individual spot lookups."
What Would You Do?
Option A โ ConcurrentDictionary alone
// Just swap List for ConcurrentDictionary โ done?
private readonly ConcurrentDictionary<string, ParkingSpot> _spots;
public ParkingSpot? Park(VehicleType type)
{
var size = SpotSizeMapping.For(type);
var spot = _spots.Values.FirstOrDefault(s => !s.IsOccupied && s.Size == size);
if (spot is null) return null;
spot.IsOccupied = true; // Still TWO separate steps!
return spot;
}
Consequence:ConcurrentDictionaryA thread-safe Dictionary implementation in .NET. Individual operations (TryAdd, TryRemove, TryGetValue) are atomic. But iterating (.Values) or combining multiple operations (find then update) is NOT atomic โ you still need a lock for those compound operations. makes individual operations thread-safe (TryAdd, TryRemove). But our bug is a compound operation: find THEN occupy. Those are still two steps. The race condition is still there.
When it IS fine: When you only need atomic individual operations โ adding items, removing items, or reading single values. No compound find-then-update logic.
Option B โ SemaphoreSlim
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task<ParkingSpot?> ParkAsync(VehicleType type)
{
await _semaphore.WaitAsync();
try
{
var size = SpotSizeMapping.For(type);
var spot = _spots.Values.FirstOrDefault(s => !s.IsOccupied && s.Size == size);
if (spot is null) return null;
spot.IsOccupied = true;
return spot;
}
finally { _semaphore.Release(); }
}
Consequence: Works correctly โ SemaphoreSlimA lightweight semaphore that limits the number of threads that can enter a section of code concurrently. SemaphoreSlim(1, 1) acts like a lock but supports async/await. The tradeoff: more ceremony (try/finally/Release), but necessary when your critical section contains await calls. prevents concurrent access. But it forces the entire API to be async, adds try/finally ceremony, and is overkill here โ our critical section has no await calls, so we don't need async locking.
When it IS fine: When your critical section contains await calls (database queries, HTTP requests). You can't use lock with async code โ SemaphoreSlim is the right tool there.
Option C โ lock (Monitor)
private readonly object _lock = new();
public ParkingSpot? Park(VehicleType type)
{
var size = SpotSizeMapping.For(type);
lock (_lock) // One thread at a time
{
var spot = _spots.Values.FirstOrDefault(s => !s.IsOccupied && s.Size == size);
if (spot is null) return null;
spot.IsOccupied = true;
return spot;
}
}
Why it wins: Simplest correct solution. lockC# syntactic sugar for Monitor.Enter/Monitor.Exit. Ensures only one thread can execute the enclosed block at a time. Uses a dedicated lock object (never lock on 'this' or a Type). The compiler generates a try/finally under the hood, so the lock is always released โ even if an exception occurs. wraps the compound operation (find + mark) into one atomic unit. No async ceremony. No try/finally boilerplate. ConcurrentDictionary handles individual spot lookups, lock handles the compound operation. Clean separation of concerns.
What it costs: Serializes access โ only one thread parks at a time. For a parking lot with <10 gates, this is perfectly fine. For thousands of concurrent operations, you'd need a more granular locking strategy (per-spot locks or InterlockedSystem.Threading.Interlocked provides atomic operations on individual variables (CompareExchange, Increment, Decrement). Much faster than lock for single-variable operations, but can't protect multi-step logic. Use it when you need to atomically update ONE field, not when you need to protect a sequence of operations. operations).
Decision Compass:
"Compound operation with no awaits? โ lock. Compound operation with awaits? โ SemaphoreSlim. Individual atomic operations only? โ ConcurrentDictionary alone."
What Changed (Level 2 โ Level 3)
ParkingLotService.cs โ Level 3 (Thread-Safe)
public class ParkingLotService
{
private readonly ConcurrentDictionary<string, ParkingSpot> _spots; // Thread-safe collection
private readonly IPricingStrategy _pricing;
private readonly object _lock = new(); // NEW: guards compound operations
public ParkingLotService(IEnumerable<ParkingSpot> spots, IPricingStrategy pricing)
{
_spots = new ConcurrentDictionary<string, ParkingSpot>(
spots.Select(s => KeyValuePair.Create(s.Id, s)));
_pricing = pricing;
}
public ParkingSpot? Park(VehicleType vehicleType)
{
var requiredSize = SpotSizeMapping.For(vehicleType);
lock (_lock) // NEW: atomic find + occupy
{
var spot = _spots.Values
.FirstOrDefault(s => !s.IsOccupied && s.Size == requiredSize);
if (spot is null) return null;
spot.IsOccupied = true; // happens inside lock โ no race
return spot;
}
}
public void Leave(string spotId)
{
lock (_lock) // NEW: atomic release
{
if (_spots.TryGetValue(spotId, out var spot))
spot.IsOccupied = false;
}
}
public decimal CalculateFee(TimeSpan duration)
=> _pricing.Calculate(duration); // No lock needed โ pure calculation
}
After the Fix โ Lock Makes It Safe
Fixed โ Lock Prevents Race Condition
Before This Level
You write "find then update" and think "looks fine."
After This Level
You see any multi-step operation on shared data and immediately ask: "What if two threads hit this at the same time?"
Growing Diagram โ After Level 3
Class Diagram โ Level 3
๐ "The Simultaneous Access Smell"
When you see multiple actors modifying the same shared resource โ wrap the compound operation in a lock. Individual read/write โ ConcurrentDictionary. Multi-step read-then-write โ lock.
Transfer: Same bug appears in an Online Shopping system โ two users add the last item to cart simultaneously. Both see "1 in stock." Both click Buy. One gets the item, the other gets an angry email. The fix: lock around check-inventory + decrement-inventory.
Level 4 ๐ก MEDIUM
Tickets, Timestamps & Fee Calculation
New Constraint: "When a car enters, issue a ticket with entry time. When it exits, calculate the fee from entry to exit. Keep a record of all transactions."
What Breaks?
Our Level 3 has no concept of time. Park() doesn't record when a vehicle entered. CalculateFee() takes a hardcoded TimeSpan โ who calculates that? There's no ticket, no receipt, no audit trail. The system forgets everything the moment a car leaves.
Think First #6
A parking ticket is created at entry and completed at exit. Once issued, should its entry time ever change? What C# type represents data that is fixed after creation?
60 seconds โ think about mutability vs immutability.
Reveal Answer
A record. The ticket's ID, license plate, entry time, and spot ID should NEVER change after creation. Making it a record communicates this intent to every developer who reads the code. The exit time and fee are set once at exit โ use a separate ParkingReceipt record for the completed transaction.
Your Internal Monologue
"I need a ticket... should it be a class or a record? The entry time, plate, spot โ these are set at creation and never change. That screams immutability. A record with init properties."
"Wait โ for the exit, I need to add exit time and fee. Should I mutate the ticket? No... the entry data is fixed. The exit data is a new event. Maybe a separate ParkingReceipt that combines the ticket with exit info. Two immutable snapshots: what happened at entry, what happened at exit."
"And I need DateTimeOffset.UtcNow, not DateTime.Now. Timezones and daylight saving time have killed too many production systems."
Models.cs โ Level 4
// Ticket โ immutable snapshot of the ENTRY event
public sealed record ParkingTicket(
string TicketId,
string LicensePlate,
VehicleType VehicleType,
string SpotId,
DateTimeOffset EntryTime);
// Receipt โ immutable snapshot of the EXIT event
public sealed record ParkingReceipt(
ParkingTicket Ticket,
DateTimeOffset ExitTime,
TimeSpan Duration,
decimal Fee);
// Updated ParkingSpot โ now tracks which ticket is parked here
public record ParkingSpot(string Id, SpotSize Size)
{
public bool IsOccupied { get; set; }
public string? CurrentTicketId { get; set; }
}
ParkingLotService.cs โ Level 4 (Key Methods)
public class ParkingLotService
{
private readonly ConcurrentDictionary<string, ParkingSpot> _spots;
private readonly ConcurrentDictionary<string, ParkingTicket> _activeTickets = new();
private readonly IPricingStrategy _pricing;
private readonly object _lock = new();
public ParkingTicket? Entry(string licensePlate, VehicleType vehicleType)
{
lock (_lock)
{
var requiredSize = SpotSizeMapping.For(vehicleType);
var spot = _spots.Values
.FirstOrDefault(s => !s.IsOccupied && s.Size == requiredSize);
if (spot is null) return null;
var ticket = new ParkingTicket(
TicketId: Guid.NewGuid().ToString("N")[..8].ToUpper(),
LicensePlate: licensePlate,
VehicleType: vehicleType,
SpotId: spot.Id,
EntryTime: DateTimeOffset.UtcNow);
spot.IsOccupied = true;
spot.CurrentTicketId = ticket.TicketId;
_activeTickets[ticket.TicketId] = ticket;
return ticket;
}
}
public ParkingReceipt? Exit(string ticketId)
{
lock (_lock)
{
if (!_activeTickets.TryRemove(ticketId, out var ticket))
return null; // invalid or already used ticket
var spot = _spots[ticket.SpotId];
spot.IsOccupied = false;
spot.CurrentTicketId = null;
var exitTime = DateTimeOffset.UtcNow;
var duration = exitTime - ticket.EntryTime;
var fee = _pricing.Calculate(duration);
return new ParkingReceipt(ticket, exitTime, duration, fee);
}
}
}
Data Flow: Entry โ Exit
Entry & Exit Flow
Before This Level
You store everything in mutable objects and hope nothing changes after creation.
After This Level
You use records for data that's fixed after creation. Immutability isn't a nice-to-have โ it prevents whole categories of bugs.
Growing Diagram โ After Level 4
Class Diagram โ Level 4
๐ "The Fixed After Creation Smell"
When data should never change after it's born (ticket, receipt, transaction) โ use a recordC# records are reference types (record class) or value types (record struct) that provide value-based equality and are ideal for immutable data. Positional parameters become init-only properties. The compiler generates Equals, GetHashCode, ToString, and a copy constructor (with expression) automatically.. The compiler enforces what comments can't.
Transfer: Same technique in a Movie Ticket Booking system โ the booking confirmation is a record (seat, show, time, price). Once issued, it never changes. The receipt after the show? Another record. Immutable data = trustworthy audit trail.
Level 5 ๐ด HARD
Everything That Can Go Wrong
New Constraint: "The system must handle: lot full, payment failure, lost tickets, invalid operations, and weird edge cases. It cannot crash or corrupt data."
What Breaks?
Our Level 4 code handles the happy pathThe path through code where everything works as expected. No errors, no edge cases, no unusual inputs. It's the scenario developers test first โ and often the ONLY scenario they test. The happy path works in demos. Production needs the sad paths too. perfectly. But what happens when a car tries to enter a full lot? When payment fails mid-exit? When someone presents a ticket that doesn't exist? Our code returns null for some cases but has no systematic approach to failure.
Think First #7
Use the What If? framework: for each operation (Entry, Exit), ask these 4 questions: (1) What if two actors do it simultaneously? (2) What if it fails mid-way? (3) What if we hit a limit? (4) What if the input is unexpected? List as many edge cases as you can.
90 seconds โ this one's harder. Try to find at least 5.
Reveal Answer
Here are 7 edge cases discovered by the What If? framework:
Category
Edge Case
Impact
Boundary
Lot is full โ no spots for this size
Must reject gracefully, not crash
Failure
Payment fails at exit gate
Car stuck at exit? Retry? Manual override?
Weird Input
Ticket ID doesn't exist (lost/fake)
Must not corrupt data or throw unhandled exceptions
Weird Input
Same ticket used twice (double exit)
Must be idempotent โ second attempt = "already exited"
Boundary
Duration is exactly 1 hour โ which rate tier?
Off-by-one in tiered pricing
Concurrency
Entry + Exit hit same spot simultaneously
Already handled by lock (Level 3)
Failure
System clock jumps (DST, NTP sync)
Duration becomes negative. Fee is -$5. Not great.
Your Internal Monologue
"My code handles the happy path beautifully. Car enters, gets a ticket, exits, pays a fee. Clean. But... what if the lot is full? Right now I return null. The caller doesn't know why it failed."
"What if someone passes an invalid ticket ID? My _activeTickets[ticketId] would throw KeyNotFoundExceptionThrown when accessing a Dictionary with a key that doesn't exist using the indexer. The safe alternative is TryGetValue() which returns false instead of throwing. Always use TryGetValue() for keys that might not exist โ it's both safer and faster (one lookup instead of two with ContainsKey + indexer).. An unhandled exception. In production, that's a gate that stays closed."
"I need a systematic way to think about what can go wrong. Not just random 'what ifs' โ a framework. Let me categorize: what can fail concurrently? What can fail mid-operation? What are the boundary conditions? What's the weirdest input someone could send?"
"That's four categories. And I need a way to return errors without throwing exceptions everywhere. A result type โ success or failure with a message."
What Would You Do?
Option A โ Exceptions for control flow
public ParkingTicket Entry(string plate, VehicleType type)
{
if (string.IsNullOrWhiteSpace(plate))
throw new ArgumentException("Plate required.");
if (_activeTickets.Values.Any(t => t.LicensePlate == plate))
throw new InvalidOperationException("Already parked.");
var spot = FindSpot(type)
?? throw new InvalidOperationException("No spots available.");
// ... park and return ticket
}
Consequence:Exceptions for control flowUsing exceptions to communicate expected business outcomes (lot full, invalid ticket) rather than truly exceptional situations (out of memory, network down). This is an anti-pattern because: (1) exceptions are expensive โ ~100x slower than returning a value, (2) the caller must know to catch specific exceptions, (3) nothing in the type signature tells you what can go wrong. is an anti-pattern. "Lot is full" isn't exceptional โ it's a normal business scenario. Exceptions are expensive (~100x slower), invisible in the API signature, and force callers into try/catch guessing games.
When it IS fine: For truly unexpected situations โ null references, configuration errors, database connection failures. Things that should never happen in normal operation.
Option B โ Return null on failure
public ParkingTicket? Entry(string plate, VehicleType type)
{
if (string.IsNullOrWhiteSpace(plate)) return null;
if (AlreadyParked(plate)) return null;
var spot = FindSpot(type);
if (spot is null) return null;
// ... park and return ticket
}
Consequence: The caller gets null but has no idea why. Was the plate invalid? Already parked? Lot full? All three return the same null. The gate display says "Error" with no useful message. The attendant calls IT. IT looks at logs. Logs show nothing because null carries no information.
When it IS fine: When the caller genuinely doesn't need to know why something failed โ e.g., FindSpot() returning null means "no spot" and that's all the info needed.
Option C โ Result<T> pattern
public sealed record ParkingResult<T>(bool Success, T? Value, string? Error)
{
public static ParkingResult<T> Ok(T value) => new(true, value, null);
public static ParkingResult<T> Fail(string error) => new(false, default, error);
}
public ParkingResult<ParkingTicket> Entry(string plate, VehicleType type)
{
if (string.IsNullOrWhiteSpace(plate))
return ParkingResult<ParkingTicket>.Fail("Plate is required.");
// ... explicit success or failure with reason
}
Why it wins: The Result patternA functional programming technique where operations return a Result object containing either a success value or an error message โ never both. The caller checks .Success before accessing .Value. This makes error handling explicit in the type signature, eliminates exception-based control flow, and is composable (you can chain Results). Common in F#, Rust, and increasingly in modern C#. makes error handling explicit in the return type. The caller sees ParkingResult<T> and knows they must check for failure. The error message is specific ("No compact spots available" vs generic null). No exceptions, no guessing, no silent failures.
What it costs: More verbose than returning null. Every caller must unwrap the result. Worth it for any public API where the caller needs to act on the failure reason.
Decision Compass:
"Expected failure the caller should handle? โ Result<T>. Truly unexpected? โ Exception. Caller doesn't need the reason? โ nullable return."
The "What If?" Framework
What If? โ 4 Edge Case Categories
ParkingLotService.cs โ Level 5 (Edge Cases)
// Result type for operations that can fail
public sealed record ParkingResult<T>(bool Success, T? Value, string? Error)
{
public static ParkingResult<T> Ok(T value) => new(true, value, null);
public static ParkingResult<T> Fail(string error) => new(false, default, error);
}
public ParkingResult<ParkingTicket> Entry(string licensePlate, VehicleType vehicleType)
{
if (string.IsNullOrWhiteSpace(licensePlate))
return ParkingResult<ParkingTicket>.Fail("License plate is required.");
lock (_lock)
{
// Edge case: already parked?
if (_activeTickets.Values.Any(t => t.LicensePlate == licensePlate))
return ParkingResult<ParkingTicket>.Fail("Vehicle is already parked.");
var requiredSize = SpotSizeMapping.For(vehicleType);
var spot = _spots.Values
.FirstOrDefault(s => !s.IsOccupied && s.Size == requiredSize);
if (spot is null)
return ParkingResult<ParkingTicket>.Fail($"No {requiredSize} spots available.");
var ticket = new ParkingTicket(
TicketId: Guid.NewGuid().ToString("N")[..8].ToUpper(),
LicensePlate: licensePlate,
VehicleType: vehicleType,
SpotId: spot.Id,
EntryTime: DateTimeOffset.UtcNow);
spot.IsOccupied = true;
spot.CurrentTicketId = ticket.TicketId;
_activeTickets[ticket.TicketId] = ticket;
return ParkingResult<ParkingTicket>.Ok(ticket);
}
}
public ParkingResult<ParkingReceipt> Exit(string ticketId)
{
if (string.IsNullOrWhiteSpace(ticketId))
return ParkingResult<ParkingReceipt>.Fail("Ticket ID is required.");
lock (_lock)
{
if (!_activeTickets.TryRemove(ticketId, out var ticket))
return ParkingResult<ParkingReceipt>.Fail("Invalid or already-used ticket.");
if (!_spots.TryGetValue(ticket.SpotId, out var spot))
return ParkingResult<ParkingReceipt>.Fail("Spot not found โ data integrity issue.");
spot.IsOccupied = false;
spot.CurrentTicketId = null;
var exitTime = DateTimeOffset.UtcNow;
var duration = exitTime - ticket.EntryTime;
// Edge case: clock jumped backward
if (duration < TimeSpan.Zero)
duration = TimeSpan.Zero;
var fee = _pricing.Calculate(duration);
return ParkingResult<ParkingReceipt>.Ok(
new ParkingReceipt(ticket, exitTime, duration, fee));
}
}
Growing Diagram โ After Level 5
Class Diagram โ Level 5
Before This Level
You write the happy path and think "I'll handle errors later." (You won't.)
After This Level
You use the What If? framework to systematically discover edge cases BEFORE they discover you in production.
๐ "The Happy Path Only Smell"
When your code handles success but not failure โ apply the What If? framework. Ask all 4 questions (Concurrency, Failure, Boundary, Weird Input) for every operation. The bugs you find now won't wake you at 2 AM.
Skill Unlocked: What If? Framework
For every operation, ask: (1) Concurrency? (2) Failure? (3) Boundary? (4) Weird Input? This 4-question framework discovers 80% of edge cases in any system. Use it in EVERY case study, EVERY interview.
Level 6 ๐ด HARD
Make It Testable
New Constraint: "We need unit tests. The service must be injectable, mockable, and testable in isolation. We're using ASP.NET Core's DI container."
What Breaks?
If ParkingLotService were a static singleton (ParkingLotService.Instance), we couldn't mock it in tests, couldn't inject different pricing strategies, and couldn't run tests in parallel (they'd all share one instance). Static = untestable.
Think First #8
We need exactly ONE ParkingLotService per application โ that sounds like Singleton. But we also need it testable (mockable, injectable). What's the difference between a static Singleton and a DI Singleton? Which do you choose?
60 seconds.
Reveal Answer
DI Singleton. Register ParkingLotService as AddSingleton in the DI container. One instance at runtime, but in tests you can create fresh instances with mock strategies. The DI container controls the lifetime โ your class doesn't know or care that it's a singleton.
Your Internal Monologue
"OK, we need one instance of ParkingLotService. That's Singleton... but which kind?"
"I've seen static readonly Lazy<T> everywhere. Simple. Thread-safe. Done... But wait โ how do I test this? I can't inject a mock pricing strategy. I can't reset the spots between tests. Every test would share the same global instance."
"That's the trap. Static Singleton = easy to write, impossible to test. And we're in ASP.NET Core โ we already HAVE a DI container. AddSingleton gives us one instance at runtime, but in tests I just new up a fresh one with whatever I want..."
"And I should extract an interface โ IParkingLotService. Then controllers depend on the interface, not the concrete class. That's DIPDependency Inversion Principle โ the 'D' in SOLID. High-level modules (controllers) depend on abstractions (interfaces), not concrete implementations. This is what makes DI Singleton testable โ the test injects a real instance, the production code receives it from the container. in action."
What Would You Do?
StaticSingleton.cs
public class ParkingLotService
{
private static readonly Lazy<ParkingLotService> _instance = new(() =>
new ParkingLotService(/* hardcoded config */));
public static ParkingLotService Instance => _instance.Value;
private ParkingLotService() { /* ... */ }
}
// In your controller:
var ticket = ParkingLotService.Instance.Entry("ABC-123", VehicleType.Car);
// In your test:
// ??? How do you reset state? How do you inject a mock pricing strategy?
// You can't. The instance is baked in at process startup.
Consequence: Global mutable state. Tests pollute each other. Can't inject mock strategies. Can't run tests in parallel. Can't swap config between environments.
When IS this fine? Console apps, scripts, tools with no tests and no DI container. If you're not testing and not injecting, static is simpler.
DISingleton.cs
// Interface for the service (enables mocking)
public interface IParkingLotService
{
ParkingResult<ParkingTicket> Entry(string licensePlate, VehicleType vehicleType);
ParkingResult<ParkingReceipt> Exit(string ticketId);
int AvailableSpots(SpotSize size);
}
// Registration in Program.cs
builder.Services.AddSingleton<IPricingStrategy, HourlyPricing>();
builder.Services.AddSingleton<IParkingLotService>(sp =>
{
var pricing = sp.GetRequiredService<IPricingStrategy>();
var spots = Enumerable.Range(1, 100)
.Select(i => new ParkingSpot($"S-{i}", i <= 30 ? SpotSize.Compact
: i <= 80 ? SpotSize.Regular : SpotSize.Large));
return new ParkingLotService(spots, pricing);
});
// In tests โ full control:
var mockPricing = new FlatRatePricing(10.00m);
var testSpots = new[] { new ParkingSpot("T-1", SpotSize.Regular) };
var sut = new ParkingLotService(testSpots, mockPricing);
// Fresh instance. No global state. Tests run in parallel.
Why it wins: One instance at runtime (DI manages lifetime). Fresh instances in tests (constructor injection). Mockable (interface). Configurable (swap HourlyPricing for FlatRatePricing via config). The class doesn't know it's a singleton.
What it costs: Requires a DI container. More setup in Program.cs. Worth it for any service-based application.
Decision Compass:
"Need one instance AND testable? โ DI Singleton. Script with no tests? โ static is fine."
4 Testing Strategies
ParkingLotServiceTests.cs
public class ParkingLotServiceTests
{
private ParkingLotService CreateService(
int compactSpots = 5, int regularSpots = 10, int largeSpots = 3,
IPricingStrategy? pricing = null)
{
var spots = Enumerable.Range(1, compactSpots)
.Select(i => new ParkingSpot($"C-{i}", SpotSize.Compact))
.Concat(Enumerable.Range(1, regularSpots)
.Select(i => new ParkingSpot($"R-{i}", SpotSize.Regular)))
.Concat(Enumerable.Range(1, largeSpots)
.Select(i => new ParkingSpot($"L-{i}", SpotSize.Large)));
return new ParkingLotService(spots, pricing ?? new HourlyPricing());
}
[Fact]
public void Entry_WhenSpotAvailable_ReturnsTicket()
{
var sut = CreateService();
var result = sut.Entry("ABC-123", VehicleType.Car);
Assert.True(result.Success);
Assert.Equal("ABC-123", result.Value!.LicensePlate);
}
[Fact]
public void Entry_WhenLotFull_ReturnsFail()
{
var sut = CreateService(compactSpots: 0, regularSpots: 0, largeSpots: 0);
var result = sut.Entry("ABC-123", VehicleType.Car);
Assert.False(result.Success);
Assert.Contains("No", result.Error);
}
[Fact]
public void Exit_CalculatesCorrectFee_WithMockPricing()
{
var sut = CreateService(pricing: new FlatRatePricing(42.00m));
var entry = sut.Entry("ABC-123", VehicleType.Car);
var exit = sut.Exit(entry.Value!.TicketId);
Assert.Equal(42.00m, exit.Value!.Fee); // exact fee from mock
}
[Fact]
public void ConcurrentEntry_NoRaceCondition()
{
var sut = CreateService(regularSpots: 1);
var results = new ConcurrentBag<ParkingResult<ParkingTicket>>();
Parallel.For(0, 10, i =>
{
results.Add(sut.Entry($"CAR-{i}", VehicleType.Car));
});
Assert.Single(results.Where(r => r.Success)); // exactly 1 wins
Assert.Equal(9, results.Count(r => !r.Success)); // 9 rejected
}
}
Growing Diagram โ After Level 6
Class Diagram โ Level 6
Before This Level
You use static singletons without thinking about testing.
After This Level
You default to DI Singleton for services โ one instance at runtime, fresh instances in tests. And you mention testability in interviews.
๐ "The One Instance, Must Be Testable Smell"
When you need global state that's also mockable โ DI Singleton, not static Singleton. The DI container manages the lifetime. Your class stays unaware.
Level 7 ๐ด HARD
Scale It โ Bridge to HLD
New Constraint: "The company now manages a CHAIN of 50 parking lots across the city. Real-time availability display at each entrance. Central dashboard for management. Analytics for peak hours."
What Breaks?
Our Level 6 is a single parking lot in a single process. 50 lots means 50 instances โ probably 50 servers. Real-time display means events. Central dashboard means aggregation. We've hit the boundary between LLD and HLD (High-Level Design)HLD deals with system architecture at the infrastructure level: how services communicate, where data lives, how to handle load. LLD designs the classes within a single service. A senior engineer sees both levels and knows where one ends and the other begins..
Think First #9
What changes when you go from 1 parking lot to 50? Think about: data storage, real-time communication, and centralized management. What stays in LLD? What becomes HLD?
60 seconds.
Reveal Answer
LLD stays: ParkingLotService, Strategy pattern, thread safety, records โ the code WITHIN each lot doesn't change. HLD adds: Database (persist tickets/receipts), Message queue (entry/exit events โ display updates), API Gateway (route to correct lot), Dashboard service (aggregate analytics). The LLD is one microservice. The HLD is the infrastructure around it.
Your Internal Monologue
"50 lots. OK. My ParkingLotService handles ONE lot. So... 50 instances? That means 50 servers, or at least 50 service instances..."
"But the real question is: what NEW things does the company need? Real-time displays, central dashboard, analytics. Those are all CONSUMERS of events. When a car enters or exits, something needs to KNOW about it."
"Wait โ that's ObserverThe Observer pattern defines a one-to-many dependency. When one object changes state, all its dependents are notified. In our case, when a car enters/exits, the display board, dashboard, and analytics service all need to react. At scale, this becomes event-driven architecture with message queues.. Or at scale, it's event-driven architecture. Same concept, bigger pipe."
"And here's the beautiful part โ because we used interfaces (IParkingLotService, IPricingStrategy), the core LLD code doesn't change AT ALL. We just swap the implementations behind the interfaces. The patterns we chose for testability are the SAME patterns that enable scaling. That's not a coincidence."
What Would You Do?
Polling.cs
// Dashboard polls each lot every 5 seconds
public class DashboardService(HttpClient http, string[] lotUrls)
{
public async Task<List<LotStatus>> GetAllStatusAsync()
{
var tasks = lotUrls.Select(url =>
http.GetFromJsonAsync<LotStatus>(url));
return (await Task.WhenAll(tasks))
.Where(s => s is not null).ToList()!;
}
}
// Display board refreshes by polling:
// while (true) { await Task.Delay(5000); Refresh(); }
Consequence: 50 lots ร every 5 seconds = 600 HTTP requests/minute. Stale data (up to 5s delay). Wasted bandwidth when nothing changed. Dashboard scales linearly with lots.
When IS this fine? Low-update-frequency systems (weather, stock at close). When real-time isn't actually needed. Simplest to implement.
SharedDB.cs
// All 50 lots write to the same database
// Dashboard reads directly from DB
public class DashboardService(ParkingDbContext db)
{
public async Task<List<LotStatus>> GetAllStatusAsync()
=> await db.Lots
.Select(l => new LotStatus(l.Id, l.AvailableSpots))
.ToListAsync();
}
// Display: also reads from same DB
Consequence: Tight coupling โ every service depends on the same DB schema. Schema change = ALL services break. DB becomes a bottleneck. No way to independently deploy or scale lots.
When IS this fine? Monolith with shared database. Small teams. Few lots (under 5). When simplicity beats independence.
EventDriven.cs
// Each lot PUBLISHES events when state changes
public class ParkingLotService(
string lotId, // NEW: identifies this lot in the chain
IEnumerable<ParkingSpot> spots,
IPricingStrategy pricing,
IParkingEventPublisher events) : IParkingLotService
{
public ParkingResult<ParkingTicket> Entry(...)
{
// ... existing logic ...
events.PublishAsync(new VehicleEnteredEvent(
lotId, ticket.TicketId, spot.Size, DateTimeOffset.UtcNow));
return ParkingResult<ParkingTicket>.Ok(ticket);
}
}
// Dashboard SUBSCRIBES to events (via message queue)
// Display boards SUBSCRIBE via SignalR (real-time push)
// Analytics pipeline SUBSCRIBES to same event stream
Why it wins: Lots are decoupled โ they publish and forget. Consumers subscribe independently. Real-time (no polling delay). New consumers (billing, ML model) added without changing lot code. This is how real parking systems work.
What it costs: Message broker infrastructure (RabbitMQAn open-source message broker. Producers send messages to exchanges, which route them to queues. Consumers subscribe to queues. In our system, each parking lot publishes entry/exit events; the dashboard, display boards, and analytics each have their own queue. Alternatives: Azure Service Bus, Amazon SQS, Apache Kafka., Kafka). Eventual consistency (display may lag by milliseconds). More operational complexity.
Decision Compass:
"Need real-time, decoupled consumers? โ Events + push. Few consumers, delay OK? โ polling is simpler."
The Scaling Ladder
Scale
What Changes
Technology
1 lot, 1 server
Nothing โ our Level 6 code works
In-memory, single ASP.NET Core app
1 lot, persistent
Tickets/receipts survive restarts
PostgreSQL or SQL Server, EF CoreEntity Framework Core โ .NET's default ORM. Maps our records (ParkingTicket, ParkingReceipt) to database tables. Our record types map cleanly to EF entities.
Multi-lot, centralized
Each lot is a service. Central API routes.
API Gateway, lot ID in URL, shared DB or DB-per-lot
Real-time display
Entry/exit events update display boards
SignalRA .NET library for real-time web communication. Pushes updates from server to connected clients instantly. Perfect for display boards showing available spots โ each entry/exit event triggers a SignalR message. or WebSockets, event-driven updates
Analytics dashboard
Peak hours, revenue, occupancy trends
Event stream โ analytics pipeline โ dashboard
The key insight: Good LLD makes scaling EASY. Our IPricingStrategy injection means each lot can have different pricing. Our IParkingLotService interface means the API layer doesn't know or care if the service is in-memory or backed by a database. The patterns we chose for testability also give us scalability โ DIDependency Injection. The same mechanism that lets us inject mock strategies in tests lets us inject database-backed strategies in production. Testability and scalability share the same architecture. is the foundation of both.
Events.cs โ HLD Bridge
// Events that bridge LLD โ HLD
public sealed record VehicleEnteredEvent(
string LotId, string TicketId, SpotSize SpotSize, DateTimeOffset Time);
public sealed record VehicleExitedEvent(
string LotId, string TicketId, decimal Fee, DateTimeOffset Time);
// In ParkingLotService โ raise events after entry/exit
public interface IParkingEventPublisher
{
Task PublishAsync<T>(T @event) where T : class;
}
// Implementation could be:
// - In-memory (for single-lot): Channel<T> or IObservable<T>
// - Multi-lot: RabbitMQ, Azure Service Bus, Kafka
// The LLD code doesn't change. Only the publisher implementation.
Growing Diagram โ Complete System
Class Diagram โ Level 7 (Final)
Before This Level
You design LLD in isolation and hope it scales when needed.
After This Level
You see LLD and HLD as a continuum. Good interfaces (Strategy, DI) are the bridge. You mention scaling in every interview.
Constraint Game Complete! In 7 levels, you built a production-grade parking lot system from scratch. You discovered Strategy, mastered thread safety, handled edge cases, made it testable, and bridged to HLD. More importantly โ you learned how to think, not just what to code. These skills transfer to EVERY system you'll ever design.
Section 11
The Full Code โ Production-Grade
Here's the complete system โ every line annotated with which level introduced it. This is the production-gradeCode that handles concurrency (lock), edge cases (Result pattern), timezone safety (DateTimeOffset), immutability (records), and testability (DI). The gap between "works on my machine" and "works in production" is exactly these qualities. code you'd walk through in an interview.
Models.cs
using System.Collections.Frozen;
namespace ParkingLot;
// Level 1: Vehicle types and spot sizes โ enums, not classes
public enum VehicleType { Motorcycle, Car, Van, Truck }
public enum SpotSize { Compact, Regular, Large }
// Level 1: Static mapping โ vehicle type โ required spot size
public static class SpotSizeMapping
{
private static readonly FrozenDictionary<VehicleType, SpotSize> Map =
new Dictionary<VehicleType, SpotSize>
{
[VehicleType.Motorcycle] = SpotSize.Compact,
[VehicleType.Car] = SpotSize.Regular,
[VehicleType.Van] = SpotSize.Large,
[VehicleType.Truck] = SpotSize.Large,
}.ToFrozenDictionary();
public static SpotSize For(VehicleType type) => Map[type];
}
// Level 1 + Level 4: Parking spot with occupancy tracking
public record ParkingSpot(string Id, SpotSize Size)
{
public bool IsOccupied { get; set; } // Level 0
public string? CurrentTicketId { get; set; } // Level 4
}
// Level 4: Immutable entry record
public sealed record ParkingTicket(
string TicketId,
string LicensePlate,
VehicleType VehicleType,
string SpotId,
DateTimeOffset EntryTime);
// Level 4: Immutable exit record
public sealed record ParkingReceipt(
ParkingTicket Ticket,
DateTimeOffset ExitTime,
TimeSpan Duration,
decimal Fee);
// Level 5: Result type for operations that can fail
public sealed record ParkingResult<T>(bool Success, T? Value, string? Error)
{
public static ParkingResult<T> Ok(T value) => new(true, value, null);
public static ParkingResult<T> Fail(string error) => new(false, default, error);
}
// Level 7: Events for HLD bridge
public sealed record VehicleEnteredEvent(
string LotId, string TicketId, SpotSize SpotSize, DateTimeOffset Time);
public sealed record VehicleExitedEvent(
string LotId, string TicketId, decimal Fee, DateTimeOffset Time);
Pricing.cs
namespace ParkingLot;
// Level 2: Strategy pattern โ each pricing type is its own class
public interface IPricingStrategy
{
decimal Calculate(TimeSpan duration);
}
public sealed class HourlyPricing(decimal ratePerHour = 5.00m) : IPricingStrategy
{
public decimal Calculate(TimeSpan duration)
=> (decimal)Math.Ceiling(duration.TotalHours) * ratePerHour;
}
public sealed class FlatRatePricing(decimal dailyRate = 20.00m) : IPricingStrategy
{
public decimal Calculate(TimeSpan duration) => dailyRate;
}
public sealed class TieredPricing(
decimal firstHourRate = 5.00m,
decimal additionalRate = 3.00m) : IPricingStrategy
{
public decimal Calculate(TimeSpan duration)
{
var hours = (decimal)Math.Ceiling(duration.TotalHours);
if (hours <= 0) return 0m;
return hours <= 1
? firstHourRate
: firstHourRate + (hours - 1) * additionalRate;
}
}
// Bonus: Decorator โ wraps any strategy with a weekend multiplier
public sealed class WeekendSurchargePricing(
IPricingStrategy inner,
decimal multiplier = 1.5m) : IPricingStrategy
{
public decimal Calculate(TimeSpan duration)
{
var baseFee = inner.Calculate(duration);
return DateTimeOffset.UtcNow.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday
? baseFee * multiplier
: baseFee;
}
}
ParkingLotService.cs
namespace ParkingLot;
// Level 6: Interface for DI + mocking
public interface IParkingLotService
{
ParkingResult<ParkingTicket> Entry(string licensePlate, VehicleType vehicleType);
ParkingResult<ParkingReceipt> Exit(string ticketId);
int AvailableSpots(SpotSize size);
int TotalCapacity { get; }
}
public sealed class ParkingLotService : IParkingLotService
{
private readonly ConcurrentDictionary<string, ParkingSpot> _spots; // Level 3
private readonly ConcurrentDictionary<string, ParkingTicket> _active = new(); // Level 4
private readonly IPricingStrategy _pricing; // Level 2
private readonly object _lock = new(); // Level 3
public ParkingLotService(IEnumerable<ParkingSpot> spots, IPricingStrategy pricing)
{
_spots = new ConcurrentDictionary<string, ParkingSpot>(
spots.Select(s => KeyValuePair.Create(s.Id, s)));
_pricing = pricing;
}
public int TotalCapacity => _spots.Count;
public int AvailableSpots(SpotSize size)
=> _spots.Values.Count(s => !s.IsOccupied && s.Size == size);
public ParkingResult<ParkingTicket> Entry(string licensePlate, VehicleType vehicleType)
{
// Level 5: Input validation
if (string.IsNullOrWhiteSpace(licensePlate))
return ParkingResult<ParkingTicket>.Fail("License plate is required.");
lock (_lock) // Level 3: Atomic find + occupy
{
// Level 5: Already parked?
if (_active.Values.Any(t => t.LicensePlate == licensePlate))
return ParkingResult<ParkingTicket>.Fail("Vehicle is already parked.");
var requiredSize = SpotSizeMapping.For(vehicleType);
var spot = _spots.Values
.FirstOrDefault(s => !s.IsOccupied && s.Size == requiredSize);
// Level 5: Lot full
if (spot is null)
return ParkingResult<ParkingTicket>.Fail($"No {requiredSize} spots available.");
// Level 4: Issue ticket
var ticket = new ParkingTicket(
TicketId: Guid.NewGuid().ToString("N")[..8].ToUpper(),
LicensePlate: licensePlate,
VehicleType: vehicleType,
SpotId: spot.Id,
EntryTime: DateTimeOffset.UtcNow);
spot.IsOccupied = true;
spot.CurrentTicketId = ticket.TicketId;
_active[ticket.TicketId] = ticket;
return ParkingResult<ParkingTicket>.Ok(ticket);
}
}
public ParkingResult<ParkingReceipt> Exit(string ticketId)
{
if (string.IsNullOrWhiteSpace(ticketId))
return ParkingResult<ParkingReceipt>.Fail("Ticket ID is required.");
lock (_lock)
{
// Level 5: Invalid or already-used ticket
if (!_active.TryRemove(ticketId, out var ticket))
return ParkingResult<ParkingReceipt>.Fail("Invalid or already-used ticket.");
if (!_spots.TryGetValue(ticket.SpotId, out var spot))
return ParkingResult<ParkingReceipt>.Fail("Spot not found โ data integrity issue.");
spot.IsOccupied = false;
spot.CurrentTicketId = null;
var exitTime = DateTimeOffset.UtcNow;
var duration = exitTime - ticket.EntryTime;
if (duration < TimeSpan.Zero) duration = TimeSpan.Zero; // Level 5: Clock safety
var fee = _pricing.Calculate(duration); // Level 2: Delegate to strategy
return ParkingResult<ParkingReceipt>.Ok(
new ParkingReceipt(ticket, exitTime, duration, fee));
}
}
}
Program.cs
using ParkingLot;
var builder = WebApplication.CreateBuilder(args);
// Level 2: Register pricing strategy (swap via config)
builder.Services.AddSingleton<IPricingStrategy>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var type = config["Pricing:Type"] ?? "hourly";
IPricingStrategy basePricing = type switch
{
"hourly" => new HourlyPricing(config.GetValue("Pricing:Rate", 5.00m)),
"flat" => new FlatRatePricing(config.GetValue("Pricing:Rate", 20.00m)),
"tiered" => new TieredPricing(
config.GetValue("Pricing:FirstHour", 5.00m),
config.GetValue("Pricing:Additional", 3.00m)),
_ => new HourlyPricing()
};
// Decorator: wrap with weekend surcharge if enabled
return config.GetValue("Pricing:WeekendSurcharge", false)
? new WeekendSurchargePricing(basePricing)
: basePricing;
});
// Level 6: DI Singleton โ one instance, testable
builder.Services.AddSingleton<IParkingLotService>(sp =>
{
var pricing = sp.GetRequiredService<IPricingStrategy>();
var config = sp.GetRequiredService<IConfiguration>();
var compact = config.GetValue("Spots:Compact", 20);
var regular = config.GetValue("Spots:Regular", 50);
var large = config.GetValue("Spots:Large", 10);
var spots = Enumerable.Range(1, compact)
.Select(i => new ParkingSpot($"C-{i}", SpotSize.Compact))
.Concat(Enumerable.Range(1, regular)
.Select(i => new ParkingSpot($"R-{i}", SpotSize.Regular)))
.Concat(Enumerable.Range(1, large)
.Select(i => new ParkingSpot($"L-{i}", SpotSize.Large)));
return new ParkingLotService(spots, pricing);
});
var app = builder.Build();
// Minimal API endpoints
app.MapPost("/entry", (IParkingLotService lot, EntryRequest req) =>
{
var r = lot.Entry(req.LicensePlate, req.VehicleType);
return r.Success ? Results.Ok(r.Value) : Results.BadRequest(r.Error);
});
app.MapPost("/exit", (IParkingLotService lot, ExitRequest req) =>
{
var r = lot.Exit(req.TicketId);
return r.Success ? Results.Ok(r.Value) : Results.BadRequest(r.Error);
});
app.MapGet("/available/{size}", (IParkingLotService lot, SpotSize size) =>
Results.Ok(new { Size = size, Available = lot.AvailableSpots(size) }));
app.Run();
public record EntryRequest(string LicensePlate, VehicleType VehicleType);
public record ExitRequest(string TicketId);
Section 12
Pattern Spotting โ X-Ray Vision
Let's look at our complete system through "X-ray glasses" โ where does each pattern live, and what would collapse without it? The diagram below shows the same classes you've been building, but with colored overlays marking each pattern's boundary.
Think First #10
We've explicitly used StrategyStrategy pattern: defines a family of algorithms (here: pricing rules), encapsulates each one, and makes them interchangeable. The service doesn't care which strategy it holds โ they all respond to the same Calculate() call. and DI SingletonDependency Injection with Singleton lifetime: the DI container creates ONE instance of ParkingLotService for the app's lifetime, but the dependency is injected โ so tests can swap in a mock without touching production code.. But there are 2 more patterns hiding in our code that we didn't name. Can you spot them? Hint: look at WeekendSurchargePricing and ParkingResult<T>.
60 seconds โ name both patterns before scrolling.
Reveal Answer
DecoratorDecorator pattern: wraps an object in another object with the same interface, adding behavior without modifying the original. You can stack decorators โ WeekendSurcharge wraps HourlyPricing, and tomorrow you could wrap WeekendSurcharge in a HolidayPricing decorator.:WeekendSurchargePricing wraps another IPricingStrategy and multiplies the result on weekends. Same interface, enhanced behavior, original class untouched. That's textbook Decorator โ the key tell is that it holds a reference to the same interface it implements.
Result Pattern (Railway-Oriented)Inspired by functional programming's "railway-oriented programming." Think of it as two tracks: a success track and an error track. Operations either continue on the success track or switch to the error track permanently. No exceptions needed โ the type system forces callers to handle both outcomes.:ParkingResult<T> carries either a value or an error โ operations chain without throwing exceptions. It's not a GoF pattern, but it's the modern .NET way to model expected failures. The difference from exceptions: this is part of the signature, so callers can't ignore it.
Why "Hidden" Patterns Matter in Interviews
Most candidates name patterns they intentionally applied. Strong candidates also name patterns that emerged naturally from good design decisions. When an interviewer asks "what patterns did you use?", spotting the emergent ones โ like Decorator and Result โ signals that you understand why patterns exist, not just what they're called.
The tell for Decorator: does the class implement an interface AND hold a field of the same interface type? If yes โ it's a Decorator. The tell for Result Pattern: does a method return a discriminated unionA discriminated union is a type that can be one of several named cases. In C# you approximate this with records. ParkingResult<T> is either Success(Value) or Failure(Error) โ never both. F# and Rust have these natively as "Result" types. of success/error instead of throwing? If yes โ it's Result Pattern.
Pattern X-Ray โ Colored Overlays Show Pattern Boundaries
Now let's go deeper on each one โ what it actually enables, what collapses without it, and when you'd skip it entirely.
What it enables: New pricing models (premium zone, event surcharge, subscriber discount) slot in as new classes โ no existing code is touched. The service has no idea which strategy it holds, only that it responds to Calculate(hours).
Without it: A switch on an enum inside ParkingLotService. Every new pricing type means modifying the service. That's OCP violation territory โ the class keeps growing, and every change risks breaking existing pricing.
Skip when: There's genuinely only one pricing model and you're certain that won't change. A premature abstractionPremature abstraction: creating an interface or pattern before you have evidence of the variation it handles. If you'll never swap the strategy, you've added indirection with no benefit โ future developers have to follow the interface bouncing to find where the logic actually lives. for a single concrete class adds indirection for zero gain.
DI Singleton
Where:builder.Services.AddSingleton<IParkingLotService>(...) in Program.cs
What it enables: One instance owns all state (the spot list, the ticket dictionary) at runtime, while test code can inject a fresh mock without touching anything. The DI containerA DI (Dependency Injection) container is a framework component that knows how to construct objects and wire up their dependencies. In .NET, builder.Services is the container. When a controller asks for IParkingLotService, the container provides the singleton it already constructed โ no new keyword, no static access. guarantees the lifetime; callers don't manage it.
Without it:static ParkingLotService Instance. Tests share the same instance โ test A parks a car, test B sees it. You can't mock. You can't parallelize. Debugging is a nightmare because there's no clear owner.
Skip when: You're writing a console script or a tiny CLI tool with no test suite. DI containers have bootstrap cost โ not worth it for a 50-line utility. In those cases, pass the dependency as a constructor argument directly.
Decorator
Where:WeekendSurchargePricing holds an _inner: IPricingStrategy and delegates to it, then multiplies โ same interface, extra behavior.
What it enables: You can stack behaviors at composition time. new HolidayPricing(new WeekendSurchargePricing(new HourlyPricing(2.50m))) โ three layers, zero class explosion. Each decorator knows nothing about the others.
Without it: You'd add weekend logic into every pricing class separately โ or create a subclass for every combination (WeekendHourlyPricing, WeekendFlatRatePricing...). That's a combinatorial explosionCombinatorial explosion in inheritance: if you have 3 base pricing strategies and 2 modifiers (weekend, holiday), naive subclassing gives you 3ร2 = 6 classes. Add a third modifier and it's 3ร3 = 9. Decorator keeps it at 3 + 3 = 6 classes regardless of how many modifiers you add. of subclasses.
Skip when: There's only one modifier and it will never be composed with others. Decorator shines specifically when you need to mix and match behaviors independently โ if it's a single fixed rule, just put it in the base class.
Result Pattern
Where:ParkingResult<T> returned by Entry() and Exit() โ carries either Value: T or Error: string, never both.
What it enables: Callers are forced by the type system to check .Success before accessing .Value. The API surface makes "lot is full" and "ticket not found" expected outcomes โ not surprises. Controller code maps them to HTTP 400/200 cleanly with no try/catch.
Without it: Three bad alternatives โ (1) return null and hope callers check, (2) throw exceptions for predictable states like "lot full" (expensive, wrong semanticExceptions are designed for unexpected, unrecoverable situations โ hardware failure, network timeout, programming errors. Using them for "lot is full" is like pulling a fire alarm because the meeting room is booked. The machinery works, but you're abusing it, and it hides the business rule in a catch block.), (3) out parameters that make the API awkward to call. All three make the error path invisible in the method signature.
Skip when: The operation genuinely cannot fail in a way worth modelling (e.g., AvailableSpots() just returns an int). Don't wrap every method โ only the ones where failure is a normal, expected outcome the caller needs to handle differently from success.
Section 13
The Growing Diagram โ Complete Evolution
From 1 class with 3 methods to 17 types across 4 files โ watch the system grow one constraint at a time. Each stage below shows the type countWe count distinct named types: classes, records, interfaces, enums, and static helper classes. Each one exists because a constraint demanded it โ not because a pattern was force-fitted. accumulating. Glowing boxes are new arrivals; dimmed boxes are types already introduced in earlier levels.
System Growth โ L0 through L7 (17 types total)
sealed classenuminterfaceInterfaces have dashed borders in class diagrams โ a UML convention. In our SVG we use stroke-dasharray to follow the same signal: "this is a contract, not an implementation." Glow indicates it's new at this level.sealed recorddecorator / eventPurple signals a structural wrapper (Decorator pattern) or an immutable domain event. Both are types that exist to wrap or capture โ not to hold primary domain state. The color helps you spot them instantly in the diagram.dimmed = existed in earlier level
Here's the complete picture โ every entity, its type, and the level that introduced it.
Entity
Kind
Level
Why This Kind?
VehicleType
enum
L1
Categories without behavior
SpotSize
enum
L1
Categories without behavior
SpotSizeMapping
static class
L1
Pure function, no state, no instance needed
ParkingSpot
record (mutable props)
L1
Identity (Id) is fixed, occupancy changes
IPricingStrategy
interface
L2
Defines the contract for interchangeable algorithms
HourlyPricing
sealed class
L2
Strategy implementation โ has calculation behavior
FlatRatePricing
sealed class
L2
Strategy implementation
TieredPricing
sealed class
L2
Strategy implementation with parameters
WeekendSurchargePricing
sealed class
L2+
DecoratorA Decorator wraps another object of the same interface and adds behavior before or after delegating to the wrapped object. WeekendSurchargePricing implements IPricingStrategy and takes another IPricingStrategy in its constructor โ it's transparent to callers and composable without modifying originals. โ wraps another strategy
ParkingLotService
sealed class
L0โL6
Core service โ has state and behavior
IParkingLotService
interface
L6
DI abstractionBy extracting IParkingLotService in L6, we can inject a mock or fake in unit tests while the real ParkingLotService runs in production. This is the classic Dependency Inversion Principle application: depend on an abstraction, not a concrete type. for testability
ParkingTicket
sealed record
L4
Immutable โ fixed after creation
ParkingReceipt
sealed record
L4
Immutable โ fixed after creation
ParkingResult<T>
sealed record
L5
Immutable result carrier
VehicleEnteredEvent
sealed record
L7
Immutable event โ captures what happened
VehicleExitedEvent
sealed record
L7
Immutable event โ carries exit data
IParkingEventPublisher
interface
L7
Decouples event production from consumption
Think First #12
Look at the table above. Which level added the most new types? And more importantly โ why do you think that level was the tipping point? What constraint made it impossible to keep going with fewer types?
60 seconds โ count the table rows, form a theory, then reveal.
Reveal Answer
Level 2 โ with 5 new types โ was the tipping point. Here's why: L2 introduced the Strategy patternThe Strategy pattern defines a family of algorithms, encapsulates each one behind an interface (IPricingStrategy), and makes them interchangeable. Each concrete strategy (HourlyPricing, FlatRatePricing, TieredPricing) is a separate sealed class โ because each algorithm has its own calculation logic and state (e.g., tier thresholds). for pricing. The moment you need to support multiple interchangeable algorithms, a single method with if/else is no longer viable โ you need:
An interface to define the contract (IPricingStrategy)
A concrete class per algorithm (HourlyPricing, FlatRatePricing, TieredPricing)
A Decorator for cross-cutting concerns (WeekendSurchargePricing)
That's 5 types spawned by a single business requirement: "the lot owner wants to switch pricing models without redeploying." Every other level added at most 2โ3 types. L2 was the design explosion because variability โ not mere complexity โ demands new types.
Notice the progression: Level 0 had 1 class with 3 methods. Level 7 has 17 types across 4 files โ but each one has a clear purpose, and you understand WHY each exists because you felt the constraint that demanded it. That's the difference between memorizing a design and discovering it.
Section 14
Three Bad Solutions โ Learn What NOT to Do
You've seen the good solution. Now let's study three BAD solutions. Each one teaches a different lesson about what goes wrong when design principles are ignored. We'll walk through exactly what happens in each case, why the code breaks, and what the fixed version looks like.
Bad Solution 1: "The God Class"
Imagine a restaurant where one person does everything: takes orders, cooks the food, serves the tables, washes the dishes, manages the cash register, and restocks the fridge. On a quiet Tuesday, it works. On a busy Saturday night, everything falls apart because one person can't context-switch between six jobs without dropping plates.
That's exactly what happens in the "God Class" approach to parking lot design. A single class called ParkingLotSystem handles spot management, ticket tracking, pricing calculations, display updates, receipt printing, and Singleton lifecycle management. At first it feels productive because everything is in one place. But the moment two different features need to change at the same time, the class becomes a minefield.
Here's the real danger: when a junior developer needs to update the pricing formula from hourly to tiered, they open a 400-line file. They scroll past spot management, past entry tracking, past display logic, and finally find the pricing code buried inside the Exit() method. They change the pricing formula but accidentally modify a variable that spot tracking also depends on. The bug doesn't show up in their quick test because they only tested with one car. Six months later, a customer is charged for 47 hours because the entry time got corrupted.
The root cause isn't bad developers. It's bad architecture. When everything lives in one class, every change is a risk because you can't modify pricing without potentially breaking parking, display, or receipts.
Walking through the buggy code: Start at the top. The class stores spots as a flat Dictionary<string, string> — no types, no structure, just string keys and string values. Vehicle types are raw strings ("motorcycle", "car", "truck"), which means a typo like "motrcycle" compiles just fine but silently does nothing at runtime. The Park() method does spot searching, spot assignment, entry time tracking, AND display updating all in one method. The Exit() method does spot lookup by plate (a full dictionary scan), spot release, time calculation, pricing with hardcoded if/else chains, AND display/receipt printing. Every line of this class has a different reason to change, and every change risks breaking something unrelated.
CleanSeparation.cs — Each Class Has One Job
// Each class does ONE thing well
public enum VehicleType { Motorcycle, Car, Van, Truck }
public interface IPricingStrategy
{
decimal Calculate(TimeSpan duration);
}
public sealed class HourlyPricing(decimal ratePerHour) : IPricingStrategy
{
public decimal Calculate(TimeSpan duration)
=> (decimal)Math.Ceiling(duration.TotalHours) * ratePerHour;
}
public sealed class ParkingLot // ONLY orchestration
{
private readonly ConcurrentDictionary<string, ParkingSpot> _spots;
private readonly IPricingStrategy _pricing;
private readonly TimeProvider _clock;
public ParkingLot(IEnumerable<ParkingSpot> spots,
IPricingStrategy pricing, TimeProvider clock) { ... }
public ParkingTicket? TryPark(string plate, VehicleType type) { ... }
public ParkingReceipt? TryExit(string ticketId) { ... }
}
// Registered in DI:
// builder.Services.AddSingleton<IPricingStrategy, HourlyPricing>();
// builder.Services.AddSingleton<ParkingLot>();
Why the fix works: Each class now has exactly one reason to change. Need different pricing? Swap IPricingStrategy — zero changes to ParkingLot. Need to update the display board? That's a separate DisplayBoard class that subscribes to parking events. Vehicle types are an enum, so the compiler catches typos. The ParkingLot class does only orchestration — it connects pieces together but doesn't contain business logic for pricing, display, or receipts. This is the Single Responsibility Principle in action: one class, one reason to change.
The incident: Month 6. The class is 800 lines. A junior dev needs to change pricing. They modify the Exit() method, accidentally break spot tracking. Nobody notices until a customer reports being charged for 47 hours. The class is too big for anyone to review safely.
How to Spot This in Your Code: If you can describe a class using the word "and" more than once ("it manages spots AND calculates pricing AND updates the display AND prints receipts"), it's a God Class. The test: can you name the class without using "Manager" or "System"? If not, split it.
Lesson:SRPSingle Responsibility Principle — a class should have only one reason to change. The God Class has 6 reasons to change: spot management, pricing, display, receipts, admin, and reporting. Each change risks breaking everything else.. One class, one reason to change. Parking logic, pricing, display, and receipts are four different reasons to change — so they belong in four different classes.
Bad Solution 2: "The Over-Engineer"
Picture this: you need to hang a picture frame. You go to the hardware store and buy a drill, a stud finder, a laser level, a drywall anchor set, a bracket system, and a motorized picture-hanging robot. All you needed was a hammer and one nail. But the hardware store salesperson (who works on commission) convinced you that you might need all that stuff "someday."
The Over-Engineer does the same thing with design patterns. They've read about all 23 Gang of Four patterns, and they're determined to use every single one. Factory for creating vehicles. Factory for creating factories. Strategy for selecting strategies. Observer for notifying the observer-notification-service. Every feature gets wrapped in three layers of abstraction "just in case" a requirement changes that hasn't been asked for.
The problem isn't that patterns are bad — patterns are excellent tools. The problem is using them without a specific problem to solve. When a new developer opens this codebase and asks "how do I park a car?", the answer is a 20-step journey through IVehicleFactory to IParkingSpotAssignmentStrategyFactoryProvider to ITicketGenerationCommandHandler. The parking lot has become an enterprise architecture demo, not a working system.
The real cost shows up in onboarding time. A new hire who should be productive in a week takes a month to understand how the pieces connect. And when they finally need to add a simple feature like "motorcycle can park in car spots if no motorcycle spots are available," they discover they need to modify six factories, three strategies, and two mediators. The system is so abstract that concrete changes are nearly impossible.
OverEngineered.cs — Pattern for Every Occasion
// 15 interfaces, 23 classes, 4 abstract factories
public interface IVehicle { }
public interface IVehicleFactory { IVehicle Create(string plate); }
public interface IParkingSpotFactory { IParkingSpot Create(string id); }
public interface IParkingSpotAssignmentStrategyFactoryProvider
{
ISpotAssignmentStrategy GetStrategy(IVehicle vehicle);
}
public interface IAbstractPricingStrategyFactoryMediator
{
IPricingStrategy ResolvePricingStrategy(
IPricingContext context,
IPricingStrategyFactoryConfig config);
}
public interface ITicketGenerationCommandHandler
{
Task<ITicket> HandleAsync(GenerateTicketCommand command,
CancellationToken ct = default);
}
public interface IEntryGateEventObserverNotificationService { ... }
public interface IExitGateReceiptBuilderDirector { ... }
// ... 200 more lines of interfaces and abstractions
// ... for a system that parks cars
Walking through the buggy code: Count the interfaces. There's a factory for creating vehicles, a factory for creating parking spots, a factory-provider for selecting which assignment strategy to use, a factory-mediator for resolving pricing strategies, a command handler for generating tickets, and an observer-notification-service for gate events. Each interface has at least one implementation class. Each implementation probably injects three other interfaces. To trace "what happens when a car enters," you'd need to follow: IVehicleFactory creates the vehicle, IParkingSpotAssignmentStrategyFactoryProvider picks a strategy, the strategy calls IParkingSpotFactory, then ITicketGenerationCommandHandler creates the ticket, and finally IEntryGateEventObserverNotificationService sends notifications. That's six indirections for "park a car."
RightSized.cs — Only Patterns That Solve Real Problems
// Strategy for pricing — because pricing rules ACTUALLY change independently
public interface IPricingStrategy
{
decimal Calculate(TimeSpan duration);
}
// The parking lot itself — clear, direct, readable
public sealed class ParkingLot
{
private readonly ConcurrentDictionary<string, ParkingSpot> _spots;
private readonly IPricingStrategy _pricing;
private readonly Lock _lock = new();
public ParkingTicket? TryPark(string plate, VehicleType type)
{
lock (_lock)
{
var size = SpotSizeMapping.For(type);
var spot = _spots.Values.FirstOrDefault(s => s.IsAvailable && s.Size == size);
if (spot is null) return null;
spot.Assign();
return new ParkingTicket(Guid.NewGuid().ToString(), plate, type,
spot.Id, DateTimeOffset.UtcNow);
}
}
}
// Total: 3 interfaces, 5 classes. Done.
// "Where does parking happen?" → ParkingLot.TryPark(), line 14.
Why the fix works: The right-sized solution uses patterns only where there's a real problem. IPricingStrategy exists because pricing rules genuinely change independently (hourly today, tiered next month). But vehicles don't need a factory because an enum and a record are plenty. Tickets don't need a command handler because new ParkingTicket(...) is perfectly clear. The result: 8 files instead of 47, a new developer can trace "park a car" in one method call, and adding a feature means changing 1 file instead of 6.
The incident: A new hire opens the repo. Counts 47 files for a parking lot. Asks "how do I add a new vehicle type?" The answer involves 6 factories, 3 strategies, and 2 mediators. They update their LinkedIn that afternoon.
How to Spot This in Your Code: If the interface name is longer than the sentence describing what it does, you've over-engineered. The YAGNI test: can you name a concrete scenario where this abstraction is needed TODAY? If you're saying "we might need it someday," remove it. You can always add abstraction later; removing it is much harder.
Lesson:YAGNIYou Aren't Gonna Need It. Don't add abstraction for hypothetical future requirements. If you don't have the problem NOW, don't add the pattern NOW. Patterns solve problems. No problem → no pattern.. Patterns solve PROBLEMS. No problem, no pattern. Our system needs 2 Strategy interfaces + 1 DI Singleton + 1 Decorator. Not 15 interfaces and 23 classes.
Bad Solution 3: "The Happy-Path Hero"
This one is the most dangerous of the three, because it looks perfect. Imagine a bridge that was beautifully designed, passed every visual inspection, and looked gorgeous in photos. But the engineer never tested what happens when two heavy trucks cross at the same time, or when a storm hits, or when the temperature drops below freezing. On a sunny day with light traffic, the bridge works flawlessly. The first real stress test? It collapses.
Bad Solution 3 is that bridge. The code is clean. The naming is professional. It uses the Strategy pattern correctly. Records are immutable. It would pass most code reviews. But it was only tested with one car, one gate, and no edge cases. The developer never asked "what if two cars arrive at the same time?" or "what if a customer loses their ticket?" or "what if the server is in a different timezone?"
Here's what makes it so dangerous: Bad Solutions 1 and 2 are obviously bad. Any experienced developer spots them in code review. They would never make it to production. But Solution 3 looks professional. It passes code review. It works in development. It works in staging. It only explodes when real-world conditions meet your code: Monday morning rush hour, three entry gates, customers who lose tickets, and servers whose clocks disagree.
The lesson isn't "write ugly code." The lesson is that clean code is necessary but not sufficient. Professional-looking code that breaks under concurrency, edge cases, and real-world time is worse than ugly code that handles them — because the ugly code will get fixed before production. The pretty code won't.
HappyPathHero.cs — Looks Great. Isn't.
public sealed class ParkingLotService(
List<ParkingSpot> spots, // ← not ConcurrentDictionary
IPricingStrategy pricing)
{
private readonly Dictionary<string, ParkingTicket> _tickets = new(); // ← not Concurrent
public ParkingTicket Park(string plate, VehicleType type)
{
var size = SpotSizeMapping.For(type);
var spot = spots.First(s => !s.IsOccupied && s.Size == size); // ← no null check
spot.IsOccupied = true;
// ← no lock around find + occupy (TOCTOU race condition)
var ticket = new ParkingTicket(Guid.NewGuid().ToString(), plate,
type, spot.Id, DateTime.Now); // ← DateTime.Now, not DateTimeOffset.UtcNow
_tickets[ticket.TicketId] = ticket;
return ticket;
}
public ParkingReceipt Exit(string ticketId)
{
var ticket = _tickets[ticketId]; // ← KeyNotFoundException if invalid
_tickets.Remove(ticketId); // ← not thread-safe
var spot = spots.First(s => s.Id == ticket.SpotId);
spot.IsOccupied = false;
var duration = DateTime.Now - ticket.EntryTime;
return new ParkingReceipt(ticket, DateTime.Now, duration,
pricing.Calculate(duration));
}
}
Walking through the buggy code: The code reads cleanly. Good class name, constructor injection, Strategy pattern for pricing. But follow it under stress. Two threads call Park() at the same time. Both execute spots.First(s => !s.IsOccupied ...) and both find the same spot available. Both set IsOccupied = true. Both create tickets pointing to the same spot. That's a double booking. In Exit(), if someone passes an invalid ticket ID, _tickets[ticketId] throws a KeyNotFoundException that crashes the whole service. And DateTime.Now means that during a Daylight Saving Time change, a 1-hour parking session might be billed as 2 hours (or 0 hours).
ProductionReady.cs — Same Clean Code, Now Robust
public sealed class ParkingLotService(
ConcurrentDictionary<string, ParkingSpot> spots, // thread-safe collection
IPricingStrategy pricing,
TimeProvider clock) // injectable clock
{
private readonly ConcurrentDictionary<string, ParkingTicket> _tickets = new();
private readonly Lock _lock = new();
public ParkingTicket? TryPark(string plate, VehicleType type)
{
lock (_lock) // find + assign is ATOMIC
{
var size = SpotSizeMapping.For(type);
var spot = spots.Values
.FirstOrDefault(s => s.IsAvailable && s.Size == size);
if (spot is null) return null; // graceful "lot full"
spot.Assign();
var ticket = new ParkingTicket(Guid.NewGuid().ToString(), plate,
type, spot.Id, clock.GetUtcNow()); // UTC, no DST bugs
_tickets[ticket.TicketId] = ticket;
return ticket;
}
}
public (bool Success, ParkingReceipt? Receipt, string Error) TryExit(string ticketId)
{
if (!_tickets.TryRemove(ticketId, out var ticket))
return (false, null, "Ticket not found"); // no crash
var spot = spots[ticket.SpotId];
spot.Release();
var duration = clock.GetUtcNow() - ticket.EntryTime;
var receipt = new ParkingReceipt(ticket, clock.GetUtcNow(),
duration, pricing.Calculate(duration));
return (true, receipt, string.Empty);
}
}
Why the fix works: The fix addresses every hidden bug while keeping the clean code style. The lock around find-and-assign makes spot allocation atomic — no more double bookings. FirstOrDefault plus a null check means a full lot returns null instead of crashing. TryRemove handles invalid ticket IDs gracefully with a tuple result. And TimeProvider (the .NET 8 abstraction for time) gives us UTC timestamps that survive DST transitions, plus it's injectable so tests can freeze or advance time. Same clean architecture, now production-ready.
The incident: First week in production. Monday morning rush. 3 entry gates. Two cars get assigned the same spot (race condition). The display shows -3 available spots. A customer with a lost ticket crashes the system with KeyNotFoundException. The developer says "but it worked in testing!" Yes — with one gate and no edge cases.
How to Spot This in Your Code: For every method, ask three questions: (1) What happens if two threads call this at the same time? (2) What happens if the input doesn't exist? (3) What happens if this runs in a different timezone? If you can't answer all three, you've got a Happy-Path Hero.
Lesson: Clean code is necessary but not sufficient. Good patterns don't excuse missing thread safety, edge cases, and proper error handling. This solution is the most dangerous of the three — because it looks professional and passes code review. The wolf in sheep's clothing.
Think First #11
Which bad solution is the most dangerous? Why?
30 seconds.
Reveal Answer
Bad Solution 3 โ The Happy-Path Hero. Solutions 1 and 2 are obviously bad โ any senior dev spots them in code review. Solution 3 looks good. It passes review. It works in dev. It works in staging. It explodes in production when real concurrency, real edge cases, and real timezones meet your code. The wolf in sheep's clothing.
Section 15
Code Review Challenge โ Find 5 Bugs
A candidate submitted this parking lot implementation. It compiles. It runs. It passes basic tests. But there are exactly 5 bugs hiding in it. Can you find them all?
CandidateSolution.cs โ Find 5 Bugs
public class ParkingLotService
{
private static ParkingLotService? _instance; // Line 3
public static ParkingLotService Instance => _instance ??= new();// Line 4
private readonly Dictionary<string, ParkingSpot> _spots = new();// Line 6
private readonly Dictionary<string, ParkingTicket> _tickets = new();
public ParkingTicket? Entry(string plate, VehicleType type)
{
var size = SpotSizeMapping.For(type);
var spot = _spots.Values
.FirstOrDefault(s => !s.IsOccupied && s.Size == size); // Line 12
if (spot is null) return null;
spot.IsOccupied = true; // Line 15
var ticket = new ParkingTicket(
Guid.NewGuid().ToString(), plate, type, spot.Id,
DateTime.Now); // Line 18
_tickets[ticket.TicketId] = ticket;
return ticket;
}
public decimal CalculateFee(string ticketId)
{
var ticket = _tickets[ticketId]; // Line 24
var hours = (DateTime.Now - ticket.EntryTime).TotalHours;
return hours switch
{
<= 1 => 5.00m,
<= 4 => 5.00m + ((decimal)hours - 1) * 3.00m,
_ => 20.00m
}; // Line 31
}
}
Found them? Reveal one at a time:
Bug #1 โ Thread Safety (Line 6)
Problem:Dictionary<string, ParkingSpot> is not thread-safeThread safety means an object can be used from multiple threads without corrupting its internal state. Regular Dictionary can throw or corrupt data when modified concurrently. ConcurrentDictionary uses fine-grained locking internally to prevent this.. Multiple gates reading/writing simultaneously = data corruption.
Fix:ConcurrentDictionary<string, ParkingSpot>
Taught in: Level 3 โ Multiple Gates
Bug #2 โ Race Condition (Lines 12-15)
Problem: Find spot (line 12) and mark occupied (line 15) are two separate steps with no lock. Two threads can find the same spot.
Fix:lock(_lock) { ... } around the compound find-and-occupy operation.
Problem:DateTime.Now uses the server's local timezone. Enter at 1:50 AM, DSTDaylight Saving Time โ clocks jump forward (spring) or backward (fall) by one hour. Code using DateTime.Now can calculate negative durations or charge for phantom hours during these transitions. DateTimeOffset.UtcNow is immune to this. springs forward, exit at 3:10 AM โ system calculates 1 hour 20 minutes, but only 20 minutes passed in real time.
Fix:DateTimeOffset.UtcNow โ always UTC, no timezone surprises.
Taught in: Level 4 โ Tickets & Data Flow
Bug #4 โ OCP Violation (Lines 27-31)
Problem: Pricing logic hardcoded in a switch expression. Adding "weekend pricing" = modifying this method. Violates Open/Closed PrincipleThe 'O' in SOLID: code should be Open for extension (add new behavior) but Closed for modification (don't change existing code). A switch/case for pricing FORCES modification. Strategy pattern enables extension via new classes..
Fix: Extract to IPricingStrategy โ inject via constructor. Each pricing type is its own class.
Taught in: Level 2 โ Strategy Pattern
Bug #5 โ Static Singleton (Lines 3-4)
Problem: Static Singleton = global mutable state. Can't mock in tests. Can't inject different strategies. Tests share state and pollute each other.
Fix: Remove static Singleton. Register as AddSingleton<IParkingLotService>ASP.NET Core's DI container lifetime: AddSingleton = one instance for the app's lifetime. AddScoped = one per HTTP request. AddTransient = new instance every time. For our parking lot service, Singleton ensures one shared instance with one set of spots. in DI container.
Taught in: Level 6 โ Testability
Score Yourself: All 5: Senior-level thinking. You see code AND its consequences. ๐ 3-4: Solid mid-level. Review the levels you missed. 1-2: Go back to Levels 2, 3, and 5. The patterns will click on second read.
Section 16
The Interview โ Both Sides of the Table
For the first time, you get to see what the interviewer is thinking while the candidate talks. Two runs: the clean one, and the realistic one (with stumbles and recovery).
Time
๐งโ๐ป Candidate Says
๐ Interviewer Thinks
0:00
"Before I code, let me clarify scope. Single lot or chain? How many spots? Multiple entry gates? Do we need persistence?"
โ Great โ scoping first. Shows system thinking.
2:00
"Functional requirementsWhat the system DOES โ the features. Entry, exit, calculate fee, assign spot. These are the operations visible to users.: Entry, exit, fee calculation, spot assignment. Non-functionalHow the system PERFORMS โ the qualities. Thread safety, extensibility, testability, performance. These aren't features but constraints that shape the architecture.: thread safety for concurrent gates, extensible pricing."
"Entities from the nounsThe Noun Technique: highlight every noun in the requirements document. Each noun is a potential entity/class in your design. "Parking lot has spots for vehicles with tickets" โ ParkingLot, ParkingSpot, Vehicle, ParkingTicket.: ParkingLot, ParkingSpot, Vehicle, Ticket. VehicleType and SpotSize are enums โ categories without behavior."
โ Systematic noun extraction. Enum reasoning is sharp.
โ Pattern fits naturally. Explained WHY, not just WHAT.
8:00
"Thread safety: lock around the compound find-and-occupy operationA compound operation is two or more steps that must execute as one atomic unit โ no other thread can run between them. "Find a free spot, then mark it occupied" is compound. If a second thread slips in between the find and the mark, both threads claim the same spot. A lock prevents that.. ConcurrentDictionary for individual lookups."
Watching for: C# idiomsModern C# conventions that signal expertise: primary constructors, sealed classes, records for immutable data, expression-bodied members, pattern matching, file-scoped namespaces. Using these shows you write current C#, not C# from 2010., naming, sealed classes, records for DTOs
22:00
"Edge cases: lot full returns Result type, invalid ticket handled gracefully, DateTimeOffset.UtcNow for timezone safety."
โ Proactive edge cases. Most candidates miss DateTime.
26:00
"For scaling: DI Singleton in ASP.NET, events for real-time display, database for persistence if multi-lot." Uses CREATES structureCREATES is an interview framework: Clarify โ Requirements โ Entities โ Algorithms โ Thread-safety โ Edge cases โ Scale. The candidate naturally walked through all 6 steps in this clean run, which is why the flow felt so effortless. end-to-end.
โ LLDโHLD bridgeBridging LLD to HLD means showing how the low-level design fits into a larger distributed system. Going from "ParkingLotService class" to "DI Singleton behind an ASP.NET controller, publishing events to a SignalR hub" shows architectural range โ exactly what senior roles require.. Shows breadth. Strong Hire signalStrong Hire is the top outcome in a structured interview loop. It means the candidate demonstrated not just correctness but range: structured thinking, code quality, edge case awareness, and the ability to zoom out to system scale. One Strong Hire can carry an otherwise split loop..
Time
๐งโ๐ป Candidate Says
๐ Interviewer Thinks
0:00
"So... a parking lot. Let me think about what we need..."
๐ถ Slow start. Not structured yet. Let's see.
1:30
"Actually, let me first ask โ single lot or multi-lot? Do we handle payments?"
โ Good recovery. Clarifying questions show awareness.
4:00
"I'll start with a Vehicle base class... wait, actually, do vehicles have different behavior? No โ they're just categories. Enums."
โ Self-corrected! This is BETTER than getting it right instantly โ shows thinking process.
7:00
"For pricing, I could use a switch... but that means modifying the method for every new type. Let me use an interface instead."
โ Explored the wrong path, caught it, corrected. That's how engineers actually think.
12:00
Coding... pauses... "Let me think about this for a second..."
๐ถ Pausing is fine. Silence > rushing into wrong code.
15:00
Interviewer: "What about multiple gates entering simultaneously?"
Testing if candidate thinks about concurrency.
15:30
"Oh โ that's a race condition. Find-and-occupy needs to be atomic. I'll add a lock."
โ Needed a nudge but responded immediately. Good recovery.
24:00
"I forgot to handle the lot-full case... let me add a result type."
โ Caught their own omission. Self-review is a strong signal.
28:00
"For scaling, I'd move to DI Singleton, add events for real-time display... I haven't worked with SignalR specifically, but the pattern would be publish events on entry/exit."
โ Honest about gaps. Shows how they'd approach the unknown. Strong Hire.
Key lesson: The realistic run has stumbles, pauses, and corrections โ and STILL gets a Strong Hire. Interviewers don't expect perfection. They expect good thinking, self-correction, and honesty about gaps.
Scoring Summary
The Clean Run
Strong HireStrong Hire is the top outcome in a structured interview loop. It means the candidate demonstrated not just correctness but range: structured thinking, code quality, edge case awareness, and the ability to zoom out to system scale.
Structured from the very first sentence
F/NF split stated before any code
Proactive edge cases โ not prompted
LLDโHLD bridge at the end
Zero prompting needed
The Realistic Run
Strong HireStrong Hire is the top outcome in a structured interview loop. It means the candidate demonstrated not just correctness but range: structured thinking, code quality, edge case awareness, and the ability to zoom out to system scale.
Slow start โ recovered with clarifying questions
Self-corrected enum vs. class decision
Needed a concurrency nudge โ responded immediately
Caught own omission on lot-full case
Honest about unknown (SignalR) + showed how to approach it
Key Takeaway: Two very different styles. Same outcome.
Interviewers don't grade on polish โ they grade on THINKINGWhat interviewers actually evaluate: Do you ask the right clarifying questions? Do you reason through trade-offs rather than reciting facts? Do you notice your own mistakes and correct them? Do you stay structured when under pressure? Polish is nice; thinking is what gets you hired..
A stumble you recover from is often more impressive than a flawless run โ because it shows how you handle real-world ambiguity.
Section 17
Articulation Guide — What to SAY
Here’s something most candidates get wrong: they treat articulation as a side-effect of knowing the design. It isn’t. Design skill and communication skill are separate muscles — you can have a brilliant design completely locked in your head and still tank an interview because you can’t narrate it in real time. Both muscles need deliberate practice. The 8 cards below cover the exact situations where phrasing matters most. For each one: the situation that triggers it, what to say, what kills your credibility, and why the good version works.
1. Opening the Problem
Situation: The interviewer says “Design a parking lot system.” There’s silence. You feel pressure to start typing.
Say: “Before I start, let me understand the scope. Are we designing a single lot or multi-lot? Multiple gates? Do we need persistence across restarts, or is in-memory fine?”
Don’t say: “OK so I’ll start with a Vehicle class...” (jumping to code without clarifying)
Why it works: Shows system thinking before solution thinking. Interviewers actively reward clarifying questions — they’re checking if you’ll build the right thing, not just a thing.
2. Entity Decisions
Situation: You’re deciding how to model VehicleType and ParkingTicket. The interviewer watches silently.
Say: “VehicleType is an enum because it has no behavior — it’s a pure category. ParkingTicket is a record because it’s immutable after creation. The type choice reflects the F/NF splitFunctional vs Non-Functional split: modeling data that does things (classes with behavior) vs data that just is things (records, enums, value objects). Getting this split right keeps your model clean and avoids bloated classes. in my mental model.”
Don’t say: “I’ll make a Vehicle class.” (no reasoning behind the type choice)
Why it works: Shows you choose types deliberately — enum vs class vs record is a real decision, not a default.
3. Pattern Choice
Situation: You’re about to introduce the Strategy pattern for pricing. The interviewer could ask “why not a switch statement?”
Say: “Pricing varies independently from the rest of the system — new pricing rules shouldn’t require touching ParkingLot. So I’ll use Strategy: each pricing type implements IPricingStrategy, and ParkingLot holds a reference. Swapping strategies at runtime costs zero lines of change here.”
Don’t say: “I’ll use Strategy because it’s a common pattern.” (pattern name without motivation)
Why it works: The pattern emerged from the problem, not from memorization. Interviewers can tell the difference immediately.
4. Defending Trade-offs
Situation: The interviewer pushes back: “Isn’t Strategy overkill here? You’re adding extra files and interfaces.”
Say: “Fair point — the cost is more files. The gain is zero modification to ParkingLot for new pricing types. If pricing is stable, a switch is fine. But parking lots routinely add surge pricing, subscriptions, validation discounts — so the gain wins here.”
Don’t say: “Strategy is the right pattern here.” (asserts conclusion without arguing it)
Why it works: Shows engineering judgment — you weigh actual costs against actual gains, not pattern prestige.
5. Concurrency
Situation: The interviewer asks “What happens when two cars arrive at the same time at different gates?”
Say: “Multiple gates mean concurrent entry. Find-and-occupy is a compound operationA compound operation is two or more steps that must happen together atomically. Here: (1) find a free spot, (2) mark it occupied. If another thread slips in between steps 1 and 2, both threads grab the same spot. A lock makes the pair indivisible. — two steps that must be atomic. ConcurrentDictionary makes each step thread-safe, but not the pair together. I’ll wrap find-and-occupy in a lock block.”
Don’t say: “I’ll use ConcurrentDictionary for everything.” (misses the real race condition)
Why it works: Shows you know WHERE the real danger lives — the compound operation boundary, not individual data structures.
6. Edge Cases
Situation: You’ve finished the happy path. The interviewer hasn’t asked about edge cases yet. Most candidates stop here.
Say: “Let me think about what could go wrong before we move on. Lot full when someone tries to enter. Invalid or already-used ticket on exit. Payment failure mid-checkout. Clock jumps — someone parks midnight to 1 AM on a day-rate system. Each of these needs an explicit decision.”
Don’t say:(nothing — most candidates never mention edge cases unprompted)
Why it works: Proactive edge-case thinking is a Strong Hire signal. It shows production-readiness, not just whiteboard-readiness.
7. Scaling Bridge
Situation: The interviewer asks “How would this change if we needed to support 500 lots across a city?”
Say: “This design works cleanly for a single lot in-process. Scaling to 500 lots means: a shared database for spot state and ticket persistence, an event bus so real-time displays don’t poll, and an API gateway to route clients to the right lot service. The core domain model stays the same — we just move where state lives.”
Don’t say: “We’d just add a database.” (no structure, no reasoning about what changes vs what stays)
Why it works: Shows you see both LLD and HLD, and can bridge between them. Breadth signals senior-level thinking.
8. “I Don’t Know”
Situation: The interviewer asks “How would you push real-time availability updates to a display board?” You’ve never used SignalRSignalR is a .NET library for real-time web communication. It lets a server push updates to connected clients (WebSockets under the hood, with fallbacks). Common for dashboards, live feeds, and availability displays — exactly the parking lot display-board scenario..
Say: “I haven’t worked with SignalR specifically, but the approach is clear: publish an event every time a spot changes state, and have the display subscribe to those events. Whether that’s SignalR, WebSockets, or SSE is an infrastructure choice — the publish-subscribe shape stays the same.”
Don’t say: “I don’t know SignalR.” (full stop) — or worse, bluffing through a fake answer
Why it works: Honesty about a specific tool + clear reasoning about the underlying approach = respect. Bluffing or shutting down = red flag.
Pro Tip — Practice OUT LOUD, not just in your head
Reading these phrases silently builds passive knowledge — recognition. Saying them aloud builds muscle memory — production. There’s a real difference: passive knowledge fails under interview pressure because retrieval competes with anxiety. Muscle memory is automatic. Try this: close this page, set a 5-minute timer, and explain this parking lot design to an imaginary interviewer out loud. Record yourself if you can. You’ll immediately hear where you hesitate, where you go vague, and where you suddenly sound like you know exactly what you’re talking about. Five minutes of that beats an hour of re-reading.
Target these three phrases for fluency first: the DI SingletonDependency Injection Singleton: registering a service so the DI container creates exactly one instance and shares it across all consumers. In .NET: services.AddSingleton<IParkingLot, ParkingLot>(). Critical for stateful services like ParkingLot — two instances would mean split state, each thinking different spots are free. registration, the compound operationTwo steps that must execute atomically: find a free spot AND mark it occupied. A lock wraps both steps so no other thread can slip in between them. lock explanation, and the F/NF split reasoning. These are the exact three spots where candidates most often go blank or go vague under pressure.
Section 18
Interview Questions & Answers
10 questions ranked by difficulty. Each has a "Think" prompt, solid answer, and the great answer that gets "Strong Hire."
Q1: How would you add a new vehicle type (e.g., Electric Vehicle with charging)?
Easy
Think: Does this need a new enum value, a new class, or both? Does charging introduce new BEHAVIOR?
Think about how a real parking lot adds EV charging spots. They don't redesign the entire lot — they paint new markings on a few existing spots and install chargers. The structure of the lot stays the same; they just add a new category of spot. Your code should work the same way.
Answer: Add ElectricVehicle to the VehicleType enum, add its mapping in SpotSizeMappingA static class with a FrozenDictionary mapping VehicleType → SpotSize. Adding a new vehicle type is one new dictionary entry — no if/else chains. FrozenDictionary (new in .NET 8) is optimized for read-heavy scenarios with zero writes.. Since EVs need charging spots, add Charging to SpotSize. That's two enum values and one dictionary entry — zero changes to existing code.
Great Answer: "If charging is just a spot type — enum values. But if EVs have BEHAVIOR differences (charging session management, billing for electricity), that's when VehicleType might graduate from an enum to a class hierarchy. Right now, I'd keep it simple with enums and only refactor if charging introduces actual behavior. YAGNI."
Adding EV Support — 3 Lines of Change
// Step 1: Add to enum
public enum VehicleType { Motorcycle, Car, Van, Truck, ElectricVehicle }
public enum SpotSize { Compact, Regular, Large, Charging }
// Step 2: Add one mapping entry
SpotSizeMapping: { VehicleType.ElectricVehicle, SpotSize.Charging }
// That's it. ParkingLot, IPricingStrategy, Floor -- zero changes.
What to SAY: "Adding a type is one enum value + one dictionary entry. If it needs behavior, I'd refactor then — not preemptively."
Q2: How do you ensure thread safety for spot assignment?
Medium
Think: What's the actual dangerous operation? It's not reading — it's the compound read-then-write.
Think of it like a bathroom with a lock. If two people check "is the bathroom free?" at the same time, both see "free," and both walk in. The problem isn't the checking or the walking — it's the gap between checking and walking. A lock removes the gap by making "check + enter" a single atomic action.
Answer: Use a lock around the find-and-occupy operation. ConcurrentDictionary for individual lookups.
Great Answer: "ConcurrentDictionary alone isn't enough — it handles individual operations but not compound ones. The find-a-free-spot-then-mark-it-occupied is a two-step TOCTOUTime-Of-Check to Time-Of-Use — a race condition where the state changes between checking a condition and acting on it. Thread A checks "spot available?" (yes) → Thread B grabs the spot → Thread A assigns the now-taken spot. The lock makes check+assign atomic. problem. I use lock to make it atomic. The lock scope is minimal — just the compound operation. Pure calculations like fee calculation don't need locking."
Thread Safety: What to Lock and What NOT to Lock
private readonly Lock _lock = new();
// MUST lock: compound operation (find + assign)
public ParkingTicket? TryPark(string plate, VehicleType type)
{
lock (_lock) // Only this compound section needs protection
{
var spot = _spots.Values.FirstOrDefault(s => s.IsAvailable && s.Size == size);
if (spot is null) return null;
spot.Assign(); // find + assign is now ATOMIC
return new ParkingTicket(...);
}
}
// NO lock needed: pure calculation with no shared state
public decimal Calculate(TimeSpan duration)
=> (decimal)Math.Ceiling(duration.TotalHours) * _ratePerHour;
What to SAY: "ConcurrentDictionary handles individual ops. Lock handles compound ops. Minimal lock scope — only protect the critical section, not the whole method."
Q3: How would you add a new pricing strategy (e.g., weekend pricing)?
Easy
Think: Is weekend pricing a NEW algorithm, or a MODIFIER on an existing one?
Think about how coffee shops handle pricing. A "large" upcharge doesn't change the recipe — it modifies the base price. Weekend pricing works the same way: it doesn't replace hourly or tiered pricing, it wraps around them with a multiplier. The question is whether you create a whole new algorithm or add a modifier to an existing one.
Answer: Create a new class implementing IPricingStrategy. Register it in DI. Zero changes to existing code.
Great Answer: "Weekend pricing is actually a DecoratorA pattern that wraps an existing object to add behavior without modifying it. Here, WeekendSurchargePricing wraps any IPricingStrategy, multiplying its result on weekends. You get N×M combinations (N strategies × M decorators) without creating N×M classes. — it wraps an existing strategy with a multiplier. WeekendSurchargePricing takes an IPricingStrategy inner and multiplies the base fee on weekends. This way, we can combine 'hourly + weekend' or 'tiered + weekend' without writing HourlyWeekend, TieredWeekend, etc. Composition over combinatorial explosionWithout Decorator, adding M modifiers to N strategies requires N×M classes: HourlyWeekend, HourlyHoliday, FlatRateWeekend, FlatRateHoliday... With Decorator, you have N + M classes that compose freely.."
Weekend Pricing as a Decorator
// The Decorator wraps any existing strategy -- no changes to HourlyPricing!
public sealed class WeekendSurchargePricing(
IPricingStrategy inner,
decimal weekendMultiplier = 1.5m,
TimeProvider clock) : IPricingStrategy
{
public decimal Calculate(TimeSpan duration)
{
var baseFee = inner.Calculate(duration); // delegate to wrapped strategy
var isWeekend = clock.GetUtcNow().DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
return isWeekend ? baseFee * weekendMultiplier : baseFee;
}
}
// DI registration -- compose like Lego blocks:
// Hourly + Weekend surcharge
builder.Services.AddSingleton<IPricingStrategy>(
new WeekendSurchargePricing(new HourlyPricing(5.00m), 1.5m, TimeProvider.System));
What to SAY: "New algorithm → new Strategy class. Modifier → Decorator wrapping existing strategy. Composition, not combinatorial explosion."
Q4: What happens if a customer loses their ticket?
Easy
Think: The ticket ID is the key to finding the entry record. Without it, how do we identify the vehicle?
This happens all the time in real parking garages. You've probably seen the sign: "Lost ticket? Pay maximum daily rate." The garage doesn't shrug and give up — it has a backup plan. Your system should too. The question tests whether you think about real-world failure scenarios, not just the happy path.
Answer: Look up by license plate in the active tickets. Charge the maximum daily rate as a penalty.
Great Answer: "Two approaches: (1) Look up by license plate — we store it in the ticket. Charge maximum daily rate. (2) Look up by spot — if they remember the floor/section. Either way, this is an edge case I'd handle with a separate ExitByPlate(string plate) method. The key insight: we stored the license plate in the ticket for exactly this reason. Forward-thinking data design makes edge-case handling trivial."
Lost Ticket Fallback
// Normal exit: by ticket ID (O(1) lookup)
public ParkingReceipt? TryExit(string ticketId) { ... }
// Lost ticket: by license plate (scan active tickets)
public ParkingReceipt? ExitByPlate(string plate)
{
var ticket = _tickets.Values.FirstOrDefault(t => t.Plate == plate);
if (ticket is null) return null; // not found at all
// Apply lost-ticket penalty: charge max daily rate
var maxDailyFee = 50.00m; // configurable via IOptions
_tickets.TryRemove(ticket.TicketId, out _);
_spots[ticket.SpotId].Release();
return new ParkingReceipt(ticket, _clock.GetUtcNow(),
_clock.GetUtcNow() - ticket.EntryTime, maxDailyFee);
}
What to SAY: "License plate lookup as fallback. We planned for this by storing the plate in the ticket. Max daily rate penalty — just like real garages."
Q5: Why did you use records instead of classes for tickets?
Medium
Think: What distinguishes records from classes in C#? When does immutability matter?
Think of a parking ticket as a printed receipt. Once the printer creates it, the text can't change — "Car ABC entered at 2:30 PM in spot R-5" is a historical fact. If someone could scribble on the receipt and change the entry time to 1:00 PM, the entire billing system breaks. Records are the programming equivalent of that printed receipt: once created, the data is locked in.
Answer: Tickets are immutable after creation — records enforce this at the type level.
Great Answer: "A ticket represents a historical event — 'Car ABC entered at 2:30 PM in spot R-5.' That fact should NEVER change. Records give me: immutability by defaultC# records with positional syntax (record Foo(int X)) generate init-only properties. Once created, the values can't change. This prevents accidental mutation — no one can "update" an entry time after the fact., value equalityUnlike classes (reference equality — same object in memory?), records compare by VALUE (same data?). Two ParkingTicket instances with identical fields are Equal. This makes testing easier and dictionary lookups intuitive. (two tickets with the same data are equal), and built-in ToString() for logging. Classes would let someone accidentally mutate the entry time. The compiler should prevent bugs, not just comments."
Record vs. Class — Why It Matters for Tickets
// Record: immutable by default -- entry time can NEVER be modified
public record ParkingTicket(
string TicketId, string Plate, VehicleType Type,
string SpotId, DateTimeOffset EntryTime);
var ticket = new ParkingTicket("T1", "ABC-123", VehicleType.Car, "R-5",
DateTimeOffset.UtcNow);
// ticket.EntryTime = someOtherTime; // Compiler ERROR! Can't mutate.
// Class: mutable by default -- anyone can change anything
public class MutableTicket
{
public string TicketId { get; set; } // anyone can change this
public DateTimeOffset EntryTime { get; set; } // billing fraud waiting to happen
}
var bad = new MutableTicket { TicketId = "T1", EntryTime = DateTimeOffset.UtcNow };
bad.EntryTime = DateTimeOffset.MinValue; // Compiles fine. Billing: $0.00.
What to SAY: "Records for immutable data snapshots. Classes for things with behavior and mutable state. Let the compiler enforce what comments can't."
Q6: How would this system scale to 50 parking lots?
Hard
Think: What changes when you go from in-memory to persistent? From single-service to distributed?
This is the bridge question between LLD and HLD. The interviewer wants to see if you can zoom out from code to architecture. Think of it like running a single coffee shop versus a chain of 50. The coffee recipe (your LLD) doesn't change. What changes is the logistics: inventory tracking across locations, a central dashboard, delivery routing. Same product, different infrastructure.
Answer: Database for persistence. Each lot as a microservice. API Gateway for routing.
Great Answer: "Step 1: Persist to PostgreSQLA popular open-source relational database. Our record types map naturally to rows — ParkingTicket becomes a tickets table, ParkingReceipt becomes a receipts table. EF Core handles the mapping. — our records map cleanly to tables. Step 2: Each lot is an instance of the same service with a lot ID. Step 3: API GatewayA single entry point that routes requests to the correct backend service. Instead of clients knowing about 50 lot URLs, they call one gateway. Routes like /lots/{lotId}/entry get forwarded to the right lot service instance. routes /lots/{lotId}/entry to the right instance. Step 4: Entry/exit events published to a message busInfrastructure that decouples producers and consumers. The parking lot publishes events without knowing who listens. Dashboard, display boards, and analytics each subscribe independently. Examples: RabbitMQ, Azure Service Bus, Apache Kafka. — dashboard service subscribes for analytics, display boards subscribe for availability via SignalR. The LLD code barely changes — Strategy and DI mean swapping in-memory for DB is a config change, not a rewrite."
Notice the key insight in the diagram: every lot service runs the exact same LLD code we built. The only thing that changes is the infrastructure around it — database instead of in-memory dictionaries, message bus instead of direct method calls, API Gateway for routing. This is why good LLD matters for scalability: if your classes depend on interfaces (Strategy + DI), swapping implementations is a configuration change, not a rewrite.
What to SAY: "Good LLD makes scaling a config change. Strategy + DI = swap storage and transport without touching business logic."
Q7: How would you handle payment failure at the exit gate?
Medium
Think: The car is at the exit. Payment failed. Do we let them leave? Do we keep the spot occupied?
Think about what happens at a toll booth when a credit card declines. They don't block the highway forever. They note the license plate, let the car through, and send a bill later. Customer safety and traffic flow matter more than immediate payment. Your parking system should follow the same principle.
Answer: Retry payment. If still failing, issue a "pay later" voucher and raise the barrier. Don't hold the car hostage.
Great Answer: "I'd separate the exit operation into two phases: (1) Calculate fee + attempt payment (can fail), (2) Release spot + open barrier (only after payment or override). If payment fails: retry once, then fall back to a 'deferred payment' record linked to the license plate. The car leaves, the spot is released, and the unpaid fee is tracked for follow-up. The key: never keep the barrier closed indefinitely — safety and customer experience trump revenue collection."
Two-Phase Exit with Payment Failure Handling
public async Task<ExitResult> ProcessExit(string ticketId)
{
// Phase 1: Calculate fee + attempt payment
var ticket = _tickets[ticketId];
var fee = _pricing.Calculate(ticket.EntryTime, _clock.GetUtcNow());
var paymentResult = await _paymentService.TryCharge(fee);
if (!paymentResult.Success)
{
// Retry once
paymentResult = await _paymentService.TryCharge(fee);
}
// Phase 2: Release spot regardless of payment outcome
_spots[ticket.SpotId].Release();
_tickets.TryRemove(ticketId, out _);
if (!paymentResult.Success)
{
// Deferred payment -- car leaves, fee tracked for follow-up
await _deferredPayments.Record(ticket.Plate, fee, _clock.GetUtcNow());
return ExitResult.DeferredPayment(ticket, fee);
}
return ExitResult.Paid(ticket, fee, paymentResult.TransactionId);
// NEVER: keep barrier closed indefinitely. Safety first.
}
What to SAY: "Separate calculation from barrier control. Never hold cars hostage. Deferred payment for failures — track by plate, bill later."
Q8: Why not use inheritance for vehicle types?
Medium
Think: What's the deciding factor between enum and class hierarchy?
This question is about one of the most important judgment calls in OOP: when does a "type" deserve its own class versus being a simple label? Think about T-shirt sizes. Small, Medium, Large, XL — they're not different kinds of shirts with different behavior. They're the same shirt in different sizes. You wouldn't create class SmallShirt : Shirt. You'd use an enum. Vehicle types in a parking lot work the same way.
Answer: Vehicle types have no different behavior — they're categories. Enums are the right abstraction.
Great Answer: "The test is: does the type override any behavior? A Motorcycle doesn't park() differently from a Car — it just needs a different sized spot. That's DATA, not behavior. Enums express this perfectly. If we later needed EVs with charging logic or motorcycles that share spots, THAT introduces behavior — and that's when we'd refactor to a hierarchy. But YAGNI: solve the problem you have, not the problem you might have."
The Decision Test: Data vs. Behavior
// DATA difference (spot size) -- use enum
public enum VehicleType { Motorcycle, Car, Van, Truck }
// One mapping table handles everything:
SpotSizeMapping: { Motorcycle → Compact, Car → Regular, Truck → Large }
// BEHAVIOR difference (if EVs needed charging sessions) -- THEN use classes
public abstract class Vehicle(string Plate)
{
public abstract Task OnPark(ParkingSpot spot); // different behavior per type
}
public class ElectricVehicle(string Plate) : Vehicle(Plate)
{
public override async Task OnPark(ParkingSpot spot)
=> await spot.StartChargingSession(); // EVs do something DIFFERENT
}
// But we don't have this requirement NOW -- so we use enums. YAGNI.
What to SAY: "Different behavior → classes. Different data → enums. Currently just data. I'd refactor to classes only if a new type introduces genuinely different behavior."
Q9: How would you add a real-time availability display?
Hard
Think: "Real-time" means push, not poll. What events trigger display updates?
When you see "Available: 42 spots" on a highway sign for a parking garage, that number updates the instant a car enters or exits. It doesn't refresh every 30 seconds — it changes immediately. That's push-based (event-driven), not pull-based (polling). Your system needs the same behavior: when something happens, tell everyone who cares, right now.
Answer: Publish events on entry/exit. Display board subscribes via WebSocket/SignalR.
Great Answer: "I'd introduce an IParkingEventPublisher interface. After each entry/exit, the service publishes a VehicleEnteredEvent or VehicleExitedEvent. The display board subscribes via SignalR. In LLD, the publisher is an interface — the implementation could be in-memory Channel<T> for single-lot or RabbitMQ for multi-lot. The service doesn't know or care. Observer pattern at the infrastructure level."
Event Publisher Interface + SignalR Subscriber
// The interface -- ParkingLot depends on this, not the implementation
public interface IParkingEventPublisher
{
Task PublishAsync(ParkingEvent evt);
}
// In ParkingLot.TryPark():
await _eventPublisher.PublishAsync(
new VehicleEnteredEvent(ticket.SpotId, ticket.Plate, DateTimeOffset.UtcNow));
// SignalR hub -- display board subscribes here
public class ParkingHub : Hub
{
public async Task OnVehicleEntered(VehicleEnteredEvent evt)
=> await Clients.All.SendAsync("SpotUpdate", new { Available = currentCount });
}
// Implementation swap: Channel<T> for dev, RabbitMQ for prod -- zero code changes
What to SAY: "Event-driven: publish on entry/exit, display subscribes. Interface means transport is swappable — Channel for dev, RabbitMQ for prod."
Q10: How would you persist and recover state after a server crash?
Hard
Think: Current system is in-memory. What happens to active tickets if the server restarts?
Imagine a power outage at a parking garage. The barrier is down, the display is off. When power comes back, the system needs to know: which cars are currently inside? What time did they enter? Which spots are occupied? If all that data was only in RAM, it's gone. The garage has no idea what's happening inside it.
This question tests whether you understand the difference between volatile (in-memory) and durable (persisted) state, and what trade-offs come with each persistence strategy.
Answer: Persist tickets to a database. On startup, reload active tickets into memory.
Great Answer: "Two strategies: (1) Write-throughEvery state change is immediately persisted to the database. On crash recovery, the DB has the latest state. Simple and consistent, but adds latency to every operation (DB write on every entry/exit).: Every entry/exit writes to DB immediately. On restart, reload active tickets from DB. Consistent but slower. (2) Event sourcingInstead of storing current state, store the sequence of events that produced it (VehicleEntered, VehicleExited). To recover, replay all events. More complex but gives you a complete audit trail and the ability to "time travel" through state history.: Store entry/exit events. Replay events on startup to rebuild state. More complex but gives full audit trail. For a parking lot, write-through is sufficient — the volume is manageable and we get immediate durability. Our record types map cleanly to DB rows since they're already immutable snapshots."
Write-Through Recovery in Code
// On every entry: persist immediately
public async Task<ParkingTicket?> TryPark(string plate, VehicleType type)
{
lock (_lock)
{
var spot = FindAvailableSpot(type);
if (spot is null) return null;
spot.Assign();
var ticket = new ParkingTicket(...);
_tickets[ticket.TicketId] = ticket;
await _db.Tickets.AddAsync(ticket); // write-through to DB
await _db.SaveChangesAsync();
return ticket;
}
}
// On startup: reload active tickets from DB
public async Task InitializeAsync()
{
var activeTickets = await _db.Tickets
.Where(t => t.ExitTime == null) // still parked
.ToListAsync();
foreach (var ticket in activeTickets)
{
_tickets[ticket.TicketId] = ticket;
_spots[ticket.SpotId].Assign(); // rebuild in-memory state
}
// System is back to exactly where it was before the crash
}
The code walkthrough shows how write-through works in practice. Every TryPark() call writes the ticket to the database immediately after creating it in memory. If the server crashes between two operations, the database has the complete state. On restart, InitializeAsync() queries for all tickets without an exit time (meaning those cars are still parked), loads them back into the in-memory dictionary, and marks their spots as occupied. The system is back to exactly where it was before the crash, with zero data loss.
What to SAY: "Write-through for simplicity and zero data loss. Event sourcing if you need full audit trail. Records map naturally to DB rows — our immutable design pays off here."
Section 19
10 Deadliest Interview Mistakes
These aren't hypothetical mistakes โ every one of them has ended real interviews. Interviewers who've seen hundreds of candidates can spot them in the first five minutes. Here's exactly what they see, and what to do instead.
Think First #11
Which of these 10 mistakes have you made? Be honest โ identifying your habits is the first step to fixing them. Before you scroll, pick 2 or 3 you know you're guilty of.
30 seconds โ commit to your answer before reading on.
●
Critical Mistakes — Interview Enders
#1 Jumping straight to code without clarifying requirements
Why this happens: Anxiety. You hear "design a parking lot" and your brain panics: "I need to start showing output fast or they'll think I'm stuck." So you jump to the keyboard and start typing class ParkingSpot. You haven't asked: how many floors? Multiple vehicle types? Pricing? Reservations? The interviewer watches you build a solution to a problem you never confirmed.
Here's what goes wrong: 10 minutes into coding, you realize you need pricing but you've already structured everything around a simple enter/exit flow. Now you have to rip apart your design while the clock ticks. Meanwhile, the interviewer has already decided you don't think before you build — because in production, this person will build the wrong feature for two sprints before asking what the customer actually needed.
Bad — No Requirements Gathering
// Interviewer says "design a parking lot"
// Candidate immediately types...
public class ParkingLot
{
private List<ParkingSpot> spots = new();
// 10 minutes later: "Wait, do you need pricing?"
// Interviewer: *already decided*
}
What the interviewer thinks: "No system thinking. This person codes before understanding. They'll build the wrong thing in production too."
Fix: Open with CREATESThe 7-step LLD interview structure: Clarify → Requirements → Entities → API → Trade-offs → Edge cases → Scale. Use it as your opening ritual every single interview — it signals structured thinking before a single line of code.. Spend the first 3 minutes asking: "Single-floor or multi-floor? Which vehicle types? Is pricing required? Do we need reservations?" Then summarize back before touching code. The interviewer won't penalize you for 3 minutes of questions — they'll reward you for it. That's exactly what senior engineers do on real projects.
#2 God class — cramming everything into ParkingLot
Why this happens: It feels efficient. "I'll put everything in one class so I don't have to jump between files." Early on, the class is short and readable. But classes grow. Every new feature — pricing, receipts, display updates, payment processing — gets bolted onto the same class because "it's already here." By week 3, the class is 300 lines. By month 6, it's 800.
The real danger is that the God Class makes changes unpredictable. A pricing change might accidentally break spot tracking because both live in the same class and share the same state. No one can review the class confidently because understanding the pricing code requires reading the spot management code requires reading the display code. Everything is tangled.
Bad — God Class
public class ParkingLot // does EVERYTHING
{
public ParkingSpot? FindSpot(VehicleType t) { ... }
public decimal CalculateFee(DateTime entry) { ... } // pricing logic here
public void ProcessPayment(decimal amount) { ... } // payment logic here
public void SendReceipt(string email) { ... } // email logic here
public void UpdateSign() { ... } // display logic here
// 250 more lines...
}
Connection to the fix: Each method in the God Class represents a separate responsibility. CalculateFee should live in IPricingStrategy. ProcessPayment should live in a payment service. UpdateSign should live in a display observer. When each piece is separate, you can change pricing without risking spot assignment, test pricing without needing a real display board, and review each piece independently.
What the interviewer thinks: "Doesn't understand SRPSingle Responsibility Principle — a class should have only one reason to change. If pricing changes, only IPricingStrategy changes. If spot-finding changes, only the finder changes. God classes violate SRP by having dozens of reasons to change.. This person will create unmaintainable systems."
Fix: Ask yourself "what changes independently?" Pricing rules change separately from spot assignment. Split responsibilities: ParkingLot (orchestration only), IPricingStrategy (fee logic), ISpotFinder (assignment logic), Floor (spot inventory). The test: if you can describe the class using the word "and" more than once, it needs splitting.
#3 Deep inheritance hierarchy for vehicle types
Why this happens: Object-oriented programming is taught through inheritance. "A Dog IS-A Animal. A Cat IS-A Animal." So when you see "Motorcycle, Car, Truck," your brain immediately reaches for a class hierarchy. It feels natural. But vehicles in a parking lot are categories, not behavioral subtypes. A motorcycle doesn't park differently from a car — it just needs a smaller spot. That's data, not behavior.
The deep hierarchy (Vehicle → Car → ElectricCar → TeslaModelS) creates fragile coupling. Adding "Van" means deciding: does Van extend Car? Does it extend Vehicle directly? What about an electric van? Every new type forces an existential crisis about the class tree. Enums avoid all of this because they represent what vehicle types actually are: a flat list of categories.
Bad vs. Good — Inheritance for Data
// WRONG: inheritance for categorization
public abstract class Vehicle { }
public class Car : Vehicle { }
public class Van : Car { } // Van IS-A Car? No.
public class ElectricCar : Car { }
// RIGHT: enum for categorization, record for shared data
public enum VehicleType { Motorcycle, Car, Van, Truck }
public record Vehicle(string LicensePlate, VehicleType Type);
What the interviewer thinks: "Confused about when to use inheritance vs. enums. Will create rigid hierarchies that can't evolve."
Fix: Behavior differences → subclasses. Category differences → enumsEnums represent a closed set of named constants. VehicleType.Car vs a Car subclass: enums work when the "type" affects data (spot size mapping) but not method signatures. Use subclasses only when different types need genuinely different behavior implementations.. Car and Van have the same behavior (park, exit) — they just need different spot sizes. That's a data difference, not a behavior difference.
#4 Ignoring concurrency completely
Why this happens: Most developers learn to code in a single-threaded world. Your university assignments, your side projects, your tutorials — they all run one request at a time. So when you design a parking lot, you naturally think: "Find a free spot. Assign it." That's two steps, and in single-threaded code, nothing happens between them.
But a real parking lot has multiple entry gates. On a Monday morning, three cars might arrive within the same second. Three threads each run "find a free spot" and all three see the same spot as available. Then all three run "assign it." The result: three tickets pointing to one physical spot. Two of those drivers show up to find someone already parked there. This isn't a rare edge case — it's what happens every rush hour.
Race Condition vs. Fixed
// Thread A and Thread B both call this simultaneously
public ParkingSpot? FindAvailableSpot(SpotSize size)
{
return _spots.FirstOrDefault(s => s.IsAvailable && s.Size == size);
// Both threads see the same spot as available -- double booking!
}
// Fix: lock the critical section so find+assign is atomic
private readonly Lock _lock = new();
public ParkingSpot? FindAndAssign(SpotSize size)
{
lock (_lock)
{
var spot = _spots.FirstOrDefault(s => s.IsAvailable && s.Size == size);
spot?.Assign(); // atomic: find AND assign together
return spot;
}
}
What the interviewer thinks: "Will write race conditionsA race condition occurs when the correctness of a program depends on the timing of uncontrollable events (like thread scheduling). The classic form: Thread A reads state, Thread B reads state, Thread A writes, Thread B writes — overwriting A's work. Fix: lock, Interlocked, or ConcurrentDictionary depending on the operation. in production. This is a senior-level concern they're completely missing."
Fix: Any time you have a "check then act" operation (check if spot is free, then assign it), wrap it in a lock. The find-and-assign must be atomic. Bonus: mention ConcurrentDictionary for the ticket store to show you think about thread safety throughout the design.
●
Serious Mistakes — Significant Red Flags
#5 Over-engineering with patterns you can't justify
Why this happens: You studied design patterns hard. You memorized all 23. Now you're in the interview and you want to show that knowledge. So you add a Chain of Responsibility for spot assignment, an Abstract Factory for ticket creation, and a Facade for the entrance — none of which solve a real problem in the current scope.
The problem surfaces when the interviewer asks the most important follow-up question in any design interview: "Why did you use Chain of Responsibility here?" And you hesitate. Because there's no real answer. You used it because you know it, not because you need it. The interviewer immediately sees the difference between someone who understands patterns and someone who name-drops them.
Pattern Name-Dropping vs. Problem-Driven Patterns
// BAD: Pattern for pattern's sake -- no real problem
public interface ISpotAssignmentChainHandler // Chain of Responsibility
{
ISpotAssignmentChainHandler? Next { get; set; }
ParkingSpot? Handle(VehicleType type);
}
// Why? There's only ONE assignment rule: find a free spot of the right size.
// Chain of Responsibility needs multiple handlers to justify its existence.
// GOOD: Pattern because there's a real problem
public interface IPricingStrategy // Strategy pattern
{
decimal Calculate(TimeSpan duration);
}
// Why? Pricing rules ACTUALLY change: hourly, flat, tiered.
// Without Strategy, we'd need if/else chains that grow with every new rule.
What the interviewer thinks: "Uses patterns as resume keywords, not solutions. Knows the name but not the problem it solves. This is cargo-cult design."
Fix: Only introduce a pattern when you can complete this sentence: "I'm using [Pattern] because [specific problem] — without it, [this bad thing] happens." Strategy for pricing works because pricing rules change independently. Observer for events works because the lot needs to notify multiple systems on entry/exit. No real problem, no pattern.
#6 Never mentioning tests or testability
Why this happens: Testing feels like a separate concern — something you do after the design is done. So you focus entirely on making the system work, and you never stop to ask: "Could I write a unit test for this?" The result: every class depends on concrete types. Your pricing class calls DateTime.Now directly, so you can't test what happens at midnight. Your service creates its own dependencies, so you can't substitute a fake payment processor.
Interviewers care about testability because it reveals whether you've maintained production code. Anyone can write a system that works once. The question is: can someone else change it safely six months later? Testability is the foundation of safe change.
Hard to Test vs. Easy to Test
// Hard to test -- DateTime.Now baked in, no seam to inject a fake time
public decimal Calculate(DateTime entryTime)
=> (decimal)Math.Ceiling((DateTime.Now - entryTime).TotalHours) * _rate;
// Testable -- pass exit time explicitly, no hidden clock dependency
public decimal Calculate(DateTime entryTime, DateTime exitTime)
=> (decimal)Math.Ceiling((exitTime - entryTime).TotalHours) * _rate;
// Even more testable -- inject ITimeProvider for full control in tests
public decimal Calculate(DateTime entryTime)
=> (decimal)Math.Ceiling((_clock.UtcNow - entryTime).TotalHours) * _rate;
What the interviewer thinks: "Doesn't think about quality or maintenance. Will write code that's impossible to test and unsafe to refactor."
Fix: After writing any class, say: "This is testable because I inject IPricingStrategy — in tests I swap in a FlatRatePricing(0m) to isolate behavior." One sentence on testability signals seniority.
#7 Using a static singleton without explaining the trade-off
You write ParkingLot.Instance with a private static field and a private constructor — and just leave it there. You haven't acknowledged that this makes testing harder, that it creates global mutable state, or when you'd choose DI instead.
Static Singleton vs. DI Singleton
// Static singleton -- hard to test, creates global state
public class ParkingLot
{
public static readonly ParkingLot Instance = new();
private ParkingLot() { }
}
// DI singleton -- same one-instance lifetime, fully testable
// In Program.cs:
builder.Services.AddSingleton<IParkingLot, ParkingLot>();
// Tests inject a mock IParkingLot -- no static state involved
What the interviewer thinks: "Doesn't understand DIDependency Injection — instead of a class creating its own dependencies (new PricingStrategy()), they're provided from outside via constructor. This makes classes independently testable and swappable. The DI container manages object lifetimes including singleton scope, without the static coupling. or why testability matters. Probably writes tightly-coupled code in their day job."
Fix: Say it out loud: "I could use a static singleton, but I prefer DI singleton — same one instance, but I can swap it in tests. I'd use static only in a console app with no DI container."
#8 Magic numbers scattered through the code
Your pricing class is littered with 5.00m, 2.50m, 10.00m. A week after the interview you'd have to grep the whole codebase to change the overnight rate. An interviewer sees this as "not production-ready."
Magic Numbers vs. Named Config
// Bad -- what is 5.00? 2.50? Why 8 hours?
public decimal Calculate(TimeSpan d)
=> d.TotalHours > 8
? (decimal)d.TotalHours * 2.50m
: (decimal)Math.Ceiling(d.TotalHours) * 5.00m;
// Good -- constructor-injected, every number has a name
public sealed class HourlyPricing(
decimal ratePerHour = 5.00m,
decimal overnightRate = 2.50m,
int overnightThresholdHours = 8) : IPricingStrategy
{
public decimal Calculate(TimeSpan duration)
=> duration.TotalHours > overnightThresholdHours
? (decimal)duration.TotalHours * overnightRate
: (decimal)Math.Ceiling(duration.TotalHours) * ratePerHour;
}
What the interviewer thinks: "Hard to configure. Not production-ready. The rates will change — this code requires a redeployment just to update a price."
Fix: Named constructor parameters with defaults, or IOptions<T>ASP.NET Core's strongly-typed configuration. IOptions<PricingConfig> binds your appsettings.json "Pricing" section to a C# class. Rates change via config file with no code change and no redeployment. Pattern: record PricingConfig(decimal RatePerHour, decimal OvernightRate) then bind it in the DI container at startup. for full config-file control. Either way: every number has a name.
●
Minor Mistakes — Missed Opportunities
#9 Only handling the happy path
Why this happens: When you're designing under time pressure, your brain focuses on making the system work, not on what happens when it doesn't. You write Exit() assuming the ticket exists, the vehicle matches, and the payment succeeds. You're not being careless — you're being focused on the wrong thing.
But production doesn't care about your assumptions. Customers lose tickets. Credit cards get declined. Databases go down during checkout. The gap between "works in my tests" and "works in production" is almost entirely edge cases. Interviewers know this, which is why proactively mentioning 2-3 edge cases signals production experience more than any pattern name ever could.
Happy Path Only vs. Defensive
// Happy path only -- throws on real-world inputs
public decimal Exit(string ticketId)
{
var ticket = _tickets[ticketId]; // KeyNotFoundException if ticket missing
var fee = _pricing.Calculate(...);
_tickets.Remove(ticketId);
return fee;
}
// Defensive -- handles real scenarios gracefully
public (bool Success, decimal Fee, string Error) Exit(string ticketId)
{
if (!_tickets.TryGetValue(ticketId, out var ticket))
return (false, 0m, "Ticket not found -- apply lost ticket policy");
var fee = _pricing.Calculate(ticket.EntryTime, _clock.UtcNow);
_tickets.TryRemove(ticketId, out _);
return (true, fee, string.Empty);
}
What the interviewer thinks: "Will need supervision in production. Doesn't think about what can go wrong. Classic junior habit."
Fix: After writing any method, run the What If? checkA quick mental checklist: What if the input is null? What if the key doesn't exist in the dictionary? What if a downstream call (DB, payment, network) fails? What if two threads call this at the same time? Answering even one of these proactively signals production maturity to the interviewer.: what if input is null? What if the key doesn't exist? What if a downstream call fails? Proactively mentioning 2–3 edge cases is a Strong Hire signal.
#10 Not bridging the design to high-level architecture
Why this happens: You've been heads-down in code for 30 minutes. You finally feel good about your design — clean classes, patterns justified, concurrency handled. You're mentally exhausted. So you stop and say "I think that's it." But the interviewer was waiting for you to zoom out.
They wanted to hear how this in-memory, single-server system would survive in the real world: 10 parking lots, 1000 entries per hour, server restarts, mobile apps querying live spot counts. The LLD-to-HLD bridge is what separates "good coder" from "systems thinker." Even 30 seconds of HLD thinking elevates your entire interview.
What to SAY in the Last 2 Minutes
// "If I had more time, here's how this scales:"
//
// 1. PERSISTENCE: Replace in-memory dictionaries with PostgreSQL.
// Our record types map cleanly to DB rows.
// builder.Services.AddSingleton<ITicketStore, PostgresTicketStore>();
//
// 2. MULTI-LOT: Each lot is a service instance with a lotId.
// API Gateway routes /lots/{lotId}/entry to the right instance.
//
// 3. EVENTS: Entry/exit publish to a message bus.
// Display boards subscribe via SignalR for real-time updates.
//
// 4. RESILIENCE: Write-through to DB means server crash loses nothing.
// On restart, reload active tickets from the database.
//
// The beauty: our LLD code barely changes. Strategy + DI means
// swapping in-memory for DB is a config change, not a rewrite.
What the interviewer thinks: "Only sees code, not systems. Can't zoom out. May struggle at senior level where design spans multiple components."
Fix: In the last 2 minutes, bridge to HLDHigh-Level Design — the system architecture view: services, databases, queues, APIs. LLD is how a single component works internally. HLD is how multiple components connect. Interviewers at senior levels want you to connect both worlds: "My ParkingLot class would run inside an ASP.NET Core service, behind an API gateway, backed by PostgreSQL.": "This LLD runs as an ASP.NET Core service. For multiple lots, I'd add a persistence layer (PostgreSQL), a message busAn event-driven communication layer (e.g., Azure Service Bus, RabbitMQ). On VehicleEntered, publish an event. Multiple consumers subscribe: billing service, notification service, analytics. Decoupled, scalable, observable — and your ParkingLot class stays completely unchanged. for entry/exit events, and a read-through cache for availability display boards." One paragraph of HLD shows you see the big picture — and it costs you only 30 seconds.
How Interviewers Score You
Level
Requirements
Design
Code
Edge Cases
Communication
Strong Hire
Structured F+NF
Patterns natural
Clean modern C#
3+ proactive
Explains WHY
Hire
Key requirements
1-2 patterns
Mostly correct
When asked
Clear
Lean No
Partial
Forced/wrong
Messy
Misses obvious
Quiet/verbose
No Hire
None
No abstractions
Can't code
None
Can't explain
Section 20
Memory Anchors โ Never Forget This
The CREATES Approach โ Your Universal LLD Structure
"Every system design CREATES a solution." โ Use this in every interview, every case study, every design discussion.
Memory Palace โ Walk Through a Parking Lot
Memory Palace โ Parking Lot โ Design Concepts
Smell โ Pattern Quick Reference
Smell
Signal
Response
๐ Categories Without Behavior
Types differ in identity, not actions
Enum, not class hierarchy
๐ Multiple Algorithms
3+ ways to do the same thing
Strategy pattern
๐ Simultaneous Access
Multiple actors, same resource
Lock + ConcurrentDictionary
๐ Fixed After Creation
Data never changes after birth
Record (immutable type)
๐ Happy Path Only
No failure handling
What If? framework
๐ One Instance, Must Test
Global state + mockability
DI Singleton
5 Things to ALWAYS Mention in a Parking Lot Interview
โ Thread safety (lock for compound ops)
โ Strategy for pricing
โ DI Singleton, not static Singleton
โ 2+ edge cases proactively
โ Scaling bridge (persistence + events)
Section 21
Transfer โ These Techniques Work Everywhere
You didn't just learn a parking lot. You learned a PROCESS โ a repeatable way to approach any system. The walkthrough, the noun-hunting, the "what varies?" question, the lock instinct, the scale thinking โ none of those are parking lot tricks. They're engineering reflexes. And every time you apply them, they get faster and sharper. Below is the proof: the exact same 7 thinking tools, applied to 3 different systems. Same structure, different domain. That's what transfer looks like.
Technique
Parking Lot
Elevator
Online Shop
Real-world walkthrough
Enter โ Find โ Park โ Exit โ Pay
Press โ Wait โ Board โ Ride โ Exit
Browse โ Cart โ Checkout โ Pay โ Ship
Every system starts with a physical walkthrough โ even digital ones. Walking the flow reveals the nouns, verbs, and sequence before you touch any code.
Key nouns
Lot, Spot, Vehicle, Ticket
Elevator, Floor, Button, Request
Product, Cart, Order, Payment
Nouns become classes. The concrete things users talk about in the real world are almost always your core entities.
What varies?
Pricing algorithm
Scheduling algorithm
Payment method
One question โ "Is there more than one way to do this?" โ surfaces every Strategy candidate in your design.
Primary pattern
Strategy (pricing)
Strategy (scheduling)
Strategy (payment)
Patterns aren't domain-specific. Strategy solves "swappable algorithm" in any domain โ pricing, movement, transactions.
Concurrency risk
Two gates, one spot
Two requests, one elevator
Two users, last item
Shared resource + concurrent access = race conditionA race condition occurs when two threads or processes read and act on the same shared state simultaneously, each "winning the race" before the other can update. The result is corrupted state โ two cars assigned the same spot, two users buying the last item. Fix: acquire an exclusive lock before reading AND writing the shared resource. risk. The lock pattern is the answer โ every time, every domain.
Key edge case
Lot full, lost ticket
Overweight, stuck doors
Out of stock after adding
Edge cases cluster around the same 4 categories in every system: concurrency, failure, boundary, and weird input.
Scale path
Multi-lot chain + events
Multi-building + scheduling
Microservices + event bus
Scale always means: decouple, distribute, and coordinate with events. The topology changes; the pattern doesn't.
The insight: Systems share STRUCTUREEvery system has: entities (nouns), operations (verbs), algorithms that vary (Strategy candidates), shared resources (concurrency risks), and failure modes (edge cases). This structural similarity is why learning ONE system deeply teaches you how to approach ALL systems. even when domains differ. The skills transfer because they target the structure, not the domain. "What varies?" works for pricing, scheduling, payment, and any future system you'll design.
Quick Transfer โ Three Systems, Same Moves
Here are three specific, concrete transfers. Each one shows a technique you learned in this parking lot, dropped directly into a different domain.
Strategy โ Elevator
The parking lot uses IPricingStrategy so you can swap flat-rate, hourly, and VIP pricing at runtime. An elevator does the same: ISchedulingStrategy lets you swap FCFSFirst Come, First Served โ the simplest elevator scheduling algorithm. Requests are handled in the order they arrive, regardless of direction. Simple to implement, but causes the elevator to "bounce" back and forth inefficiently. Better alternatives: SCAN and LOOK., SCANThe SCAN algorithm (also called the "elevator algorithm") moves in one direction servicing all requests until it hits the end, then reverses. This prevents starvation โ no floor is skipped indefinitely. More efficient than FCFS for high-traffic buildings. Variant: LOOK stops and reverses when there are no more requests in the current direction, rather than always going to the end., and LOOK algorithms without changing the elevator's movement logic. Same interface contract, different domain.
Lock โ Online Shop
In the parking lot, two entry gates racing to assign the same spot is a race conditionA race condition occurs when two threads read shared state, both see "available", and both proceed โ resulting in double-assignment. The fix is a lock: only one thread can own the critical section at a time. The second thread waits until the first has finished reading AND writing the state atomically.. In an online shop, two users racing to buy the last item is the identical bug. Same fix: acquire a lock before checking inventory, decrement, and release โ so the second user sees "out of stock" instead of buying a phantom item. The lock pattern is idempotentIdempotent means applying an operation multiple times produces the same result as applying it once. A properly locked inventory check is idempotent: whether one user or a hundred try simultaneously, the final count is always correct. Idempotency is also critical for payment retries โ a payment request retried after a timeout should not charge the user twice. infrastructure โ it works the same in every system with shared mutable state.
What If? โ Chat App
The "What If?" framework from Section 7 asked: what if payment fails? What if the lot is full when the driver arrives? Apply it to a chat app: What if message delivery fails mid-send? What if the recipient is offline when the message arrives? What if the same message is sent twice (network retry causing a duplicate)? Each question surfaces a real edge case: retry logic, offline queues, and idempotencyIn messaging, idempotency means delivering the same message twice has the same effect as delivering it once. Achieved by assigning each message a unique ID โ the receiver deduplicates by ID before displaying. Without this, network retries cause the user to see the same message twice. keys. Same four-category thinking (Concurrency, Failure, Boundary, Weird Input) โ different domain.
Section 22
The Reusable Toolkit
Six cognitive frameworks you picked up in this case study. Each one is a portable thinking tool โ not a parking lot trick, but a repeatable move you can make in any LLD interview or real-world design session. Here's what each tool is, how to wield it, and exactly where you used it in this case study.
How to use: Before any design, ask one question per category until you have enough to build a class diagram.
Parking Lot use: Section 2 โ used to nail down spot types (Size), pricing variations (Complexity), entry/exit flow (Operations), and multi-lot support (Extensions).
What If?
Concurrency ยท Failure ยท Boundary ยท Weird Input โ The 4 edge case categoriesA structured way to find edge cases by asking four questions: (1) What if two things happen at the same time? (2) What if a step fails mid-way? (3) What if the input is at the maximum or minimum? (4) What if the input is unexpected, malformed, or deliberately weird? These four categories cover the vast majority of real-world bugs..
How to use: After your happy path is designed, run through all 4 categories and ask "what breaks here?" for each operation.
Parking Lot use: Section 7 โ uncovered the double-park race, the lost ticket problem, the full-lot boundary, and the zero-vehicle weird input.
What Varies?
Ask for every operation: "Is there more than one way to do this? Does it change at runtime?" โ Strategy candidateA Strategy candidate is any operation where the answer to "what varies?" is yes. You extract it into an interface (IXxxStrategy) and inject the chosen implementation. This keeps the host class stable โ it never needs to change when a new algorithm is added..
How to use: List every verb in your design. For each one, ask the two questions. If yes to either โ extract an interface.
Parking Lot use: Sections 4 & 8 โ identified IPricingStrategy (flat, hourly, VIP) and ISpotAssignmentStrategy (nearest, largest-first) as the two variation points.
Record vs Class
MutableCan change after creation. ParkingSpot.IsOccupied changes when a car parks/leaves โ that's mutable state requiring a class. Contrast with ParkingTicket, which is fixed after creation (record). + behavior โ class. Immutable data snapshot โ record. Categories โ enum.
How to use: For every noun you find, ask "does this change after creation?" and "does this have methods?". Answer determines the C# type.
Parking Lot use:ParkingSpot โ class (IsOccupied mutates). ParkingTicket โ record (immutable snapshot). VehicleType โ enum (fixed category set).
How to use: Use as a verbal checklist in interviews. When you finish one letter, say it aloud and move to the next. It prevents you from skipping steps under pressure.
Parking Lot use: The entire page follows this arc โ Sections 1-2 (C/R), 3-4 (E), 5-6 (A), 9 (T), 7 (E), 16-17 (S).
Smell โ Pattern
6 smells learned in this case study. Each one is a sensory trigger that maps to a design response.
How to use: When code feels wrong but you can't name why, run through the smell list. Each smell has one or two pattern responses you can apply immediately.
Parking Lot use: "Pricing switch block" โ Strategy. "Manual object assembly everywhere" โ Factory. "One class does payment + tracking + notifications" โ SRP / split.
Your Toolkit Checklist
Before you leave this page, do a quick self-check. Can you explain each tool to a teammate โ without looking at the page?
☐SCOPE โ Can you name all 5 categories and ask one concrete parking lot question for each?
☐What If? โ Can you describe a Concurrency edge case AND a Failure edge case from this design, from memory?
☐What Varies? โ Can you explain why IPricingStrategy exists and what would go wrong if it didn't?
☐Record vs Class โ Given a new noun (say, ParkingLevel), can you immediately say which type it is and why?
☐CREATES โ Can you walk through all 7 steps in order, with one sentence on what you do in each?
☐Smell โ Pattern โ Can you name 3 smells from this case study and the pattern each one triggered?
These 6 tools are your permanent inventory. They work for every LLD problem โ parking lots, elevators, online shops, chat apps, ride-hailing, hospital systems. Domains change. The structural questions don't. If you remember nothing else from this page, remember THESEThe six tools in order: (1) SCOPE โ clarify before designing. (2) What If? โ stress-test after designing. (3) What Varies? โ find your Strategy interfaces. (4) Record vs Class โ pick the right C# type for every noun. (5) CREATES โ the interview framework from Clarify to Scale. (6) Smell โ Pattern โ turn intuition into action..
Section 23
Practice Exercises
Three exercises that test whether you truly learned the thinking, not just memorized the code.
Exercise 1: EV Charging Spots Medium
New constraint: The parking lot now has 10 EV charging spots. Electric vehicles can park in regular spots OR charging spots. When parked in a charging spot, they pay an additional charging fee calculated by a IChargingStrategy.
Think: What entity changes? What new enum values? Does this need a new Strategy? How does it interact with existing pricing?
Hint
Add Charging to SpotSize. Add Electric to VehicleType. Create IChargingStrategy similar to IPricingStrategy. The total fee = parking fee + optional charging fee. Think Decorator: charging wraps parking.
Full Solution
EVCharging.cs
// New enum values
public enum SpotSize { Compact, Regular, Large, Charging }
public enum VehicleType { Motorcycle, Car, Van, Truck, Electric }
// Charging strategy (separate from parking pricing)
public interface IChargingStrategy
{
decimal CalculateChargingFee(TimeSpan chargingDuration);
}
public sealed class PerKwhCharging(decimal ratePerHour = 2.50m) : IChargingStrategy
{
public decimal CalculateChargingFee(TimeSpan duration)
=> (decimal)Math.Ceiling(duration.TotalHours) * ratePerHour;
}
// Updated spot mapping: EVs can use Regular OR Charging
// In Entry(), try Charging first, fall back to Regular
public ParkingResult<ParkingTicket> Entry(string plate, VehicleType type)
{
lock (_lock)
{
SpotSize[] preferredSizes = type == VehicleType.Electric
? [SpotSize.Charging, SpotSize.Regular] // prefer charging, fall back
: [SpotSizeMapping.For(type)];
ParkingSpot? spot = null;
foreach (var size in preferredSizes)
{
spot = _spots.Values.FirstOrDefault(s => !s.IsOccupied && s.Size == size);
if (spot is not null) break;
}
if (spot is null) return ParkingResult<ParkingTicket>.Fail("No spots available.");
// ... issue ticket as before, noting if it's a charging spot
}
}
// Total fee = parking fee + optional charging fee
// Charging fee only applies if parked in a Charging spot
Exercise 2: Reservation System Medium
New constraint: Customers can reserve a spot up to 2 hours in advance. A reserved spot shows as occupied until the reservation expires or the customer arrives. If they don't show up in 30 minutes past their reservation, the spot is released.
Think: What new entity/record is needed? How does reservation interact with the existing Entry()? What edge cases appear?
Hint
New ReservationrecordRecords are ideal for reservations โ a reservation is an immutable fact: "Spot R-5 is reserved for plate ABC-123 from 2:00 PM to 4:00 PM." That fact shouldn't change after creation (though it can be cancelled by creating a CancellationEvent). with plate, spot, scheduled time, and expiry. Add a ReservedBy property to ParkingSpot. Entry() must check: is this plate's reservation valid? Edge cases: arrives late, cancels, double-booksTwo customers reserve the same spot for overlapping times โ a concurrency edge case. The lock around spot assignment also needs to cover reservation creation to prevent this race condition..
Exercise 3: The Final Boss โ Rebuild From Memory Hard
Close this page. Open a blank document. In 15 minutes, from memory:
List the core entities and their types (record/class/enum)
Draw the class diagram (rough sketch)
Explain which patterns you'd use and WHY
List 3 edge cases and how you'd handle them
Describe how you'd bridge to HLD
Then come back and compare with Section 11.
Scoring: 5/5 with detail: You've MASTERED this system. Move to the next case study. 3-4/5: Re-read the levels you missed. Try again tomorrow. 1-2/5: You scrolled without thinking. Go back to Level 0 and STOP at every ๐ง .
Spaced RepetitionA learning technique based on neuroscience: reviewing material at increasing intervals (1 day โ 3 days โ 1 week โ 2 weeks) moves it from short-term to long-term memory. Each successful recall strengthens the neural pathway.: Try this challenge again in 3 days (without re-reading). Then 1 week. If you nail it after 1 week, it's permanent.
Section 24
Related Topics
You've learned patterns, principles, and cognitive frameworks in this case study. Here's where to go next — the pages below will deepen specific skills you've already started building.
Same difficulty tier. Same Strategy skill (scheduling algorithms instead of pricing). Adds the State patternWhen an object's behavior changes based on its internal state. An elevator acts differently when it's idle, moving up, moving down, or doors-open. Each state is a separate class implementing the same interface — the elevator delegates to its current state. for elevator modes (idle, moving, doors-open).
Same difficulty tier. Introduces the State patternThe vending machine has distinct modes: Idle, HasMoney, Dispensing, OutOfStock. Each state handles user actions differently. Instead of massive if/else chains checking the current mode, each state is its own class with specific behavior. as the primary pattern (machine states: idle → has-money → dispensing). Strategy for payment methods.
Skills that transfer: State + Strategy combo, edge cases (out of stock, stuck item), What If? framework
Coming Soon
Tic-Tac-Toe
Slightly easier. Great for practicing game state managementTracking whose turn it is, validating moves, detecting win/draw conditions, and supporting undo/redo. These are the same concerns as any stateful system — parking lot tracks spot state, tic-tac-toe tracks board state. and win condition detection. Introduces the Command pattern for undo/redo.
Skills that transfer: State tracking, validation logic, What Varies? for AI difficulty levels
Coming Soon
Recommended order: If this was your first case study, try Elevator or Vending Machine next — they're at the same difficulty level but introduce new primary patterns (State). Then move to Chess or Online Shopping for a step up in complexity. The thinking tools you built here (SCOPE, What Varies?, What If?, CREATES) will carry forward to every single one.