Loading learning content...
With our requirements established, we now embark on the most creative phase of object-oriented design: entity identification. This is where the abstract problem domain becomes concrete—where nouns transform into classes and behaviors into methods.
The parking lot domain has four fundamental entities that form the core of any implementation:
Each entity carries distinct responsibilities, and how we model them determines our system's flexibility, maintainability, and elegance. In this page, we'll examine each entity in exhaustive detail.
By the end of this page, you will understand how to extract entities from requirements, define their attributes and behaviors, apply class hierarchies for type variations, and ensure each entity adheres to single responsibility. You'll see entity modeling as the bridge between requirements and implementation.
A vehicle is the primary actor in our system. It enters, occupies a spot for some duration, and exits. Our requirements specify three vehicle types: motorcycle, car, and bus. Each has different space requirements.
The fundamental question: Inheritance or Composition?
When modeling types, we face a classic design decision. Should we:
The answer depends on behavioral differences. If different vehicle types have fundamentally different behaviors—different methods entirely—inheritance makes sense. If they share the same behaviors but differ only in properties, composition (type as attribute) is cleaner.
| Aspect | Motorcycle | Car | Bus | Variation Type |
|---|---|---|---|---|
| Can be parked? | Yes | Yes | Yes | Same behavior |
| Has license plate? | Yes | Yes | Yes | Same behavior |
| Space requirement | Small | Medium | Large | Property difference |
| Allowed spot types | Motorcycle, Compact, Large | Compact, Large | Large only | Property difference |
| Pricing rate | Different | Different | Different | Property difference |
Analysis: All vehicles share the same behaviors (park, unpark, have a license plate). The differences are purely in allowed spots and pricing—both of which are property-based. This suggests composition over inheritance: a single Vehicle class with a VehicleType enum.
However, we should design for extensibility. What if future vehicles need special behaviors? Electric vehicles might need charging status. Handicapped vehicles might have priority allocation. The solution: use an interface/abstract class pattern that allows extension while keeping the common case simple.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// Vehicle Type enumeration - extensible for new typesenum VehicleType { MOTORCYCLE = 'MOTORCYCLE', CAR = 'CAR', BUS = 'BUS', // Future: ELECTRIC_CAR, HANDICAPPED, etc.} // Vehicle interface - defines the contractinterface IVehicle { readonly licensePlate: string; readonly type: VehicleType; // What spot types can this vehicle use? getCompatibleSpotTypes(): SpotType[]; // What's the default/preferred spot type? getPreferredSpotType(): SpotType;} // Concrete Vehicle implementationclass Vehicle implements IVehicle { readonly licensePlate: string; readonly type: VehicleType; constructor(licensePlate: string, type: VehicleType) { this.licensePlate = licensePlate; this.type = type; } getCompatibleSpotTypes(): SpotType[] { // Each vehicle type has a hierarchy of acceptable spots // Preference order: smallest acceptable → largest switch (this.type) { case VehicleType.MOTORCYCLE: return [SpotType.MOTORCYCLE, SpotType.COMPACT, SpotType.LARGE]; case VehicleType.CAR: return [SpotType.COMPACT, SpotType.LARGE]; case VehicleType.BUS: return [SpotType.LARGE]; default: throw new Error(`Unknown vehicle type: ${this.type}`); } } getPreferredSpotType(): SpotType { // First compatible type is the preferred one return this.getCompatibleSpotTypes()[0]; }} // Factory for creating vehicles - centralizes creation logicclass VehicleFactory { static createMotorcycle(licensePlate: string): Vehicle { return new Vehicle(licensePlate, VehicleType.MOTORCYCLE); } static createCar(licensePlate: string): Vehicle { return new Vehicle(licensePlate, VehicleType.CAR); } static createBus(licensePlate: string): Vehicle { return new Vehicle(licensePlate, VehicleType.BUS); } static create(licensePlate: string, type: VehicleType): Vehicle { return new Vehicle(licensePlate, type); }}The VehicleFactory might seem like overkill for a simple constructor. But it provides extension points: adding validation (license plate format), logging, or switching to subclasses later—all without changing client code. It's minimal cost for maximum future flexibility.
A parking spot is a physical location that can hold exactly one vehicle. Like vehicles, spots come in types: motorcycle, compact, and large. Each spot type can accommodate certain vehicle types.
Spot State Machine:
A spot is fundamentally a stateful entity. It transitions between states:
[AVAILABLE] ---park(vehicle)---> [OCCUPIED]
[OCCUPIED] ---vacate()-------> [AVAILABLE]
This is a simple two-state machine, but recognizing it as a state machine opens doors: we might later add states like RESERVED, MAINTENANCE, or OUT_OF_SERVICE.
| Spot Type | Fits Motorcycle? | Fits Car? | Fits Bus? | Size (sq ft) |
|---|---|---|---|---|
| MOTORCYCLE | ✓ | ✗ | ✗ | ~50 |
| COMPACT | ✓ (not preferred) | ✓ | ✗ | ~100 |
| LARGE | ✓ (waste of space) | ✓ (not preferred) | ✓ | ~200+ |
Key Design Decisions:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
// Spot types with clear hierarchyenum SpotType { MOTORCYCLE = 'MOTORCYCLE', COMPACT = 'COMPACT', LARGE = 'LARGE',} // Spot status - explicit state machine statesenum SpotStatus { AVAILABLE = 'AVAILABLE', OCCUPIED = 'OCCUPIED', // Future: RESERVED, MAINTENANCE, OUT_OF_SERVICE} // Interface for parking spot operationsinterface IParkingSpot { readonly id: string; readonly floor: number; readonly row: number; readonly spotNumber: number; readonly type: SpotType; getStatus(): SpotStatus; isAvailable(): boolean; getParkedVehicle(): IVehicle | null; canFit(vehicle: IVehicle): boolean; park(vehicle: IVehicle): void; vacate(): IVehicle | null;} // Concrete implementationclass ParkingSpot implements IParkingSpot { readonly id: string; readonly floor: number; readonly row: number; readonly spotNumber: number; readonly type: SpotType; private status: SpotStatus = SpotStatus.AVAILABLE; private parkedVehicle: IVehicle | null = null; constructor( floor: number, row: number, spotNumber: number, type: SpotType ) { this.floor = floor; this.row = row; this.spotNumber = spotNumber; this.type = type; // Generate unique ID: "F1-R2-S15" format this.id = `F${floor}-R${row}-S${spotNumber}`; } getStatus(): SpotStatus { return this.status; } isAvailable(): boolean { return this.status === SpotStatus.AVAILABLE; } getParkedVehicle(): IVehicle | null { return this.parkedVehicle; } /** * Check if a vehicle can fit in this spot * A vehicle can fit if this spot type is in its compatible list */ canFit(vehicle: IVehicle): boolean { return vehicle.getCompatibleSpotTypes().includes(this.type); } /** * Park a vehicle in this spot * Throws if spot is occupied or vehicle doesn't fit */ park(vehicle: IVehicle): void { if (!this.isAvailable()) { throw new Error(`Spot ${this.id} is not available`); } if (!this.canFit(vehicle)) { throw new Error( `Vehicle type ${vehicle.type} cannot fit in ${this.type} spot` ); } this.parkedVehicle = vehicle; this.status = SpotStatus.OCCUPIED; } /** * Remove the vehicle from this spot * Returns the vehicle that was parked (for ticket processing) */ vacate(): IVehicle | null { const vehicle = this.parkedVehicle; this.parkedVehicle = null; this.status = SpotStatus.AVAILABLE; return vehicle; }} // Factory for creating spots (useful for bulk initialization)class ParkingSpotFactory { static createSpot( floor: number, row: number, number: number, type: SpotType ): ParkingSpot { return new ParkingSpot(floor, row, number, type); } /** * Create a range of spots of a given type * Useful for initializing floors with specific layouts */ static createSpotRange( floor: number, row: number, startNumber: number, count: number, type: SpotType ): ParkingSpot[] { const spots: ParkingSpot[] = []; for (let i = 0; i < count; i++) { spots.push(new ParkingSpot(floor, row, startNumber + i, type)); } return spots; }}Notice the synchronized keyword in the Java version. Since multiple entry gates might try to allocate spots simultaneously, the park() and vacate() methods need thread safety. However, synchronizing at the spot level is granular—it allows parallel operations on different spots. We'll discuss concurrency more in the design section.
The Ticket is the transaction record—the proof of a vehicle's stay in the parking lot. It links the vehicle, the spot, the entry time, and eventually the exit time and payment. Unlike Vehicle and ParkingSpot (which represent physical things), Ticket is a domain event artifact.
Ticket Lifecycle:
[ACTIVE] ---exit(time)---> [PAID]
[ACTIVE] ---lost()-------> [LOST] (requires special handling)
What the Ticket Must Track:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
// Ticket status represents the lifecycle stateenum TicketStatus { ACTIVE = 'ACTIVE', // Vehicle is in the lot PAID = 'PAID', // Vehicle has exited and paid LOST = 'LOST', // Ticket lost, special pricing applies} // Immutable ticket data - captured at entry timeinterface TicketData { readonly ticketId: string; readonly vehicleLicensePlate: string; readonly vehicleType: VehicleType; readonly spotId: string; readonly entryTime: Date;} // Full ticket interface with mutable exit datainterface ITicket extends TicketData { getStatus(): TicketStatus; getExitTime(): Date | null; getPaidAmount(): number | null; getDurationMinutes(): number; markExited(exitTime: Date, amount: number): void; markLost(): void;} // Ticket implementationclass Ticket implements ITicket { readonly ticketId: string; readonly vehicleLicensePlate: string; readonly vehicleType: VehicleType; readonly spotId: string; readonly entryTime: Date; private status: TicketStatus = TicketStatus.ACTIVE; private exitTime: Date | null = null; private paidAmount: number | null = null; constructor( ticketId: string, vehicle: IVehicle, spot: IParkingSpot, entryTime: Date ) { this.ticketId = ticketId; this.vehicleLicensePlate = vehicle.licensePlate; this.vehicleType = vehicle.type; this.spotId = spot.id; this.entryTime = entryTime; } getStatus(): TicketStatus { return this.status; } getExitTime(): Date | null { return this.exitTime; } getPaidAmount(): number | null { return this.paidAmount; } /** * Calculate duration from entry to now (or exit time if set) * Returns duration in minutes for billing precision */ getDurationMinutes(): number { const endTime = this.exitTime || new Date(); const diffMs = endTime.getTime() - this.entryTime.getTime(); return Math.ceil(diffMs / (1000 * 60)); // Round up to next minute } /** * Calculate duration in hours (rounded up for billing) */ getDurationHours(): number { return Math.ceil(this.getDurationMinutes() / 60); } /** * Mark ticket as exited and paid */ markExited(exitTime: Date, amount: number): void { if (this.status !== TicketStatus.ACTIVE) { throw new Error( `Cannot exit ticket in status ${this.status}` ); } if (exitTime < this.entryTime) { throw new Error('Exit time cannot be before entry time'); } this.exitTime = exitTime; this.paidAmount = amount; this.status = TicketStatus.PAID; } /** * Mark ticket as lost - triggers special handling */ markLost(): void { if (this.status !== TicketStatus.ACTIVE) { throw new Error( `Cannot mark as lost ticket in status ${this.status}` ); } this.status = TicketStatus.LOST; }} // Ticket ID generator - could use UUID, sequential, or custom formatclass TicketIdGenerator { private static counter = 0; static generate(): string { const timestamp = Date.now().toString(36).toUpperCase(); const sequence = (++this.counter).toString().padStart(6, '0'); return `TKT-${timestamp}-${sequence}`; } static generateUUID(): string { // In production, use a proper UUID library return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); }}Notice that vehicle info is copied into the ticket (license plate and type) rather than holding a reference to the Vehicle object. This prevents issues if the Vehicle object changes or goes out of scope. The ticket becomes a self-contained historical record—crucial for auditing and dispute resolution.
The ParkingLot is the aggregate root—the central entity that coordinates all others. It's the entry point for all operations: parking vehicles, retrieving availability, managing exits. This is where the rubber meets the road.
Responsibilities of ParkingLot:
Key Design Decision: What does ParkingLot know about?
The ParkingLot should know about spots and tickets, but should it know about pricing? Here we apply the Single Responsibility Principle: ParkingLot handles space management; a separate PricingStrategy handles fee calculation. This separation allows pricing to change independently of the lot structure.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
// Parking lot configurationinterface ParkingLotConfig { name: string; address: string; floors: number; spotsPerFloor: Map<SpotType, number>;} // Interface for the ParkingLot aggregateinterface IParkingLot { readonly name: string; readonly address: string; // Availability queries getTotalCapacity(): number; getAvailableSpots(): number; getAvailableSpotsByType(type: SpotType): number; hasAvailableSpot(vehicleType: VehicleType): boolean; // Core operations parkVehicle(vehicle: IVehicle): Ticket; exitVehicle(ticket: Ticket): number; // Information getSpotInfo(spotId: string): IParkingSpot | null; getActiveTickets(): Ticket[];} // Result type for parking operationsinterface ParkingResult { success: boolean; ticket?: Ticket; spot?: IParkingSpot; error?: string;} // ParkingLot implementationclass ParkingLot implements IParkingLot { readonly name: string; readonly address: string; // Spot storage - organized by type for efficient lookup private spotsByType: Map<SpotType, IParkingSpot[]> = new Map(); private allSpots: Map<string, IParkingSpot> = new Map(); // Active tickets indexed by ticket ID private activeTickets: Map<string, Ticket> = new Map(); // Pricing strategy - injected dependency private pricingStrategy: IPricingStrategy; // Spot allocation strategy - injected dependency private allocationStrategy: ISpotAllocationStrategy; constructor( config: ParkingLotConfig, pricingStrategy: IPricingStrategy, allocationStrategy: ISpotAllocationStrategy ) { this.name = config.name; this.address = config.address; this.pricingStrategy = pricingStrategy; this.allocationStrategy = allocationStrategy; // Initialize spots based on configuration this.initializeSpots(config); } private initializeSpots(config: ParkingLotConfig): void { // Initialize the spotsByType map for (const type of Object.values(SpotType)) { this.spotsByType.set(type as SpotType, []); } // Create spots for each floor for (let floor = 1; floor <= config.floors; floor++) { let spotNumber = 1; for (const [type, count] of config.spotsPerFloor) { const spots = ParkingSpotFactory.createSpotRange( floor, 1, // row spotNumber, count, type ); for (const spot of spots) { this.spotsByType.get(type)!.push(spot); this.allSpots.set(spot.id, spot); } spotNumber += count; } } } getTotalCapacity(): number { return this.allSpots.size; } getAvailableSpots(): number { let count = 0; for (const spot of this.allSpots.values()) { if (spot.isAvailable()) count++; } return count; } getAvailableSpotsByType(type: SpotType): number { const spots = this.spotsByType.get(type) || []; return spots.filter(s => s.isAvailable()).length; } /** * Check if we can accommodate a vehicle of the given type */ hasAvailableSpot(vehicleType: VehicleType): boolean { // Create a temporary vehicle to check compatible types const tempVehicle = new Vehicle('CHECK', vehicleType); const compatibleTypes = tempVehicle.getCompatibleSpotTypes(); for (const spotType of compatibleTypes) { if (this.getAvailableSpotsByType(spotType) > 0) { return true; } } return false; } /** * Park a vehicle - the main entry flow */ parkVehicle(vehicle: IVehicle): Ticket { // Use allocation strategy to find a spot const spot = this.allocationStrategy.findSpot( vehicle, this.spotsByType ); if (!spot) { throw new Error( `No available spot for vehicle type ${vehicle.type}` ); } // Park the vehicle spot.park(vehicle); // Create and register the ticket const ticket = new Ticket( TicketIdGenerator.generate(), vehicle, spot, new Date() ); this.activeTickets.set(ticket.ticketId, ticket); return ticket; } /** * Exit a vehicle - the main exit flow * Returns the fee amount */ exitVehicle(ticket: Ticket): number { // Validate ticket if (!this.activeTickets.has(ticket.ticketId)) { throw new Error(`Ticket ${ticket.ticketId} not found or already used`); } // Find and vacate the spot const spot = this.allSpots.get(ticket.spotId); if (spot) { spot.vacate(); } // Calculate fee using pricing strategy const fee = this.pricingStrategy.calculateFee(ticket); // Mark ticket as paid ticket.markExited(new Date(), fee); // Remove from active tickets this.activeTickets.delete(ticket.ticketId); return fee; } getSpotInfo(spotId: string): IParkingSpot | null { return this.allSpots.get(spotId) || null; } getActiveTickets(): Ticket[] { return Array.from(this.activeTickets.values()); }}Notice how ParkingLot receives its pricingStrategy and allocationStrategy through the constructor. This is dependency injection—the lot doesn't know which concrete strategy it's using. This design allows testing with mock strategies, and changing strategies at runtime without modifying ParkingLot code.
With all four entities defined, let's visualize how they relate to each other. Understanding these relationships is crucial for your interview whiteboard sketches.
| Relationship | Type | Cardinality | Explanation |
|---|---|---|---|
| ParkingLot → ParkingSpot | Composition | 1 to many | Lot owns its spots; spots don't exist without a lot |
| ParkingLot → Ticket | Aggregation | 1 to many | Lot manages tickets; tickets could exist independently for archival |
| ParkingSpot → Vehicle | Aggregation | 1 to 0..1 | Spot may hold a vehicle; vehicle exists independently |
| Ticket → Vehicle | Association | Many to 1 | Ticket references vehicle info; doesn't own the vehicle |
| Ticket → ParkingSpot | Association | Many to 1 | Ticket records which spot was used |
Our design uses several enumerations and could benefit from value objects. Let's consolidate these supporting types.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
// ============================================// ENUMERATIONS// ============================================ // Vehicle types - can be extended for new categoriesenum VehicleType { MOTORCYCLE = 'MOTORCYCLE', CAR = 'CAR', BUS = 'BUS', // Extension points: // ELECTRIC_CAR = 'ELECTRIC_CAR', // HANDICAPPED = 'HANDICAPPED', // VIP = 'VIP',} // Spot types - physical spot categoriesenum SpotType { MOTORCYCLE = 'MOTORCYCLE', // ~50 sq ft COMPACT = 'COMPACT', // ~100 sq ft LARGE = 'LARGE', // ~200 sq ft // Extension points: // ELECTRIC = 'ELECTRIC', // With charging station // HANDICAPPED = 'HANDICAPPED',} // Spot status - state machine statesenum SpotStatus { AVAILABLE = 'AVAILABLE', OCCUPIED = 'OCCUPIED', // Extension points: // RESERVED = 'RESERVED', // MAINTENANCE = 'MAINTENANCE', // OUT_OF_SERVICE = 'OUT_OF_SERVICE',} // Ticket status - lifecycle statesenum TicketStatus { ACTIVE = 'ACTIVE', PAID = 'PAID', LOST = 'LOST', // Extension points: // EXPIRED = 'EXPIRED', // DISPUTED = 'DISPUTED',} // ============================================// VALUE OBJECTS// ============================================ // Money value object - encapsulates currency operationsclass Money { readonly amount: number; readonly currency: string; constructor(amount: number, currency: string = 'USD') { if (amount < 0) { throw new Error('Money amount cannot be negative'); } this.amount = Math.round(amount * 100) / 100; // 2 decimal places this.currency = currency; } add(other: Money): Money { this.assertSameCurrency(other); return new Money(this.amount + other.amount, this.currency); } multiply(factor: number): Money { return new Money(this.amount * factor, this.currency); } private assertSameCurrency(other: Money): void { if (this.currency !== other.currency) { throw new Error('Cannot operate on different currencies'); } } toString(): string { return `${this.currency} ${this.amount.toFixed(2)}`; } static zero(currency: string = 'USD'): Money { return new Money(0, currency); }} // Duration value object - parking durationclass Duration { readonly minutes: number; constructor(minutes: number) { if (minutes < 0) { throw new Error('Duration cannot be negative'); } this.minutes = Math.ceil(minutes); } get hours(): number { return Math.ceil(this.minutes / 60); } get days(): number { return Math.ceil(this.minutes / (60 * 24)); } static between(start: Date, end: Date): Duration { const diffMs = end.getTime() - start.getTime(); const minutes = diffMs / (1000 * 60); return new Duration(minutes); } toString(): string { if (this.minutes < 60) { return `${this.minutes} minutes`; } const h = Math.floor(this.minutes / 60); const m = this.minutes % 60; return m > 0 ? `${h}h ${m}m` : `${h} hours`; }} // Spot Location value objectclass SpotLocation { readonly floor: number; readonly row: number; readonly spotNumber: number; constructor(floor: number, row: number, spotNumber: number) { this.floor = floor; this.row = row; this.spotNumber = spotNumber; } toId(): string { return `F${this.floor}-R${this.row}-S${this.spotNumber}`; } toString(): string { return `Floor ${this.floor}, Row ${this.row}, Spot ${this.spotNumber}`; }}Using Money instead of 'number' and Duration instead of raw minutes prevents primitive obsession—a code smell where we use primitives for domain concepts. Value objects encapsulate validation, provide meaningful methods, and make the code self-documenting. In interviews, mentioning value objects shows sophisticated domain modeling.
What's next:
With entities defined, we need to establish how they work together through relationships and interactions. The next page dives deeper into class relationships, exploring inheritance hierarchies, dependency patterns, and the critical design decisions that make or break maintainability.
You now have a complete entity model for the Parking Lot System. Each entity has clear responsibilities, well-defined interfaces, and appropriate encapsulation. Next, we'll explore how these entities collaborate through carefully designed relationships.