Skip to content

Entity Discovery and Demotion

TL;DR: Extract nouns from your requirements to get entity candidates. Then aggressively filter: if it doesn't maintain changing state or enforce rules, it's a field on another class, not a class of its own. Most nouns get demoted.

Entity Discovery: Noun Funnel

From Requirements to Entities

Once you have a scoped requirements list, the next step is identifying the classes you'll actually build. The technique is simple: pull out every noun from your requirements, then decide which ones deserve to be classes.

Start with a Parking Lot example. Suppose your requirements are:

1. Vehicles enter the lot and receive a ticket
2. The lot has spots of different sizes (small, medium, large)
3. Vehicles are assigned to spots matching their size
4. On exit, fee is calculated based on duration
5. The lot tracks available spots per size

Noun extraction gives you these candidates:

Vehicle, Lot, Ticket, Spot, Size, Fee, Duration

Seven candidates. In the final design, you'll likely have 3-4 classes. The rest get demoted.

The Critical Filter

For each candidate noun, ask one question:

"Does it maintain changing state or enforce rules? If not, it's a field, not a class."

This single question eliminates most of your candidates. A class earns its existence by managing complexity — tracking state that changes over time, enforcing constraints, or coordinating behavior. If a noun is just a label, a measurement, or a static attribute, it belongs as a field on a class that does manage complexity.

Let's apply the filter to our candidates:

Candidate Maintains state? Enforces rules? Verdict
Vehicle No. It has a type and a license plate, but those don't change. Demote to enum VehicleType
Lot Yes. Tracks available spots, assigns vehicles, manages entry/exit flow. Keep as ParkingLot class
Ticket Yes. Links a vehicle to a spot, records entry time, used for fee calculation. Keep as Ticket class
Spot Maybe. If spots have individual state (occupied/available), it could be a class. But often a spot is just an ID + size. Demote to a size attribute — the lot tracks which spots are occupied
Size No. It's a fixed category. Demote to enum SpotSize
Fee No. It's a calculated value, not a stateful entity. Demote to a return value from calculateFee()
Duration No. It's derived from entry time and exit time. Demote to a calculation inside calculateFee()

Result: 2 classes (ParkingLot, Ticket) plus 2 enums (VehicleType, SpotSize). Clean and focused.

Entity Demotion Examples

This pattern repeats across every LLD problem. Here are common demotions that trip up candidates:

Package in Amazon Locker

Candidates often create a Package class with fields like id, size, recipientEmail. But a package doesn't do anything in the locker system — it has no behavior, no changing state. The locker system only cares about the package's size (to find a fitting compartment) and its pickup code.

// BAD: Package as a class that does nothing
class Package {
    private String id;
    private Size size;
    private String recipientEmail;
    // ...getters, no real behavior
}

// GOOD: Package is just a size parameter
class LockerSystem {
    public String storePackage(Size packageSize) {
        Compartment compartment = findAvailable(packageSize);
        compartment.occupy();
        return compartment.generatePickupCode();
    }
}
// BAD
class Package {
    std::string id;
    Size size;
    std::string recipientEmail;
};

// GOOD
class LockerSystem {
public:
    std::string storePackage(Size packageSize) {
        Compartment& compartment = findAvailable(packageSize);
        compartment.occupy();
        return compartment.generatePickupCode();
    }
};
# BAD
class Package:
    def __init__(self, id: str, size: Size, recipient_email: str):
        self._id = id
        self._size = size
        self._recipient_email = recipient_email

# GOOD
class LockerSystem:
    def store_package(self, package_size: Size) -> str:
        compartment = self._find_available(package_size)
        compartment.occupy()
        return compartment.generate_pickup_code()

Vehicle in Parking Lot

A vehicle has a type and a license plate. Neither changes while it's in the lot. The lot doesn't need to call methods on the vehicle — it just needs to know its type to assign the right spot size.

// BAD: Vehicle class with no behavior
class Vehicle {
    private VehicleType type;
    private String licensePlate;
    // ...just getters
}

// GOOD: Vehicle is an enum
enum VehicleType { SMALL, MEDIUM, LARGE }

class ParkingLot {
    public Ticket enter(VehicleType vehicleType, String licensePlate) {
        int spotId = assignSpot(vehicleType);
        return new Ticket(spotId, licensePlate, Instant.now());
    }
}
// GOOD
enum class VehicleType { SMALL, MEDIUM, LARGE };

class ParkingLot {
public:
    Ticket enter(VehicleType vehicleType, const std::string& licensePlate) {
        int spotId = assignSpot(vehicleType);
        return Ticket(spotId, licensePlate, Clock::now());
    }
};
# GOOD
class VehicleType(Enum):
    SMALL = "small"
    MEDIUM = "medium"
    LARGE = "large"

class ParkingLot:
    def enter(self, vehicle_type: VehicleType, license_plate: str) -> Ticket:
        spot_id = self._assign_spot(vehicle_type)
        return Ticket(spot_id, license_plate, datetime.now())

Seat in Movie Booking

A seat in a cinema doesn't change. Row B, Seat 7 is always Row B, Seat 7. The interesting state is whether a seat is reserved, and that's managed by the Reservation class or the Showtime class — not by the seat itself.

// BAD: Seat as a class
class Seat {
    private String row;
    private int number;
    private boolean isBooked;  // This state belongs to the booking system
}

// GOOD: Seat is a string identifier
class Showtime {
    private Map<String, Reservation> reservedSeats;  // "B7" -> Reservation

    public boolean isSeatAvailable(String seatId) {
        return !reservedSeats.containsKey(seatId);
    }
}
// GOOD
class Showtime {
    std::unordered_map<std::string, Reservation> reservedSeats;

public:
    bool isSeatAvailable(const std::string& seatId) const {
        return reservedSeats.find(seatId) == reservedSeats.end();
    }
};
# GOOD
class Showtime:
    def __init__(self):
        self._reserved_seats: dict[str, Reservation] = {}

    def is_seat_available(self, seat_id: str) -> bool:
        return seat_id not in self._reserved_seats

When Something IS a Class

Not everything gets demoted. Here are signs that a noun deserves to be a full class:

It has lifecycle state. An AccessToken starts valid, can be refreshed, and eventually expires. That's changing state with rules around transitions.

class AccessToken {
    private String token;
    private Instant expiresAt;
    private boolean revoked;

    public boolean isValid() {
        return !revoked && Instant.now().isBefore(expiresAt);
    }

    public void revoke() {
        this.revoked = true;
    }
}
class AccessToken {
    std::string token;
    TimePoint expiresAt;
    bool revoked = false;

public:
    bool isValid() const {
        return !revoked && Clock::now() < expiresAt;
    }

    void revoke() { revoked = true; }
};
class AccessToken:
    def __init__(self, token: str, expires_at: datetime):
        self._token = token
        self._expires_at = expires_at
        self._revoked = False

    def is_valid(self) -> bool:
        return not self._revoked and datetime.now() < self._expires_at

    def revoke(self) -> None:
        self._revoked = True

It enforces constraints. A Reservation links a user to specific seats for a specific showtime, and enforces that you can't double-book. That's a rule, not just data.

It coordinates multiple pieces of data. A Ticket ties together a spot ID, a license plate, and an entry time — and those pieces are meaningless without each other.

Interview tip: When presenting your entities to the interviewer, briefly explain what you demoted and why. "I considered making Vehicle a class, but it has no behavior or changing state in our system — VehicleType as an enum is sufficient." This demonstrates the exact filtering skill they're evaluating.

Full Walkthrough: Parking Lot Entity Discovery

Let's do the complete noun extraction and filtering process step by step, showing every decision.

Requirements:

1. Multi-story parking lot with spots of different sizes
2. Vehicles enter and are assigned to a matching spot on any floor
3. Vehicles receive a ticket on entry with entry time and spot info
4. On exit, fee is calculated based on hourly rate and duration
5. System tracks available spots per floor and per size
6. Different fee rates for different vehicle types

Step 1: Raw noun extraction

Read every requirement and pull out every noun, no filtering yet:

Lot, Story/Floor, Spot, Size, Vehicle, Ticket, Entry, Time, 
Fee, Rate, Duration, Info, System, Type

14 nouns. Now filter.

Step 2: Immediate demotions (obvious non-classes)

Noun Why it's not a class Demoted to
Size Fixed category (SMALL, MEDIUM, LARGE) Enum SpotSize
Type Fixed category (MOTORCYCLE, CAR, TRUCK) Enum VehicleType
Entry Not a thing -- it's an event Parameter to a method
Time Primitive value Instant / datetime field
Duration Derived from entry time and exit time Calculated value
Info Vague -- "spot info" means spotId + floor Fields on Ticket
Rate A number (dollars per hour) Field or config value
System Too abstract -- what IS the system? This becomes the orchestrator

Step 3: Evaluate remaining candidates

Candidate Does it maintain changing state? Does it enforce rules? Verdict
Lot Yes -- tracks occupied spots, manages entry/exit flow Yes -- enforces capacity limits, spot assignment Class: ParkingLot (orchestrator)
Floor Maybe -- could track spots per floor Maybe -- could manage floor-level capacity Evaluate further
Spot Minimal -- a spot has an ID, size, and floor. These don't change. No rules. Demote to fields (spotId + spotSize + floor)
Vehicle No -- type and plate don't change while parked No rules Demote to enum VehicleType
Ticket Yes -- created on entry, used on exit, links vehicle to spot + time Enforces "one ticket per vehicle" Class: Ticket
Fee No -- it's a calculated result Enforces rate rules Evaluate further

Step 4: Resolve "evaluate further" candidates

Floor: Does floor need to be a class? It depends on the design. If each floor independently tracks its own spots, making Floor a class that owns Set<Integer> occupiedSpots is reasonable. But you can also have the ParkingLot track occupiedSpotIds across all floors with each spot knowing its floor. The simpler design has fewer classes.

Decision: Demote Floor. The ParkingLot tracks spots globally. Floor is a field on each spot's metadata.

Fee: Fee is a calculated value, not a stateful entity. But fee calculation has rules (different rates per vehicle type, hourly calculation). Does the calculation logic deserve its own class?

Decision: Extract FeeCalculator as a class. It encapsulates the pricing rules and can be swapped out (Strategy pattern) for different pricing models.

Step 5: Final entity list

Classes: ParkingLot (orchestrator), Ticket, FeeCalculator
Enums:   VehicleType, SpotSize

Five types total. Clean, focused, each with a clear purpose.

Full Walkthrough: Movie Booking Entity Discovery

Requirements:

1. Theater has multiple screens with fixed seat layouts
2. Movies have showtimes assigned to screens
3. Users browse showtimes and select available seats
4. Users book seats -- if two users pick the same seat, first to confirm wins
5. Users can cancel bookings

Noun extraction:

Theater, Screen, Seat, Layout, Movie, Showtime, User, Booking, Confirmation

Filtering:

Candidate Maintains state? Enforces rules? Verdict
Theater Yes -- owns screens Coordinates bookings Class (orchestrator, or could be BookingSystem)
Screen Minimal -- has an ID and seat count, doesn't change No Demote to screenId field
Seat No -- Row B Seat 7 never changes No Demote to string identifier "B7"
Layout No -- fixed grid dimensions No Demote to rows/cols fields on screen metadata
Movie No -- title and duration don't change during booking No Demote to movieId/title field
Showtime Yes -- tracks which seats are reserved for this showing Yes -- prevents double booking Class
User No meaningful state for the booking system No Demote to userId string
Booking Yes -- links user to seats for a showtime, can be cancelled Yes -- enforces cancellation rules Class
Confirmation No -- it's an event, not a thing No Demote to return value

Final entities:

Classes: BookingSystem (orchestrator), Showtime, Booking
Enums:   SeatStatus (AVAILABLE, RESERVED, if needed)

Three classes. Movie, Screen, Seat, User -- all demoted. This surprises candidates who expect a Seat class, but a seat is just a position identifier. The interesting state is whether a seat is reserved for a specific showtime, which is managed by Showtime or Booking.

Full Walkthrough: Splitwise Entity Discovery

Requirements:

1. Users add expenses with a payer and list of participants
2. Expenses split equally among participants
3. System tracks who owes whom (net balances)
4. Users can settle debts
5. System shows each user's total balance

Noun extraction:

User, Expense, Payer, Participant, Split, Balance, Debt, Settlement

Filtering:

Candidate Maintains state? Enforces rules? Verdict
User Maybe -- has a name and running balance Minimal rules Evaluate
Expense Yes -- records who paid, who participated, the amount Yes -- must have at least 2 participants, positive amount Class
Payer No -- it's a role, not a thing No Demote to userId field on Expense
Participant No -- it's a role No Demote to List on Expense
Split No -- it's a calculated value (amount / participants) No Demote to calculation in Expense
Balance No -- it's a derived value No Demote to calculated property
Debt No -- it's a relationship (A owes B $X) No Demote to entry in a Map
Settlement Maybe -- records a payment between users Enforces amount rules Evaluate

Resolving "evaluate" candidates:

User: In Splitwise, users don't have complex behavior. A userId string is sufficient to identify who paid and who owes. But if you want to track "all expenses a user is part of" or "user's total balance," you could have a User class. The simpler path: keep user as a string ID and compute balances from the expense history.

Decision: Demote User to string ID.

Settlement: A settlement is effectively an expense where one person "pays back" another. You could model it as a special type of Expense, or as its own class. Since settlement has different semantics (it reduces debt, not creates it), a separate class is cleaner.

Decision: Keep Settlement as a class, or model it as a method settle(fromUser, toUser, amount) on the orchestrator.

Final entities:

Classes: ExpenseManager (orchestrator), Expense
Enums:   SplitType (EQUAL -- extensible to PERCENTAGE, EXACT later)

Two core classes. The orchestrator manages the list of expenses and computes balances. Each Expense records who paid, who participated, and how much. Settlements are handled as a method on ExpenseManager that adjusts the balance ledger.

Over-Modeling vs Under-Modeling: What Goes Wrong

Over-modeling: Too many classes

Parking Lot with over-modeling:
Vehicle, ParkingLot, Spot, Floor, Gate, EntryGate, ExitGate,
Ticket, Receipt, Fee, FeeCalculator, FeeRule, Duration,
LicensePlate, SpotSize, VehicleType, Address, Attendant,
Display, ParkingRecord, PaymentMethod

20 classes. What goes wrong:

  • You run out of time. You spend 25 minutes defining class relationships and never implement a single method.
  • Your design is hard to explain. The interviewer asks "walk me through parking a car" and you trace through 8 classes.
  • Classes have no behavior. Most of them are data holders with getters. The interviewer sees you can define attributes but questions whether you understand behavior-driven design.
  • Coupling explodes. Every class depends on 3-4 others. Changing anything touches everything.

Under-modeling: Too few classes

Parking Lot with under-modeling:
ParkingLot  (contains ALL logic, ALL state, ALL validation)

One class. What goes wrong:

  • God class. ParkingLot handles entry, exit, spot assignment, fee calculation, ticket management, and display. It's 300 lines with 15 methods.
  • No extensibility. "What if we add different fee structures?" requires modifying ParkingLot, which touches everything.
  • Can't discuss design. The interviewer asks about class responsibilities and there's nothing to discuss -- everything is in one place.
  • Testing is painful. You can't unit-test fee calculation without setting up the entire lot.

The sweet spot

Parking Lot with balanced modeling:
ParkingLot (orchestrator), Ticket, FeeCalculator
+ enums: VehicleType, SpotSize

3 classes + 2 enums. Each class has clear state, clear behavior, and a single reason to change. You can explain the design in 2 minutes, implement the core in 15, and discuss extensions confidently.

More Demotion Examples

Rating in a Ride-Sharing System

Candidates often create a Rating class:

// Over-modeled
class Rating {
    private int stars;
    private String comment;
    private String riderId;
    private String driverId;
    // ...just getters
}

But a rating doesn't maintain changing state or enforce rules. It's data recorded once and never modified. Demote it to fields on the Trip class:

class Trip {
    private int driverRating;     // 1-5, set once after trip
    private String riderComment;  // set once after trip

    public void rateDriver(int stars, String comment) {
        if (stars < 1 || stars > 5) throw new IllegalArgumentException("Stars must be 1-5");
        if (this.driverRating != 0) throw new IllegalStateException("Already rated");
        this.driverRating = stars;
        this.riderComment = comment;
    }
}

The validation (1-5 stars, rate only once) lives on Trip, which already manages the trip lifecycle.

Address in Any System

Address is almost never a class in LLD interviews. It's a string or a set of fields on another entity. The exceptions are rare -- maybe a geocoding service where addresses have behavior (validation, normalization). In a Parking Lot, Library, or E-commerce design, address is just a string.

Notification in Any System

Candidates love creating a Notification class, but in most LLD problems notifications are out of scope. If they're in scope, they're usually just a method call: notificationService.send(userId, message). The notification itself is a string, not a stateful entity.

The Common Mistake: Every Noun Is a Class

Candidates who create a class for every noun end up with designs like this:

Vehicle, ParkingLot, Spot, Floor, Gate, Ticket,
Fee, Duration, LicensePlate, SpotSize, Address,
EntryPoint, ExitPoint, ParkingAttendant...

Fourteen classes. Most of them are data holders with getters and no behavior. The candidate spends 30 minutes wiring together empty shells and never gets to the interesting logic — how spots are assigned, how fees are calculated, how the lot tracks capacity.

Three well-designed classes with real behavior beat fourteen empty ones every time.

Quick Recap

Concept What it means Why it matters
Noun extraction Pull every noun from requirements as a candidate entity Gives you a starting list to filter, not a final class list
The critical filter "Does it maintain changing state or enforce rules?" Separates real classes from fields/enums/parameters
Entity demotion Downgrading a noun to an enum, a field, or a method parameter Keeps your design focused on classes that carry real behavior
Signs of a real class Lifecycle state, enforced constraints, coordinated data These are the entities worth building