Loading content...
Entities don't exist in isolation—they collaborate, depend on each other, and form hierarchies. The relationships between classes are the invisible architecture of your system, and getting them right is what separates elegant designs from tangled messes.
In this page, we'll explore four fundamental relationship types:
For the parking lot system, we'll examine each relationship critically, understanding not just what they are, but why we choose one over another.
By the end of this page, you will understand how to model class relationships with precision, when to use inheritance versus composition, how to identify aggregate roots and boundaries, and how to draw UML diagrams that communicate your design clearly in interviews.
Inheritance creates IS-A relationships: a Motorcycle IS-A Vehicle; a CompactSpot IS-A ParkingSpot. In our parking lot design, we have potential inheritance hierarchies for both vehicles and spots.
The Inheritance Question:
Should we use inheritance for Vehicle and ParkingSpot types, or model them with composition (type as a property)? Let's analyze both approaches rigorously.
The Decision Framework:
| Question | Answer for Vehicles | Implication |
|---|---|---|
| Do types have different behaviors? | No - all park, unpark, have plates | Favors composition |
| Do types have different attributes? | Minimal - just type-specific capacity | Favors composition |
| Will types be added at runtime? | Possibly - new vehicle categories | Favors composition |
| Is type safety critical? | Moderately - spot allocation matters | Neutral |
| Do types form natural hierarchy? | Loosely - all are vehicles | Neutral |
Conclusion: For this design, composition is preferable for both Vehicle and ParkingSpot. The behavioral differences are minimal, and the flexibility of adding types through configuration outweighs the benefits of rigid class hierarchies.
However, we'll use interfaces to maintain polymorphism benefits.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Interface defines the contract - behavior-focusedinterface IVehicle { readonly licensePlate: string; readonly type: VehicleType; getCompatibleSpotTypes(): SpotType[]; getPreferredSpotType(): SpotType;} // Concrete implementation - data-focusedclass Vehicle implements IVehicle { readonly licensePlate: string; readonly type: VehicleType; // Type-specific logic encapsulated in a strategy/map private static readonly COMPATIBILITY_MAP: Map<VehicleType, SpotType[]> = new Map([ [VehicleType.MOTORCYCLE, [SpotType.MOTORCYCLE, SpotType.COMPACT, SpotType.LARGE]], [VehicleType.CAR, [SpotType.COMPACT, SpotType.LARGE]], [VehicleType.BUS, [SpotType.LARGE]], ]); constructor(licensePlate: string, type: VehicleType) { this.licensePlate = licensePlate; this.type = type; } getCompatibleSpotTypes(): SpotType[] { return Vehicle.COMPATIBILITY_MAP.get(this.type) || []; } getPreferredSpotType(): SpotType { return this.getCompatibleSpotTypes()[0]; }} // If we needed inheritance later for special behavior:class ElectricVehicle extends Vehicle { readonly batteryLevel: number; readonly chargingRequired: boolean; constructor(licensePlate: string, batteryLevel: number) { super(licensePlate, VehicleType.CAR); // or ELECTRIC_CAR this.batteryLevel = batteryLevel; this.chargingRequired = batteryLevel < 20; } // Override to prefer electric spots when available getCompatibleSpotTypes(): SpotType[] { if (this.chargingRequired) { return [SpotType.ELECTRIC, ...super.getCompatibleSpotTypes()]; } return super.getCompatibleSpotTypes(); }}By using interfaces with data-driven implementation AND keeping the door open for inheritance when truly needed, we get flexibility without sacrificing extensibility. This is the hallmark of professional design: choosing the simplest solution that allows future complexity.
Both composition and aggregation represent "HAS-A" relationships, but they differ in lifecycle coupling:
This distinction is crucial for the parking lot system because it affects object lifecycle management, memory handling, and system boundaries.
| Relationship | Type | Justification | UML Notation |
|---|---|---|---|
| ParkingLot → ParkingSpot | Composition (●—) | Spots are part of the lot; destroying the lot destroys its spots | Filled diamond pointing to lot |
| ParkingSpot → Vehicle | Aggregation (◇—) | Vehicle exists before parking and after leaving; spot just holds it temporarily | Empty diamond pointing to spot |
| ParkingLot → Ticket | Aggregation (◇—) | Tickets may persist (for history) after the lot session ends | Empty diamond pointing to lot |
| Ticket → Vehicle info | Value Copy | Ticket copies vehicle data; no ongoing reference | Dependency or attribute |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// COMPOSITION: ParkingLot owns ParkingSpots// Spots are created BY the lot, lifetime bound to lotclass ParkingLot { // Composition - spots created in constructor, no external injection private readonly spots: Map<string, ParkingSpot> = new Map(); constructor(config: ParkingLotConfig) { // Spots created as part of lot initialization // They cannot exist without this lot instance this.initializeSpots(config); } private initializeSpots(config: ParkingLotConfig): void { for (let floor = 1; floor <= config.floors; floor++) { for (const [type, count] of config.spotsPerFloor) { for (let i = 1; i <= count; i++) { // Spot lifecycle tied to this lot const spot = new ParkingSpot(floor, 1, i, type); this.spots.set(spot.id, spot); } } } } // When ParkingLot is garbage collected, spots go with it // No need for explicit cleanup} // AGGREGATION: ParkingSpot holds Vehicle temporarily// Vehicle exists independently of spotclass ParkingSpot { // Aggregation - vehicle passed in, exists independently private parkedVehicle: IVehicle | null = null; // Vehicle is passed in - we don't create it park(vehicle: IVehicle): void { this.parkedVehicle = vehicle; // Vehicle continues to exist in the outside world } // Vehicle is returned - it continues to exist after vacate(): IVehicle | null { const vehicle = this.parkedVehicle; this.parkedVehicle = null; return vehicle; // Vehicle still exists after this }} // VALUE COPY: Ticket copies vehicle information// No ongoing object reference - just data snapshotclass Ticket { // These are values, not object references readonly vehicleLicensePlate: string; // Copied readonly vehicleType: VehicleType; // Copied constructor(vehicle: IVehicle, spot: ParkingSpot) { // Copy relevant data at construction time this.vehicleLicensePlate = vehicle.licensePlate; this.vehicleType = vehicle.type; // We don't store: private vehicle: IVehicle // Because we don't need ongoing access to the Vehicle object // And doing so would create unnecessary coupling }}You might wonder why Ticket copies vehicleType instead of storing a Vehicle reference. Consider: the Ticket is a historical record that might be stored for years. The Vehicle object is transient—it represents a car that drove away. By copying data, the Ticket is self-contained and doesn't depend on objects that might change or be garbage collected.
Beyond ownership relationships, classes connect through associations (long-term relationships) and dependencies (transient usage). Understanding these helps us manage coupling and design clean interfaces.
Key Associations in our System:
Dependency Injection Deep Dive:
The ParkingLot doesn't create its own pricing or allocation strategies—they're injected through the constructor. This is dependency injection (DI), and it provides enormous benefits:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// Strategy interfaces define contractsinterface IPricingStrategy { calculateFee(ticket: Ticket): Money;} interface IAllocationStrategy { findSpot( vehicle: IVehicle, spotsByType: Map<SpotType, IParkingSpot[]> ): IParkingSpot | null;} // ParkingLot accepts strategies via constructor (Dependency Injection)class ParkingLot { private readonly pricingStrategy: IPricingStrategy; private readonly allocationStrategy: IAllocationStrategy; constructor( config: ParkingLotConfig, pricingStrategy: IPricingStrategy, // Injected allocationStrategy: IAllocationStrategy // Injected ) { this.pricingStrategy = pricingStrategy; this.allocationStrategy = allocationStrategy; // ... initialization } // ParkingLot delegates to strategies, doesn't implement logic parkVehicle(vehicle: IVehicle): Ticket { // Delegate spot finding to strategy const spot = this.allocationStrategy.findSpot( vehicle, this.spotsByType ); // ... } exitVehicle(ticket: Ticket): Money { // Delegate fee calculation to strategy return this.pricingStrategy.calculateFee(ticket); }} // Usage with dependency injectionconst lot = new ParkingLot( config, new HourlyPricingStrategy(rates), // Concrete strategy new FirstAvailableAllocationStrategy() // Concrete strategy); // Testing with mock strategiesconst mockPricing: IPricingStrategy = { calculateFee: (_ticket) => new Money(10) // Always $10 for testing}; const testLot = new ParkingLot( testConfig, mockPricing, // Mock for testing new FirstAvailableAllocationStrategy());Notice the arrows point FROM ParkingLot TO the interfaces. ParkingLot depends on abstractions (interfaces), not concretions (actual strategy implementations). This is Dependency Inversion in action—high-level modules don't depend on low-level modules; both depend on abstractions.
In Domain-Driven Design, an aggregate is a cluster of entities and value objects that are treated as a single unit for data changes. The aggregate root is the entry point—external code can only access the aggregate through its root.
Our Parking Lot Aggregates:
ParkingLot Aggregate
Ticket (separate aggregate)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// ParkingLot as Aggregate Root// All access to spots goes through the lot class ParkingLot { // Private - spots not directly accessible from outside private readonly spots: Map<string, ParkingSpot> = new Map(); // ✓ External code uses these methods parkVehicle(vehicle: IVehicle): Ticket { /* ... */ } exitVehicle(ticket: Ticket): Money { /* ... */ } getAvailableSpotsByType(type: SpotType): number { /* ... */ } // ✓ Read-only spot information is OK getSpotInfo(spotId: string): SpotInfo | null { const spot = this.spots.get(spotId); if (!spot) return null; // Return a read-only view, not the actual spot return { id: spot.id, type: spot.type, floor: spot.floor, isAvailable: spot.isAvailable(), }; } // ✗ NEVER expose the actual spot objects // getSpot(spotId: string): ParkingSpot { ... } // BAD! // getAllSpots(): ParkingSpot[] { ... } // BAD!} // Read-only DTO for spot informationinterface SpotInfo { readonly id: string; readonly type: SpotType; readonly floor: number; readonly isAvailable: boolean;} // Why this matters:// // BAD: Direct access breaks encapsulation// const spot = lot.getSpot('F1-R1-S5');// spot.park(vehicle); // Bypasses lot's availability tracking!// // GOOD: All modifications through aggregate root// const ticket = lot.parkVehicle(vehicle);// // Lot maintains invariants, updates counters, creates ticketAggregates exist to protect invariants—rules that must always be true. For ParkingLot: 'A spot can only be occupied by one vehicle' and 'Available spot count must match reality.' By forcing all access through the aggregate root, we guarantee these invariants are never violated by rogue code.
Let's bring everything together into a comprehensive class diagram that captures our entire design. This is what you'd draw on a whiteboard in an interview, building it up piece by piece.
Reading the Diagram:
| Symbol | Meaning | Example |
|---|---|---|
| `< | ..` | Implements |
*-- | Composition | ParkingLot owns ParkingSpots |
o-- | Aggregation | ParkingSpot holds (doesn't own) Vehicle |
--> | Association/Dependency | Ticket records VehicleType |
- before name | Private | -spots, -activeTickets |
+ before name | Public | +parkVehicle(), +id |
<<interface>> | Interface | IPricingStrategy |
<<enumeration>> | Enum | VehicleType |
The Interface Segregation Principle (ISP) states: "Clients should not be forced to depend on interfaces they do not use." For our parking lot, this means creating focused interfaces for different use cases.
Consider the ParkingLot's consumers:
parkVehicle(), hasAvailableSpot()exitVehicle()getAvailableSpots(), getOccupancy()Rather than exposing one monolithic ParkingLot interface to all consumers, we can segregate into role-specific interfaces:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// Segregated interfaces for different clients // For entry gate systemsinterface IEntryGate { hasAvailableSpot(vehicleType: VehicleType): boolean; parkVehicle(vehicle: IVehicle): Ticket;} // For exit gate systems interface IExitGate { exitVehicle(ticket: Ticket): Money; validateTicket(ticketId: string): boolean;} // For display systemsinterface IDisplayBoard { getAvailableSpotsByType(): Map<SpotType, number>; getTotalCapacity(): number; getCurrentOccupancy(): number; isFull(): boolean;} // For admin systems (extends others)interface IAdminPanel extends IEntryGate, IExitGate, IDisplayBoard { getActiveTickets(): Ticket[]; getRevenueForPeriod(start: Date, end: Date): Money; reconfigurePricing(strategy: IPricingStrategy): void;} // ParkingLot implements all interfacesclass ParkingLot implements IEntryGate, IExitGate, IDisplayBoard { // ... implementation hasAvailableSpot(vehicleType: VehicleType): boolean { /* ... */ } parkVehicle(vehicle: IVehicle): Ticket { /* ... */ } exitVehicle(ticket: Ticket): Money { /* ... */ } validateTicket(ticketId: string): boolean { /* ... */ } getAvailableSpotsByType(): Map<SpotType, number> { /* ... */ } // ... etc} // Usage: Each system receives only the interface it needsclass EntryGateController { private readonly lot: IEntryGate; // Only entry operations exposed constructor(lot: IEntryGate) { this.lot = lot; } handleVehicleEntry(vehicle: IVehicle): EntryResult { if (!this.lot.hasAvailableSpot(vehicle.type)) { return { success: false, message: 'Lot is full' }; } const ticket = this.lot.parkVehicle(vehicle); return { success: true, ticket }; }} class DisplayController { private readonly board: IDisplayBoard; // Only display operations constructor(board: IDisplayBoard) { this.board = board; } updateDisplays(): void { const available = this.board.getAvailableSpotsByType(); const isFull = this.board.isFull(); // ... update physical displays }}Sometimes entities need to reference each other in both directions. For example, a ParkingSpot might need to know its parent ParkingLot, while ParkingLot contains all its ParkingSpots. This creates a bidirectional relationship that needs careful management.
The Problem with Bidirectional:
ParkingLot ←→ ParkingSpot
The Solution: Prefer Unidirectional
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// ❌ BIDIRECTIONAL (Problematic)class ParkingSpotBad { private lot: ParkingLot; // Back-reference to parent constructor(lot: ParkingLot) { this.lot = lot; lot.addSpot(this); // Must update both sides } // If we need lot info, we access via this.lot notifyLotOfChange(): void { this.lot.handleSpotChange(this); }} class ParkingLotBad { private spots: ParkingSpot[] = []; addSpot(spot: ParkingSpot): void { this.spots.push(spot); // Careful: spot already has reference to us // Don't create circular setup! }} // ✓ UNIDIRECTIONAL (Preferred)class ParkingSpotGood { readonly id: string; readonly lotId: string; // Store ID, not object reference constructor(id: string, lotId: string) { this.id = id; this.lotId = lotId; // Just an identifier } // If we truly need lot, look it up through a service // But usually, we don't need it at all!} class ParkingLotGood { private spots: Map<string, ParkingSpotGood> = new Map(); addSpot(spot: ParkingSpotGood): void { this.spots.set(spot.id, spot); // No circular reference! } // Spot changes are handled by the lot (aggregate root pattern) handleParkingAt(spotId: string, vehicle: IVehicle): void { const spot = this.spots.get(spotId); if (spot) { spot.park(vehicle); this.updateCounters(); this.emitEvent('parked', { spotId, vehicle }); } }} // When spot truly needs lot context, use callback/event patternsinterface SpotEventHandler { onSpotOccupied(spotId: string): void; onSpotVacated(spotId: string): void;} class SmartParkingSpot { private eventHandler?: SpotEventHandler; setEventHandler(handler: SpotEventHandler): void { this.eventHandler = handler; } park(vehicle: IVehicle): void { // ... parking logic this.eventHandler?.onSpotOccupied(this.id); }}Bidirectional relationships aren't forbidden—just be intentional. They're acceptable when: 1) Navigation in both directions is genuinely frequent. 2) The relationship is managed by a single class (aggregate root). 3) You have clear ownership semantics (parent manages the relationship). Always document why the bidirectional link is necessary.
What's next:
With entities defined and relationships mapped, it's time to implement the behavioral patterns that make our system flexible and maintainable. Next, we'll apply Factory and Strategy patterns to handle vehicle/spot creation and pricing/allocation logic.
You now understand how to model class relationships with precision and purpose. These relationship decisions determine your system's flexibility, testability, and maintainability. Next, we'll see these relationships in action as we apply design patterns.