Chapter 8: Case Study - Parking Lot System
Series: Low Level Design for .NET Developers | Previous: Chapter 7: Domain Driven Design Basics | Next: Chapter 9: Case Study - Splitwise
📖 Introduction
The Parking Lot System is one of the most common Low Level Design interview questions. It tests your understanding of:
- Object Oriented Design principles
- SOLID principles
- Design patterns (Strategy, Observer, Factory, State)
- Domain modeling
- Concurrency handling
- Pricing strategies
In this case study, we'll build a complete parking lot system from scratch, applying all the concepts we've learned throughout the series.
1. Requirements Analysis
1.1 Functional Requirements
- The parking lot should have multiple floors
- Each floor has multiple parking spots of different types (Compact, Regular, Large)
- Different vehicle types can park (Motorcycle, Car, Truck)
- Each vehicle gets a ticket upon entry with entry time
- Payment is calculated based on duration and vehicle type
- Support different pricing strategies (hourly, flat rate, etc.)
- Track available spots per floor and spot type
- Support entry and exit gates
- Generate payment receipts
1.2 Non-Functional Requirements
- System should handle concurrent entries/exits
- Low latency for ticket generation
- Accurate payment calculation
- Audit trail of all transactions
1.3 Domain Language (Ubiquitous Language)
- Parking Lot - The overall facility
- Floor - A level within the parking lot
- Parking Spot - A specific spot where vehicles park
- Vehicle - The entity that parks
- Ticket - Entry/exit document
- Payment - Payment for parking
- Receipt - Proof of payment
2. Domain Model
2.1 Core Enums
namespace ParkingLot.Domain
{
public enum VehicleType
{
Motorcycle,
Car,
Truck
}
public enum ParkingSpotType
{
Compact, // For motorcycles
Regular, // For cars
Large // For trucks
}
public enum TicketStatus
{
Active,
Paid,
Exited
}
public enum PaymentMethod
{
Cash,
CreditCard,
DebitCard,
MobilePayment
}
public enum PaymentStatus
{
Pending,
Completed,
Failed,
Refunded
}
}
2.2 Value Objects
namespace ParkingLot.Domain.ValueObjects
{
// Money value object
public class Money : IEquatable<Money>
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency = "USD")
{
if (amount < 0)
throw new ArgumentException("Amount cannot be negative", nameof(amount));
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Currency is required", nameof(currency));
Amount = amount;
Currency = currency.ToUpperInvariant();
}
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException($"Cannot add different currencies: {Currency} and {other.Currency}");
return new Money(Amount + other.Amount, Currency);
}
public Money Subtract(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException($"Cannot subtract different currencies: {Currency} and {other.Currency}");
return new Money(Amount - other.Amount, Currency);
}
public Money Multiply(decimal multiplier)
{
return new Money(Amount * multiplier, Currency);
}
public static Money Zero(string currency = "USD") => new Money(0, currency);
public override bool Equals(object obj) => Equals(obj as Money);
public bool Equals(Money other) => other != null && Amount == other.Amount && Currency == other.Currency;
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
public override string ToString() => $"{Amount:F2} {Currency}";
}
// Duration value object
public class Duration
{
public TimeSpan Value { get; }
public Duration(TimeSpan value)
{
if (value < TimeSpan.Zero)
throw new ArgumentException("Duration cannot be negative", nameof(value));
Value = value;
}
public double TotalHours => Value.TotalHours;
public double TotalMinutes => Value.TotalMinutes;
public static Duration FromDateTime(DateTime start, DateTime end)
{
return new Duration(end - start);
}
public override string ToString()
{
if (Value.TotalHours >= 24)
return $"{Value.Days}d {Value.Hours}h {Value.Minutes}m";
if (Value.TotalHours >= 1)
return $"{Value.Hours}h {Value.Minutes}m";
return $"{Value.Minutes}m";
}
}
// TimeSlot value object
public class TimeSlot
{
public DateTime EntryTime { get; }
public DateTime? ExitTime { get; private set; }
public Duration Duration => ExitTime.HasValue ? Duration.FromDateTime(EntryTime, ExitTime.Value) : null;
public TimeSlot(DateTime entryTime)
{
EntryTime = entryTime;
}
public void Exit(DateTime exitTime)
{
if (exitTime < EntryTime)
throw new ArgumentException("Exit time cannot be before entry time", nameof(exitTime));
ExitTime = exitTime;
}
public bool IsActive => !ExitTime.HasValue;
}
}
2.3 Vehicle Entities
namespace ParkingLot.Domain.Entities
{
public abstract class Vehicle
{
public string LicensePlate { get; }
public VehicleType Type { get; }
protected Vehicle(string licensePlate, VehicleType type)
{
if (string.IsNullOrWhiteSpace(licensePlate))
throw new ArgumentException("License plate is required", nameof(licensePlate));
LicensePlate = licensePlate.ToUpperInvariant();
Type = type;
}
public abstract ParkingSpotType GetRequiredSpotType();
public override bool Equals(object obj)
{
if (obj is not Vehicle other) return false;
return LicensePlate == other.LicensePlate;
}
public override int GetHashCode() => LicensePlate.GetHashCode();
public override string ToString() => $"{Type} ({LicensePlate})";
}
public class Motorcycle : Vehicle
{
public Motorcycle(string licensePlate) : base(licensePlate, VehicleType.Motorcycle) { }
public override ParkingSpotType GetRequiredSpotType() => ParkingSpotType.Compact;
}
public class Car : Vehicle
{
public Car(string licensePlate) : base(licensePlate, VehicleType.Car) { }
public override ParkingSpotType GetRequiredSpotType() => ParkingSpotType.Regular;
}
public class Truck : Vehicle
{
public Truck(string licensePlate) : base(licensePlate, VehicleType.Truck) { }
public override ParkingSpotType GetRequiredSpotType() => ParkingSpotType.Large;
}
public class VehicleFactory
{
public static Vehicle Create(string licensePlate, VehicleType type)
{
return type switch
{
VehicleType.Motorcycle => new Motorcycle(licensePlate),
VehicleType.Car => new Car(licensePlate),
VehicleType.Truck => new Truck(licensePlate),
_ => throw new NotSupportedException($"Vehicle type {type} not supported")
};
}
}
}
2.4 Parking Spot Entity
namespace ParkingLot.Domain.Entities
{
public class ParkingSpot
{
public int Id { get; }
public int FloorId { get; }
public int SpotNumber { get; }
public ParkingSpotType Type { get; }
public bool IsOccupied { get; private set; }
public Vehicle CurrentVehicle { get; private set; }
public ParkingSpot(int id, int floorId, int spotNumber, ParkingSpotType type)
{
Id = id;
FloorId = floorId;
SpotNumber = spotNumber;
Type = type;
IsOccupied = false;
}
public bool CanFitVehicle(Vehicle vehicle)
{
if (IsOccupied) return false;
return vehicle.GetRequiredSpotType() switch
{
ParkingSpotType.Compact => true, // Compact spots fit all
ParkingSpotType.Regular => Type != ParkingSpotType.Compact, // Regular spots don't fit in compact
ParkingSpotType.Large => Type == ParkingSpotType.Large, // Large only fits large
_ => false
};
}
public void ParkVehicle(Vehicle vehicle)
{
if (!CanFitVehicle(vehicle))
throw new InvalidOperationException($"Vehicle {vehicle.LicensePlate} cannot park in spot {SpotNumber}");
CurrentVehicle = vehicle;
IsOccupied = true;
}
public void Vacate()
{
CurrentVehicle = null;
IsOccupied = false;
}
public override string ToString() => $"Floor {FloorId} - Spot {SpotNumber} ({Type})";
}
}
2.5 Ticket Aggregate Root
namespace ParkingLot.Domain.Aggregates
{
public class ParkingTicket : IAggregateRoot
{
private readonly List<DomainEvent> _domainEvents = new();
public string TicketNumber { get; }
public string LicensePlate { get; }
public VehicleType VehicleType { get; }
public int ParkingSpotId { get; }
public int FloorId { get; }
public DateTime EntryTime { get; }
public DateTime? ExitTime { get; private set; }
public TicketStatus Status { get; private set; }
public Money AmountPaid { get; private set; }
public PaymentMethod? PaymentMethod { get; private set; }
public string PaymentTransactionId { get; private set; }
public IReadOnlyCollection<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();
private ParkingTicket() { } // For persistence
public ParkingTicket(string licensePlate, VehicleType vehicleType, int parkingSpotId, int floorId)
{
TicketNumber = GenerateTicketNumber();
LicensePlate = licensePlate;
VehicleType = vehicleType;
ParkingSpotId = parkingSpotId;
FloorId = floorId;
EntryTime = DateTime.UtcNow;
Status = TicketStatus.Active;
AmountPaid = Money.Zero();
AddDomainEvent(new TicketIssuedEvent(this));
}
public void Pay(Money amount, PaymentMethod paymentMethod, string transactionId)
{
if (Status != TicketStatus.Active)
throw new InvalidOperationException($"Cannot pay for ticket in {Status} status");
if (amount.Amount <= 0)
throw new ArgumentException("Payment amount must be positive", nameof(amount));
AmountPaid = amount;
PaymentMethod = paymentMethod;
PaymentTransactionId = transactionId;
Status = TicketStatus.Paid;
AddDomainEvent(new TicketPaidEvent(this));
}
public void Exit(DateTime exitTime)
{
if (Status != TicketStatus.Paid)
throw new InvalidOperationException("Ticket must be paid before exit");
if (exitTime < EntryTime)
throw new ArgumentException("Exit time cannot be before entry time", nameof(exitTime));
ExitTime = exitTime;
Status = TicketStatus.Exited;
AddDomainEvent(new TicketExitedEvent(this));
}
public Duration GetDuration()
{
var endTime = ExitTime ?? DateTime.UtcNow;
return Duration.FromDateTime(EntryTime, endTime);
}
private static string GenerateTicketNumber()
{
return $"TKT-{DateTime.Now:yyyyMMddHHmmss}-{Guid.NewGuid().ToString().Substring(0, 6).ToUpper()}";
}
private void AddDomainEvent(DomainEvent eventItem)
{
_domainEvents.Add(eventItem);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
// Domain Events
public abstract class TicketDomainEvent : DomainEvent
{
public ParkingTicket Ticket { get; }
protected TicketDomainEvent(ParkingTicket ticket)
{
Ticket = ticket;
}
}
public class TicketIssuedEvent : TicketDomainEvent
{
public TicketIssuedEvent(ParkingTicket ticket) : base(ticket) { }
}
public class TicketPaidEvent : TicketDomainEvent
{
public TicketPaidEvent(ParkingTicket ticket) : base(ticket) { }
}
public class TicketExitedEvent : TicketDomainEvent
{
public TicketExitedEvent(ParkingTicket ticket) : base(ticket) { }
}
}
3. Pricing Strategy (Strategy Pattern)
namespace ParkingLot.Domain.Strategies
{
public interface IPricingStrategy
{
string StrategyName { get; }
Money CalculateCharge(Duration duration, VehicleType vehicleType);
bool IsApplicable(VehicleType vehicleType);
}
// Hourly pricing strategy
public class HourlyPricingStrategy : IPricingStrategy
{
private readonly Dictionary<VehicleType, decimal> _hourlyRates;
public string StrategyName => "Hourly Pricing";
public HourlyPricingStrategy()
{
_hourlyRates = new Dictionary<VehicleType, decimal>
{
[VehicleType.Motorcycle] = 1.00m,
[VehicleType.Car] = 2.50m,
[VehicleType.Truck] = 5.00m
};
}
public Money CalculateCharge(Duration duration, VehicleType vehicleType)
{
var rate = _hourlyRates[vehicleType];
var hours = (int)Math.Ceiling(duration.TotalHours);
hours = Math.Max(1, hours); // Minimum 1 hour
var amount = rate * hours;
// Daily max
if (hours >= 24)
{
var days = hours / 24;
var maxDailyRate = rate * 8; // Max 8 hours charge per day
amount = Math.Min(amount, days * maxDailyRate);
}
return new Money(amount);
}
public bool IsApplicable(VehicleType vehicleType) => true;
}
// Flat rate pricing strategy
public class FlatRatePricingStrategy : IPricingStrategy
{
private readonly Dictionary<VehicleType, decimal> _flatRates;
public string StrategyName => "Flat Rate Pricing";
public FlatRatePricingStrategy()
{
_flatRates = new Dictionary<VehicleType, decimal>
{
[VehicleType.Motorcycle] = 5.00m,
[VehicleType.Car] = 10.00m,
[VehicleType.Truck] = 20.00m
};
}
public Money CalculateCharge(Duration duration, VehicleType vehicleType)
{
// First hour free, then flat rate
if (duration.TotalHours <= 1)
return new Money(0);
return new Money(_flatRates[vehicleType]);
}
public bool IsApplicable(VehicleType vehicleType) => true;
}
// Weekend pricing strategy
public class WeekendPricingStrategy : IPricingStrategy
{
private readonly IPricingStrategy _baseStrategy;
public string StrategyName => "Weekend Pricing";
public WeekendPricingStrategy(IPricingStrategy baseStrategy)
{
_baseStrategy = baseStrategy;
}
public Money CalculateCharge(Duration duration, VehicleType vehicleType)
{
var baseCharge = _baseStrategy.CalculateCharge(duration, vehicleType);
// 20% surcharge on weekends
if (IsWeekend(DateTime.UtcNow))
{
return new Money(baseCharge.Amount * 1.2m);
}
return baseCharge;
}
private bool IsWeekend(DateTime date)
{
return date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday;
}
public bool IsApplicable(VehicleType vehicleType) => true;
}
// Early bird pricing strategy
public class EarlyBirdPricingStrategy : IPricingStrategy
{
public string StrategyName => "Early Bird Pricing";
public Money CalculateCharge(Duration duration, VehicleType vehicleType)
{
// Flat rate for entry before 9 AM and exit after 6 PM
var entryTime = DateTime.UtcNow - duration.Value;
if (entryTime.Hour < 9 && DateTime.UtcNow.Hour >= 18)
{
return vehicleType switch
{
VehicleType.Motorcycle => new Money(8.00m),
VehicleType.Car => new Money(12.00m),
VehicleType.Truck => new Money(20.00m),
_ => new Money(10.00m)
};
}
// Fall back to hourly pricing
var hourlyStrategy = new HourlyPricingStrategy();
return hourlyStrategy.CalculateCharge(duration, vehicleType);
}
public bool IsApplicable(VehicleType vehicleType) => true;
}
}
4. Payment Processing (Strategy Pattern)
namespace ParkingLot.Domain.Strategies
{
public interface IPaymentMethod
{
string MethodName { get; }
Task<PaymentResult> ProcessPaymentAsync(Money amount, Dictionary<string, string> paymentDetails);
}
public class CashPaymentMethod : IPaymentMethod
{
public string MethodName => "Cash";
public Task<PaymentResult> ProcessPaymentAsync(Money amount, Dictionary<string, string> paymentDetails)
{
// Cash payment is handled at the gate
return Task.FromResult(PaymentResult.Success(Guid.NewGuid().ToString()));
}
}
public class CreditCardPaymentMethod : IPaymentMethod
{
private readonly ILogger<CreditCardPaymentMethod> _logger;
public string MethodName => "Credit Card";
public CreditCardPaymentMethod(ILogger<CreditCardPaymentMethod> logger)
{
_logger = logger;
}
public async Task<PaymentResult> ProcessPaymentAsync(Money amount, Dictionary<string, string> paymentDetails)
{
_logger.LogInformation("Processing credit card payment of {Amount}", amount);
// Validate card details
if (!paymentDetails.TryGetValue("CardNumber", out var cardNumber) ||
!paymentDetails.TryGetValue("Expiry", out var expiry) ||
!paymentDetails.TryGetValue("CVV", out var cvv))
{
return PaymentResult.Failed("Missing credit card details");
}
// Simulate payment gateway call
await Task.Delay(500);
// Validate card (simplified)
if (cardNumber.Length != 16)
return PaymentResult.Failed("Invalid card number");
if (string.IsNullOrEmpty(cvv) || cvv.Length != 3)
return PaymentResult.Failed("Invalid CVV");
_logger.LogInformation("Credit card payment of {Amount} processed successfully", amount);
return PaymentResult.Success($"CC-{DateTime.Now.Ticks}");
}
}
public class MobilePaymentMethod : IPaymentMethod
{
private readonly ILogger<MobilePaymentMethod> _logger;
public string MethodName => "Mobile Payment";
public MobilePaymentMethod(ILogger<MobilePaymentMethod> logger)
{
_logger = logger;
}
public async Task<PaymentResult> ProcessPaymentAsync(Money amount, Dictionary<string, string> paymentDetails)
{
_logger.LogInformation("Processing mobile payment of {Amount}", amount);
if (!paymentDetails.TryGetValue("PhoneNumber", out var phoneNumber) ||
!paymentDetails.TryGetValue("Provider", out var provider))
{
return PaymentResult.Failed("Missing mobile payment details");
}
await Task.Delay(300);
_logger.LogInformation("Mobile payment of {Amount} processed successfully via {Provider}", amount, provider);
return PaymentResult.Success($"MP-{DateTime.Now.Ticks}");
}
}
public class PaymentResult
{
public bool IsSuccess { get; }
public string TransactionId { get; }
public string ErrorMessage { get; }
private PaymentResult(bool isSuccess, string transactionId, string errorMessage)
{
IsSuccess = isSuccess;
TransactionId = transactionId;
ErrorMessage = errorMessage;
}
public static PaymentResult Success(string transactionId)
=> new PaymentResult(true, transactionId, null);
public static PaymentResult Failed(string errorMessage)
=> new PaymentResult(false, null, errorMessage);
}
}
5. Parking Lot Aggregate Root
namespace ParkingLot.Domain.Aggregates
{
public class ParkingLot : IAggregateRoot
{
private readonly List<Floor> _floors = new();
private readonly Dictionary<string, ParkingTicket> _activeTickets = new();
private readonly List<ParkingTicket> _ticketHistory = new();
public int Id { get; }
public string Name { get; }
public IPricingStrategy PricingStrategy { get; private set; }
public IReadOnlyList<Floor> Floors => _floors.AsReadOnly();
public IReadOnlyDictionary<string, ParkingTicket> ActiveTickets => _activeTickets;
private static int _nextId = 1;
public ParkingLot(string name, IPricingStrategy pricingStrategy)
{
Id = _nextId++;
Name = name;
PricingStrategy = pricingStrategy;
}
public void AddFloor(int floorNumber, int compactSpots, int regularSpots, int largeSpots)
{
var floor = new Floor(floorNumber);
int spotId = 1;
for (int i = 1; i <= compactSpots; i++)
floor.AddSpot(new ParkingSpot(spotId++, floorNumber, i, ParkingSpotType.Compact));
for (int i = 1; i <= regularSpots; i++)
floor.AddSpot(new ParkingSpot(spotId++, floorNumber, i, ParkingSpotType.Regular));
for (int i = 1; i <= largeSpots; i++)
floor.AddSpot(new ParkingSpot(spotId++, floorNumber, i, ParkingSpotType.Large));
_floors.Add(floor);
}
public (ParkingSpot Spot, ParkingTicket Ticket) ParkVehicle(Vehicle vehicle)
{
// Find available spot
var spot = FindAvailableSpot(vehicle);
if (spot == null)
throw new InvalidOperationException("No available parking spots");
// Park the vehicle
spot.ParkVehicle(vehicle);
// Generate ticket
var ticket = new ParkingTicket(
vehicle.LicensePlate,
vehicle.Type,
spot.Id,
spot.FloorId
);
_activeTickets[ticket.TicketNumber] = ticket;
return (spot, ticket);
}
public ParkingTicket GetTicket(string ticketNumber)
{
if (_activeTickets.TryGetValue(ticketNumber, out var ticket))
return ticket;
return _ticketHistory.FirstOrDefault(t => t.TicketNumber == ticketNumber);
}
public Money CalculateCharge(string ticketNumber)
{
var ticket = GetTicket(ticketNumber);
if (ticket == null)
throw new ArgumentException($"Ticket {ticketNumber} not found");
if (ticket.Status == TicketStatus.Exited)
throw new InvalidOperationException("Ticket already exited");
var duration = ticket.GetDuration();
return PricingStrategy.CalculateCharge(duration, ticket.VehicleType);
}
public async Task<PaymentReceipt> ProcessPaymentAsync(
string ticketNumber,
PaymentMethod paymentMethod,
IPaymentMethod paymentProcessor,
Dictionary<string, string> paymentDetails)
{
var ticket = GetTicket(ticketNumber);
if (ticket == null)
throw new ArgumentException($"Ticket {ticketNumber} not found");
if (ticket.Status != TicketStatus.Active)
throw new InvalidOperationException($"Ticket is already {ticket.Status}");
var amount = CalculateCharge(ticketNumber);
var result = await paymentProcessor.ProcessPaymentAsync(amount, paymentDetails);
if (!result.IsSuccess)
throw new InvalidOperationException($"Payment failed: {result.ErrorMessage}");
ticket.Pay(amount, paymentMethod, result.TransactionId);
return new PaymentReceipt
{
TicketNumber = ticket.TicketNumber,
Amount = amount,
PaymentMethod = paymentMethod,
TransactionId = result.TransactionId,
PaidAt = DateTime.UtcNow
};
}
public ParkingSpot ExitVehicle(string ticketNumber, out ParkingTicket ticket)
{
ticket = GetTicket(ticketNumber);
if (ticket == null)
throw new ArgumentException($"Ticket {ticketNumber} not found");
if (ticket.Status != TicketStatus.Paid)
throw new InvalidOperationException("Ticket must be paid before exit");
// Find and vacate the spot
var spot = FindSpotById(ticket.ParkingSpotId);
if (spot == null)
throw new InvalidOperationException($"Parking spot {ticket.ParkingSpotId} not found");
spot.Vacate();
ticket.Exit(DateTime.UtcNow);
_activeTickets.Remove(ticketNumber);
_ticketHistory.Add(ticket);
return spot;
}
public int GetAvailableSpotsCount(ParkingSpotType? type = null)
{
return _floors.Sum(f => f.GetAvailableSpotsCount(type));
}
public Dictionary<int, Dictionary<ParkingSpotType, int>> GetAvailabilityByFloor()
{
var result = new Dictionary<int, Dictionary<ParkingSpotType, int>>();
foreach (var floor in _floors)
{
result[floor.FloorNumber] = floor.GetAvailabilityByType();
}
return result;
}
public void ChangePricingStrategy(IPricingStrategy newStrategy)
{
PricingStrategy = newStrategy;
}
private ParkingSpot FindAvailableSpot(Vehicle vehicle)
{
var requiredType = vehicle.GetRequiredSpotType();
foreach (var floor in _floors.OrderBy(f => f.FloorNumber))
{
var spot = floor.GetAvailableSpot(requiredType);
if (spot != null)
return spot;
}
// Try larger spots if compact/regular not available
if (requiredType == ParkingSpotType.Compact)
{
foreach (var floor in _floors)
{
var spot = floor.GetAvailableSpot(ParkingSpotType.Regular);
if (spot != null) return spot;
}
}
if (requiredType != ParkingSpotType.Large)
{
foreach (var floor in _floors)
{
var spot = floor.GetAvailableSpot(ParkingSpotType.Large);
if (spot != null) return spot;
}
}
return null;
}
private ParkingSpot FindSpotById(int spotId)
{
foreach (var floor in _floors)
{
var spot = floor.GetSpotById(spotId);
if (spot != null)
return spot;
}
return null;
}
}
public class Floor
{
private readonly List<ParkingSpot> _spots = new();
public int FloorNumber { get; }
public IReadOnlyList<ParkingSpot> Spots => _spots.AsReadOnly();
public Floor(int floorNumber)
{
FloorNumber = floorNumber;
}
public void AddSpot(ParkingSpot spot)
{
_spots.Add(spot);
}
public ParkingSpot GetAvailableSpot(ParkingSpotType type)
{
return _spots.FirstOrDefault(s => s.Type == type && !s.IsOccupied);
}
public ParkingSpot GetSpotById(int id)
{
return _spots.FirstOrDefault(s => s.Id == id);
}
public int GetAvailableSpotsCount(ParkingSpotType? type = null)
{
if (type.HasValue)
return _spots.Count(s => s.Type == type.Value && !s.IsOccupied);
return _spots.Count(s => !s.IsOccupied);
}
public Dictionary<ParkingSpotType, int> GetAvailabilityByType()
{
return Enum.GetValues<ParkingSpotType>()
.ToDictionary(t => t, t => _spots.Count(s => s.Type == t && !s.IsOccupied));
}
}
}
6. Repository Pattern
namespace ParkingLot.Domain.Repositories
{
public interface IParkingLotRepository
{
Task<ParkingLot> GetByIdAsync(int id);
Task<ParkingLot> GetDefaultAsync();
Task SaveAsync(ParkingLot parkingLot);
Task UpdateAsync(ParkingLot parkingLot);
}
public interface ITicketRepository
{
Task<ParkingTicket> GetByTicketNumberAsync(string ticketNumber);
Task<IEnumerable<ParkingTicket>> GetByLicensePlateAsync(string licensePlate);
Task<IEnumerable<ParkingTicket>> GetActiveTicketsAsync();
Task AddAsync(ParkingTicket ticket);
Task UpdateAsync(ParkingTicket ticket);
Task<IEnumerable<ParkingTicket>> GetTicketsByDateRangeAsync(DateTime start, DateTime end);
}
public interface IPaymentReceiptRepository
{
Task AddAsync(PaymentReceipt receipt);
Task<PaymentReceipt> GetByTicketNumberAsync(string ticketNumber);
Task<IEnumerable<PaymentReceipt>> GetByDateRangeAsync(DateTime start, DateTime end);
}
// In-memory implementation for demo
public class InMemoryParkingLotRepository : IParkingLotRepository
{
private static ParkingLot _defaultParkingLot;
private static readonly object _lock = new();
public Task<ParkingLot> GetByIdAsync(int id)
{
return Task.FromResult(_defaultParkingLot);
}
public Task<ParkingLot> GetDefaultAsync()
{
return Task.FromResult(_defaultParkingLot);
}
public Task SaveAsync(ParkingLot parkingLot)
{
lock (_lock)
{
_defaultParkingLot = parkingLot;
}
return Task.CompletedTask;
}
public Task UpdateAsync(ParkingLot parkingLot)
{
lock (_lock)
{
_defaultParkingLot = parkingLot;
}
return Task.CompletedTask;
}
public static void Initialize(ParkingLot parkingLot)
{
_defaultParkingLot = parkingLot;
}
}
}
7. Domain Services
namespace ParkingLot.Domain.Services
{
public interface IParkingService
{
Task<EntryResult> EntryAsync(string licensePlate, VehicleType vehicleType);
Task<ExitResult> ExitAsync(string ticketNumber);
Task<PaymentReceipt> PayAsync(string ticketNumber, PaymentMethod method, Dictionary<string, string> paymentDetails);
Task<Money> GetChargeAsync(string ticketNumber);
Task<ParkingAvailability> GetAvailabilityAsync();
}
public class ParkingService : IParkingService
{
private readonly IParkingLotRepository _parkingLotRepository;
private readonly ITicketRepository _ticketRepository;
private readonly IPaymentReceiptRepository _receiptRepository;
private readonly Dictionary<PaymentMethod, IPaymentMethod> _paymentProcessors;
private readonly ILogger<ParkingService> _logger;
public ParkingService(
IParkingLotRepository parkingLotRepository,
ITicketRepository ticketRepository,
IPaymentReceiptRepository receiptRepository,
IEnumerable<IPaymentMethod> paymentProcessors,
ILogger<ParkingService> logger)
{
_parkingLotRepository = parkingLotRepository;
_ticketRepository = ticketRepository;
_receiptRepository = receiptRepository;
_logger = logger;
_paymentProcessors = paymentProcessors.ToDictionary(p => Enum.Parse<PaymentMethod>(p.MethodName));
}
public async Task<EntryResult> EntryAsync(string licensePlate, VehicleType vehicleType)
{
_logger.LogInformation("Processing entry for {LicensePlate} ({VehicleType})", licensePlate, vehicleType);
var parkingLot = await _parkingLotRepository.GetDefaultAsync();
var vehicle = VehicleFactory.Create(licensePlate, vehicleType);
try
{
var (spot, ticket) = parkingLot.ParkVehicle(vehicle);
await _ticketRepository.AddAsync(ticket);
await _parkingLotRepository.UpdateAsync(parkingLot);
_logger.LogInformation("Vehicle {LicensePlate} parked at spot {SpotId} on floor {FloorId}, ticket {TicketNumber}",
licensePlate, spot.Id, spot.FloorId, ticket.TicketNumber);
return new EntryResult
{
Success = true,
TicketNumber = ticket.TicketNumber,
ParkingSpot = spot.ToString(),
EntryTime = ticket.EntryTime,
Message = "Entry successful"
};
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Entry failed for {LicensePlate}", licensePlate);
return new EntryResult
{
Success = false,
Message = ex.Message
};
}
}
public async Task<Money> GetChargeAsync(string ticketNumber)
{
var parkingLot = await _parkingLotRepository.GetDefaultAsync();
return parkingLot.CalculateCharge(ticketNumber);
}
public async Task<PaymentReceipt> PayAsync(string ticketNumber, PaymentMethod method, Dictionary<string, string> paymentDetails)
{
_logger.LogInformation("Processing payment for ticket {TicketNumber} via {Method}", ticketNumber, method);
var parkingLot = await _parkingLotRepository.GetDefaultAsync();
if (!_paymentProcessors.TryGetValue(method, out var processor))
throw new NotSupportedException($"Payment method {method} not supported");
var receipt = await parkingLot.ProcessPaymentAsync(ticketNumber, method, processor, paymentDetails);
await _receiptRepository.AddAsync(receipt);
await _parkingLotRepository.UpdateAsync(parkingLot);
_logger.LogInformation("Payment of {Amount} processed for ticket {TicketNumber}, transaction {TransactionId}",
receipt.Amount, ticketNumber, receipt.TransactionId);
return receipt;
}
public async Task<ExitResult> ExitAsync(string ticketNumber)
{
_logger.LogInformation("Processing exit for ticket {TicketNumber}", ticketNumber);
var parkingLot = await _parkingLotRepository.GetDefaultAsync();
try
{
var spot = parkingLot.ExitVehicle(ticketNumber, out var ticket);
await _parkingLotRepository.UpdateAsync(parkingLot);
await _ticketRepository.UpdateAsync(ticket);
_logger.LogInformation("Vehicle {LicensePlate} exited after {Duration}, paid {Amount}",
ticket.LicensePlate, ticket.GetDuration(), ticket.AmountPaid);
return new ExitResult
{
Success = true,
LicensePlate = ticket.LicensePlate,
EntryTime = ticket.EntryTime,
ExitTime = ticket.ExitTime.Value,
Duration = ticket.GetDuration(),
AmountPaid = ticket.AmountPaid,
ParkingSpot = spot.ToString(),
Message = "Exit successful"
};
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Exit failed for ticket {TicketNumber}", ticketNumber);
return new ExitResult
{
Success = false,
Message = ex.Message
};
}
}
public async Task<ParkingAvailability> GetAvailabilityAsync()
{
var parkingLot = await _parkingLotRepository.GetDefaultAsync();
return new ParkingAvailability
{
TotalSpots = parkingLot.Floors.Sum(f => f.Spots.Count),
AvailableSpots = parkingLot.GetAvailableSpotsCount(),
AvailableByType = new Dictionary<string, int>
{
["Compact"] = parkingLot.GetAvailableSpotsCount(ParkingSpotType.Compact),
["Regular"] = parkingLot.GetAvailableSpotsCount(ParkingSpotType.Regular),
["Large"] = parkingLot.GetAvailableSpotsCount(ParkingSpotType.Large)
},
AvailabilityByFloor = parkingLot.GetAvailabilityByFloor()
};
}
}
public class EntryResult
{
public bool Success { get; set; }
public string TicketNumber { get; set; }
public string ParkingSpot { get; set; }
public DateTime EntryTime { get; set; }
public string Message { get; set; }
}
public class ExitResult
{
public bool Success { get; set; }
public string LicensePlate { get; set; }
public DateTime EntryTime { get; set; }
public DateTime ExitTime { get; set; }
public Duration Duration { get; set; }
public Money AmountPaid { get; set; }
public string ParkingSpot { get; set; }
public string Message { get; set; }
}
public class PaymentReceipt
{
public string TicketNumber { get; set; }
public Money Amount { get; set; }
public PaymentMethod PaymentMethod { get; set; }
public string TransactionId { get; set; }
public DateTime PaidAt { get; set; }
public override string ToString()
{
return $@"
╔══════════════════════════════════════════════════════════╗
║ PARKING RECEIPT ║
╠══════════════════════════════════════════════════════════╣
║ Ticket: {TicketNumber,-46} ║
║ Amount: {Amount,-48} ║
║ Method: {PaymentMethod,-47} ║
║ Transaction: {TransactionId,-43} ║
║ Time: {PaidAt,-49} ║
╚══════════════════════════════════════════════════════════╝";
}
}
public class ParkingAvailability
{
public int TotalSpots { get; set; }
public int AvailableSpots { get; set; }
public Dictionary<string, int> AvailableByType { get; set; }
public Dictionary<int, Dictionary<ParkingSpotType, int>> AvailabilityByFloor { get; set; }
public override string ToString()
{
var sb = new StringBuilder();
sb.AppendLine($"Total Spots: {TotalSpots}");
sb.AppendLine($"Available Spots: {AvailableSpots}");
sb.AppendLine("Available by Type:");
foreach (var type in AvailableByType)
{
sb.AppendLine($" {type.Key}: {type.Value}");
}
sb.AppendLine("Availability by Floor:");
foreach (var floor in AvailabilityByFloor)
{
sb.AppendLine($" Floor {floor.Key}:");
foreach (var type in floor.Value)
{
sb.AppendLine($" {type.Key}: {type.Value}");
}
}
return sb.ToString();
}
}
}
8. API Controllers
namespace ParkingLot.API.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class ParkingController : ControllerBase
{
private readonly IParkingService _parkingService;
private readonly ILogger<ParkingController> _logger;
public ParkingController(IParkingService parkingService, ILogger<ParkingController> logger)
{
_parkingService = parkingService;
_logger = logger;
}
[HttpGet("availability")]
public async Task<IActionResult> GetAvailability()
{
var availability = await _parkingService.GetAvailabilityAsync();
return Ok(availability);
}
[HttpPost("entry")]
public async Task<IActionResult> Entry([FromBody] EntryRequest request)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var result = await _parkingService.EntryAsync(request.LicensePlate, request.VehicleType);
if (!result.Success)
return BadRequest(result);
return Ok(result);
}
[HttpGet("tickets/{ticketNumber}/charge")]
public async Task<IActionResult> GetCharge(string ticketNumber)
{
try
{
var charge = await _parkingService.GetChargeAsync(ticketNumber);
return Ok(new { TicketNumber = ticketNumber, Amount = charge });
}
catch (ArgumentException ex)
{
return NotFound(ex.Message);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("tickets/{ticketNumber}/pay")]
public async Task<IActionResult> Pay(string ticketNumber, [FromBody] PaymentRequest request)
{
try
{
var receipt = await _parkingService.PayAsync(ticketNumber, request.Method, request.PaymentDetails);
return Ok(receipt);
}
catch (ArgumentException ex)
{
return NotFound(ex.Message);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("tickets/{ticketNumber}/exit")]
public async Task<IActionResult> Exit(string ticketNumber)
{
var result = await _parkingService.ExitAsync(ticketNumber);
if (!result.Success)
return BadRequest(result);
return Ok(result);
}
}
public class EntryRequest
{
[Required]
[MinLength(5)]
[MaxLength(10)]
public string LicensePlate { get; set; }
[Required]
public VehicleType VehicleType { get; set; }
}
public class PaymentRequest
{
[Required]
public PaymentMethod Method { get; set; }
public Dictionary<string, string> PaymentDetails { get; set; }
}
}
9. Dependency Injection Setup
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Register repositories
builder.Services.AddSingleton<IParkingLotRepository, InMemoryParkingLotRepository>();
builder.Services.AddSingleton<ITicketRepository, InMemoryTicketRepository>();
builder.Services.AddSingleton<IPaymentReceiptRepository, InMemoryPaymentReceiptRepository>();
// Register payment methods
builder.Services.AddScoped<IPaymentMethod, CashPaymentMethod>();
builder.Services.AddScoped<IPaymentMethod, CreditCardPaymentMethod>();
builder.Services.AddScoped<IPaymentMethod, MobilePaymentMethod>();
// Register domain services
builder.Services.AddScoped<IParkingService, ParkingService>();
// Configure pricing strategy
builder.Services.AddSingleton<IPricingStrategy>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var strategy = config["PricingStrategy"] ?? "Hourly";
IPricingStrategy baseStrategy = strategy switch
{
"FlatRate" => new FlatRatePricingStrategy(),
"EarlyBird" => new EarlyBirdPricingStrategy(),
_ => new HourlyPricingStrategy()
};
// Apply weekend surcharge if configured
if (config.GetValue<bool>("WeekendSurcharge", false))
{
return new WeekendPricingStrategy(baseStrategy);
}
return baseStrategy;
});
// Initialize parking lot
var parkingLot = new ParkingLot("Central Parking", builder.Services.BuildServiceProvider().GetRequiredService<IPricingStrategy>());
parkingLot.AddFloor(1, compactSpots: 20, regularSpots: 50, largeSpots: 10);
parkingLot.AddFloor(2, compactSpots: 15, regularSpots: 40, largeSpots: 8);
parkingLot.AddFloor(3, compactSpots: 10, regularSpots: 30, largeSpots: 5);
InMemoryParkingLotRepository.Initialize(parkingLot);
var app = builder.Build();
// Configure pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
10. Concurrency Handling
namespace ParkingLot.Infrastructure.Concurrency
{
public class ParkingSpotReservationService
{
private readonly SemaphoreSlim _semaphore;
private readonly ConcurrentDictionary<int, string> _reservations;
public ParkingSpotReservationService(int maxConcurrentOperations = 10)
{
_semaphore = new SemaphoreSlim(maxConcurrentOperations);
_reservations = new ConcurrentDictionary<int, string>();
}
public async Task<bool> TryReserveSpotAsync(int spotId, string ticketNumber, TimeSpan timeout)
{
// Acquire semaphore to limit concurrent operations
if (!await _semaphore.WaitAsync(timeout))
return false;
try
{
// Try to reserve the spot
return _reservations.TryAdd(spotId, ticketNumber);
}
finally
{
_semaphore.Release();
}
}
public bool ReleaseSpot(int spotId, string ticketNumber)
{
return _reservations.TryRemove(spotId, out var reservedFor) && reservedFor == ticketNumber;
}
public bool IsSpotReserved(int spotId)
{
return _reservations.ContainsKey(spotId);
}
public string GetReservationTicket(int spotId)
{
return _reservations.TryGetValue(spotId, out var ticket) ? ticket : null;
}
}
// Optimistic concurrency with versioning
public class ParkingSpotWithVersion : ParkingSpot
{
public int Version { get; private set; }
public ParkingSpotWithVersion(int id, int floorId, int spotNumber, ParkingSpotType type)
: base(id, floorId, spotNumber, type)
{
Version = 1;
}
public new void ParkVehicle(Vehicle vehicle)
{
base.ParkVehicle(vehicle);
Version++;
}
public new void Vacate()
{
base.Vacate();
Version++;
}
public bool TryUpdate(Action updateAction, int expectedVersion)
{
if (Version != expectedVersion)
return false;
updateAction();
Version++;
return true;
}
}
}
11. Unit Tests
namespace ParkingLot.Tests
{
public class ParkingLotTests
{
[Fact]
public void ParkVehicle_WhenSpotAvailable_ShouldGenerateTicket()
{
// Arrange
var pricingStrategy = new HourlyPricingStrategy();
var parkingLot = new ParkingLot("Test Lot", pricingStrategy);
parkingLot.AddFloor(1, compactSpots: 1, regularSpots: 0, largeSpots: 0);
var vehicle = new Car("ABC123");
// Act
var (spot, ticket) = parkingLot.ParkVehicle(vehicle);
// Assert
Assert.NotNull(ticket);
Assert.NotNull(ticket.TicketNumber);
Assert.Equal(TicketStatus.Active, ticket.Status);
Assert.True(spot.IsOccupied);
}
[Fact]
public void ParkVehicle_WhenNoSpotsAvailable_ShouldThrowException()
{
// Arrange
var pricingStrategy = new HourlyPricingStrategy();
var parkingLot = new ParkingLot("Test Lot", pricingStrategy);
parkingLot.AddFloor(1, compactSpots: 0, regularSpots: 0, largeSpots: 0);
var vehicle = new Car("ABC123");
// Act & Assert
Assert.Throws<InvalidOperationException>(() => parkingLot.ParkVehicle(vehicle));
}
[Fact]
public void CalculateCharge_ForOneHour_ShouldReturnCorrectAmount()
{
// Arrange
var pricingStrategy = new HourlyPricingStrategy();
var parkingLot = new ParkingLot("Test Lot", pricingStrategy);
parkingLot.AddFloor(1, compactSpots: 1, regularSpots: 0, largeSpots: 0);
var vehicle = new Car("ABC123");
var (_, ticket) = parkingLot.ParkVehicle(vehicle);
// Simulate time passing
var duration = Duration.FromDateTime(ticket.EntryTime, ticket.EntryTime.AddHours(1.5));
// Act
var charge = pricingStrategy.CalculateCharge(duration, vehicle.Type);
// Assert
Assert.Equal(5.00m, charge.Amount); // 2.50 * 2 hours (ceil)
}
[Theory]
[InlineData("ABC123", VehicleType.Car, true)]
[InlineData("XYZ789", VehicleType.Motorcycle, true)]
[InlineData("TRUCK1", VehicleType.Truck, true)]
public void ParkVehicle_WithDifferentVehicleTypes_ShouldWork(string plate, VehicleType type, bool shouldSucceed)
{
// Arrange
var pricingStrategy = new HourlyPricingStrategy();
var parkingLot = new ParkingLot("Test Lot", pricingStrategy);
parkingLot.AddFloor(1, compactSpots: 5, regularSpots: 5, largeSpots: 5);
var vehicle = VehicleFactory.Create(plate, type);
// Act
var exception = Record.Exception(() => parkingLot.ParkVehicle(vehicle));
// Assert
if (shouldSucceed)
Assert.Null(exception);
else
Assert.NotNull(exception);
}
}
public class PricingStrategyTests
{
[Fact]
public void HourlyPricing_ForMotorcycle_ShouldCalculateCorrectly()
{
var strategy = new HourlyPricingStrategy();
var duration = Duration.FromDateTime(DateTime.Now, DateTime.Now.AddHours(2.5));
var charge = strategy.CalculateCharge(duration, VehicleType.Motorcycle);
Assert.Equal(3.00m, charge.Amount); // 1.00 * 3 hours
}
[Fact]
public void FlatRatePricing_ForCar_ShouldCalculateCorrectly()
{
var strategy = new FlatRatePricingStrategy();
var duration = Duration.FromDateTime(DateTime.Now, DateTime.Now.AddHours(3));
var charge = strategy.CalculateCharge(duration, VehicleType.Car);
Assert.Equal(10.00m, charge.Amount);
}
[Fact]
public void FlatRatePricing_ForCar_FirstHourFree()
{
var strategy = new FlatRatePricingStrategy();
var duration = Duration.FromDateTime(DateTime.Now, DateTime.Now.AddMinutes(45));
var charge = strategy.CalculateCharge(duration, VehicleType.Car);
Assert.Equal(0m, charge.Amount);
}
}
}
12. Summary
In this case study, we've built a complete Parking Lot System applying:
- Domain-Driven Design - Entities, Value Objects, Aggregates, Repositories
- Strategy Pattern - For pricing strategies and payment methods
- Factory Pattern - For creating vehicles
- Repository Pattern - For data access abstraction
- Domain Events - For ticket lifecycle events
- SOLID Principles - Single responsibility, open/closed, dependency inversion
- Concurrency Handling - Semaphores, optimistic locking
- Unit Testing - Comprehensive test coverage
13. Practice Extensions
Try extending this system with:
- Reservation System - Allow customers to reserve spots in advance
- Loyalty Program - Discounts for frequent parkers
- License Plate Recognition - Automatic entry/exit
- Mobile App Integration - Find and reserve spots via mobile
- Dynamic Pricing - Prices based on demand and time of day
- Electric Vehicle Charging - Special spots with charging stations
- Monthly Pass - Subscription model for regular customers
📝 Key Takeaways:
- Start with requirements analysis and domain language
- Model entities and value objects based on business concepts
- Use aggregates to maintain consistency boundaries
- Apply appropriate design patterns for flexibility
- Handle concurrency carefully in real-world systems
- Write comprehensive unit tests for core logic
Happy Coding! 🚀