Skip to content

Classes, Objects, Encapsulation

TL;DR: A class is a blueprint that bundles data and behavior together. Encapsulation means making the data private and controlling access through methods. This prevents other code from putting your object into an invalid state.

Encapsulation: Private State + Public API

Why This Matters in Interviews

Every LLD interview starts with you defining classes. If you get this wrong, everything downstream falls apart. The interviewer isn't testing whether you know the definition of "encapsulation." They're testing whether your classes protect their own data and expose clean, minimal interfaces.

The difference between a passing and failing LLD answer often comes down to one thing: did the candidate let external code reach into objects and manipulate state directly, or did the objects manage their own state?

Classes and Objects

A class defines what data an object holds (its state) and what it can do (its behavior). An object is a specific instance of that class with actual values.

// Class: the blueprint
class BankAccount {
    private String accountId;
    private long balanceCents;

    public BankAccount(String accountId, long initialBalanceCents) {
        this.accountId = accountId;
        this.balanceCents = initialBalanceCents;
    }

    public long getBalanceCents() {
        return balanceCents;
    }
}
// C++ equivalent
class BankAccount {
private:
    std::string accountId;
    long balanceCents;

public:
    BankAccount(std::string accountId, long initialBalanceCents)
        : accountId(std::move(accountId)), balanceCents(initialBalanceCents) {}

    long getBalanceCents() const { return balanceCents; }
};
# Python equivalent
class BankAccount:
    def __init__(self, account_id: str, initial_balance_cents: int):
        self._account_id = account_id
        self._balance_cents = initial_balance_cents

    def get_balance_cents(self) -> int:
        return self._balance_cents

The class defines the shape. Each new BankAccount(...) call creates an independent object with its own accountId and balanceCents.

Encapsulation: Why Hiding Matters

Encapsulation means keeping an object's data private and letting the object control how that data is used. You interact with it through methods instead of reaching in and changing its fields directly.

The Problem Without Encapsulation

// BAD: No encapsulation
class BankAccount {
    public long balanceCents;  // Anyone can change this

    public BankAccount(long balanceCents) {
        this.balanceCents = balanceCents;
    }
}

// Somewhere else in the code...
account.balanceCents = -500;  // Negative balance? No one stops you.
account.balanceCents = account.balanceCents + 100;  // No audit trail.

When balanceCents is public, any code anywhere can set it to anything. Negative balances, impossible values, skipped validations. The BankAccount class has no way to enforce its own rules.

The Fix: Control Access Through Methods

// GOOD: Encapsulated
class BankAccount {
    private long balanceCents;

    public BankAccount(long initialBalanceCents) {
        if (initialBalanceCents < 0) {
            throw new IllegalArgumentException("Initial balance cannot be negative");
        }
        this.balanceCents = initialBalanceCents;
    }

    public void deposit(long amountCents) {
        if (amountCents <= 0) {
            throw new IllegalArgumentException("Deposit must be positive");
        }
        this.balanceCents += amountCents;
    }

    public void withdraw(long amountCents) {
        if (amountCents <= 0) {
            throw new IllegalArgumentException("Withdrawal must be positive");
        }
        if (amountCents > this.balanceCents) {
            throw new IllegalArgumentException("Insufficient funds");
        }
        this.balanceCents -= amountCents;
    }

    public long getBalanceCents() {
        return balanceCents;
    }
}
class BankAccount {
private:
    long balanceCents;

public:
    BankAccount(long initialBalanceCents) : balanceCents(initialBalanceCents) {
        if (initialBalanceCents < 0)
            throw std::invalid_argument("Initial balance cannot be negative");
    }

    void deposit(long amountCents) {
        if (amountCents <= 0) throw std::invalid_argument("Deposit must be positive");
        balanceCents += amountCents;
    }

    void withdraw(long amountCents) {
        if (amountCents <= 0) throw std::invalid_argument("Withdrawal must be positive");
        if (amountCents > balanceCents) throw std::invalid_argument("Insufficient funds");
        balanceCents -= amountCents;
    }

    long getBalanceCents() const { return balanceCents; }
};
class BankAccount:
    def __init__(self, initial_balance_cents: int):
        if initial_balance_cents < 0:
            raise ValueError("Initial balance cannot be negative")
        self._balance_cents = initial_balance_cents

    def deposit(self, amount_cents: int) -> None:
        if amount_cents <= 0:
            raise ValueError("Deposit must be positive")
        self._balance_cents += amount_cents

    def withdraw(self, amount_cents: int) -> None:
        if amount_cents <= 0:
            raise ValueError("Withdrawal must be positive")
        if amount_cents > self._balance_cents:
            raise ValueError("Insufficient funds")
        self._balance_cents -= amount_cents

    def get_balance_cents(self) -> int:
        return self._balance_cents

Now BankAccount enforces its own rules. Negative balances are impossible. Every change goes through a method that can validate, log, or reject the operation. The data is protected.

More Encapsulation in Practice: ParkingLot

The BankAccount example is classic, but interview problems require encapsulation on more complex data. Consider a ParkingLot that tracks occupied spots:

// BAD: No encapsulation -- external code can corrupt state
class ParkingLot {
    public Set<Integer> occupiedSpotIds = new HashSet<>();
    public int totalSpots;

    // Anyone can write: lot.occupiedSpotIds.add(999);  // spot doesn't exist
    // Or: lot.totalSpots = -1;  // impossible state
    // Or: lot.occupiedSpotIds.clear();  // wipe all records silently
}

What goes wrong without encapsulation here? A developer on another team writes lot.occupiedSpotIds.add(spotId) without checking if the spot exists, whether it's already occupied, or updating related data (like the active tickets map). Six months later, getAvailableSpots() returns -3 and nobody knows why.

// GOOD: The lot controls its own state
class ParkingLot {
    private final int totalSpots;
    private final Set<Integer> occupiedSpotIds = new HashSet<>();

    public ParkingLot(int totalSpots) {
        if (totalSpots <= 0) {
            throw new IllegalArgumentException("Must have at least one spot");
        }
        this.totalSpots = totalSpots;
    }

    public void occupySpot(int spotId) {
        if (spotId < 0 || spotId >= totalSpots) {
            throw new IllegalArgumentException("Invalid spot ID: " + spotId);
        }
        if (occupiedSpotIds.contains(spotId)) {
            throw new IllegalStateException("Spot " + spotId + " is already occupied");
        }
        occupiedSpotIds.add(spotId);
    }

    public void freeSpot(int spotId) {
        if (!occupiedSpotIds.remove(spotId)) {
            throw new IllegalStateException("Spot " + spotId + " was not occupied");
        }
    }

    public int getAvailableCount() {
        return totalSpots - occupiedSpotIds.size();  // Always correct
    }
}
class ParkingLot {
    const int totalSpots;
    std::unordered_set<int> occupiedSpotIds;

public:
    explicit ParkingLot(int totalSpots) : totalSpots(totalSpots) {
        if (totalSpots <= 0)
            throw std::invalid_argument("Must have at least one spot");
    }

    void occupySpot(int spotId) {
        if (spotId < 0 || spotId >= totalSpots)
            throw std::invalid_argument("Invalid spot ID");
        if (occupiedSpotIds.count(spotId))
            throw std::runtime_error("Spot already occupied");
        occupiedSpotIds.insert(spotId);
    }

    void freeSpot(int spotId) {
        if (occupiedSpotIds.erase(spotId) == 0)
            throw std::runtime_error("Spot was not occupied");
    }

    int getAvailableCount() const { return totalSpots - occupiedSpotIds.size(); }
};
class ParkingLot:
    def __init__(self, total_spots: int):
        if total_spots <= 0:
            raise ValueError("Must have at least one spot")
        self._total_spots = total_spots
        self._occupied_spot_ids: set[int] = set()

    def occupy_spot(self, spot_id: int) -> None:
        if spot_id < 0 or spot_id >= self._total_spots:
            raise ValueError(f"Invalid spot ID: {spot_id}")
        if spot_id in self._occupied_spot_ids:
            raise RuntimeError(f"Spot {spot_id} is already occupied")
        self._occupied_spot_ids.add(spot_id)

    def free_spot(self, spot_id: int) -> None:
        if spot_id not in self._occupied_spot_ids:
            raise RuntimeError(f"Spot {spot_id} was not occupied")
        self._occupied_spot_ids.discard(spot_id)

    def get_available_count(self) -> int:
        return self._total_spots - len(self._occupied_spot_ids)

The encapsulated version makes it impossible to:

  • Add a spot ID that doesn't exist
  • Double-occupy a spot
  • Free a spot that wasn't occupied
  • Set the total to a negative number

Every state-modifying operation goes through a method that validates first. The object is always in a consistent state.

Encapsulation and Thread Safety

Encapsulation isn't just about validation. In multi-threaded systems, encapsulation gives you a single place to add synchronization.

// Because deposit() and withdraw() are the ONLY way to change balanceCents,
// making them thread-safe makes the entire object thread-safe.

class BankAccount {
    private long balanceCents;
    private final Object lock = new Object();

    public void deposit(long amountCents) {
        if (amountCents <= 0) throw new IllegalArgumentException("Must be positive");
        synchronized (lock) {
            balanceCents += amountCents;
        }
    }

    public void withdraw(long amountCents) {
        if (amountCents <= 0) throw new IllegalArgumentException("Must be positive");
        synchronized (lock) {
            if (amountCents > balanceCents) {
                throw new IllegalArgumentException("Insufficient funds");
            }
            balanceCents -= amountCents;
        }
    }

    public long getBalanceCents() {
        synchronized (lock) {
            return balanceCents;
        }
    }
}
class BankAccount {
    long balanceCents;
    mutable std::mutex mtx;

public:
    void deposit(long amountCents) {
        if (amountCents <= 0) throw std::invalid_argument("Must be positive");
        std::lock_guard<std::mutex> guard(mtx);
        balanceCents += amountCents;
    }

    void withdraw(long amountCents) {
        if (amountCents <= 0) throw std::invalid_argument("Must be positive");
        std::lock_guard<std::mutex> guard(mtx);
        if (amountCents > balanceCents) throw std::invalid_argument("Insufficient funds");
        balanceCents -= amountCents;
    }

    long getBalanceCents() const {
        std::lock_guard<std::mutex> guard(mtx);
        return balanceCents;
    }
};
import threading

class BankAccount:
    def __init__(self, initial_balance_cents: int):
        self._balance_cents = initial_balance_cents
        self._lock = threading.Lock()

    def deposit(self, amount_cents: int) -> None:
        if amount_cents <= 0:
            raise ValueError("Must be positive")
        with self._lock:
            self._balance_cents += amount_cents

    def withdraw(self, amount_cents: int) -> None:
        if amount_cents <= 0:
            raise ValueError("Must be positive")
        with self._lock:
            if amount_cents > self._balance_cents:
                raise ValueError("Insufficient funds")
            self._balance_cents -= amount_cents

If balanceCents were public, you'd have no central place to lock. Every piece of code that touches the field would need its own synchronization, and missing even one creates a race condition. Encapsulation turns "secure every access site" into "secure two methods."

Interview tip: If a senior-level interviewer asks about thread safety, mention that encapsulation is a prerequisite. "Because all mutations go through deposit() and withdraw(), I only need to synchronize those two methods. If the field were public, every caller would need their own lock, which is fragile and error-prone."

The "Getter Returning Mutable Reference" Trap in More Detail

This trap is so common in interviews that it's worth seeing multiple examples.

Playlist Songs

// BAD: Leaking the song list
class Playlist {
    private List<Song> songs = new ArrayList<>();

    public List<Song> getSongs() {
        return songs;  // Direct reference!
    }
}

// Attack scenario
List<Song> leaked = playlist.getSongs();
leaked.add(new Song("Rick Astley", "Never Gonna Give You Up"));  // bypassed validation
leaked.clear();  // deleted everything
Collections.sort(leaked);  // changed ordering without Playlist knowing

Shopping Cart Items

// BAD
class ShoppingCart {
    private Map<String, Integer> items = new HashMap<>();  // productId -> quantity

    public Map<String, Integer> getItems() {
        return items;  // anyone can add items at any price
    }
}

// Attack scenario
cart.getItems().put("EXPENSIVE_ITEM", 100);  // added without stock check
cart.getItems().clear();  // emptied cart, bypassing any "cart cleared" event

// GOOD
class ShoppingCart {
    private final Map<String, Integer> items = new HashMap<>();

    public void addItem(String productId, int quantity) {
        if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
        items.merge(productId, quantity, Integer::sum);
    }

    public void removeItem(String productId) {
        if (!items.containsKey(productId)) {
            throw new IllegalArgumentException("Item not in cart");
        }
        items.remove(productId);
    }

    public Map<String, Integer> getItems() {
        return Map.copyOf(items);  // immutable copy
    }
}
// GOOD -- return a const reference or a copy
class ShoppingCart {
    std::unordered_map<std::string, int> items;

public:
    void addItem(const std::string& productId, int quantity) {
        if (quantity <= 0) throw std::invalid_argument("Quantity must be positive");
        items[productId] += quantity;
    }

    // Return a copy -- caller cannot modify internal state
    std::unordered_map<std::string, int> getItems() const {
        return items;
    }
};
class ShoppingCart:
    def __init__(self):
        self._items: dict[str, int] = {}

    def add_item(self, product_id: str, quantity: int) -> None:
        if quantity <= 0:
            raise ValueError("Quantity must be positive")
        self._items[product_id] = self._items.get(product_id, 0) + quantity

    def get_items(self) -> dict[str, int]:
        return dict(self._items)  # return a copy

The pattern is always the same: if you have a collection internally, return a copy or unmodifiable view. Never return the live reference.

When to Use Records / Data Classes vs Full Encapsulation

Not every class needs getters, setters, and validation. Some objects are just data bundles. Modern languages have constructs for this.

// Java 16+ Record: immutable data carrier
// Use when the object is just a bag of values with no invariants to enforce
record Ticket(int spotId, String licensePlate, Instant entryTime) {}

// Usage
Ticket t = new Ticket(5, "ABC123", Instant.now());
t.spotId();       // auto-generated accessor
t.licensePlate(); // auto-generated accessor
// t.spotId = 10; // COMPILE ERROR -- records are immutable
// C++ struct: data carrier (public by default)
struct Ticket {
    const int spotId;
    const std::string licensePlate;
    const TimePoint entryTime;
};
# Python dataclass (frozen = immutable)
from dataclasses import dataclass
from datetime import datetime

@dataclass(frozen=True)
class Ticket:
    spot_id: int
    license_plate: str
    entry_time: datetime

When to use which:

Use a record/data class when... Use a full class with encapsulation when...
The data is immutable (never changes after creation) The state changes over time (balances, occupancy)
There are no invariants to enforce Business rules constrain valid states
It's a value being passed around (Ticket, Receipt, Coordinate) It manages a collection or resource (ParkingLot, Cart, Account)
All fields are set at construction and never modified Methods mutate internal state with validation

In an interview, using a record for Ticket and a full class for ParkingLot shows you know the difference. It tells the interviewer you're not just blindly applying encapsulation everywhere -- you understand when it matters.

Interview tip: When the interviewer asks "what attributes does Ticket have?" and you say "spotId, licensePlate, entryTime -- it's immutable, so I'd use a Java record," you've demonstrated more OOP understanding in one sentence than most candidates show in their entire design.

State vs Behavior: What Goes Where

In interviews, a common question is "what attributes and methods does this class have?" The rule is straightforward:

  • State = what the object needs to remember to do its job
  • Behavior = what actions the object can perform or answer

For a ParkingSpot:

State (what it stores) Behavior (what it does)
id getId()
spotType (MOTORCYCLE, CAR, LARGE) getSpotType()

Notice ParkingSpot does NOT store isOccupied. Whether a spot is occupied is a relationship managed by the parking lot system, not a property of the physical spot itself. This is a design decision we'll explore deeply in Chapter 5.

Quick Recap

Concept What it means Why it matters in interviews
Class Blueprint defining state + behavior You'll define 3-5 classes in every LLD interview
Object Instance of a class with actual values Each object manages its own state independently
Encapsulation Private data + public methods Prevents invalid state, makes classes self-protecting
Defensive copies Return copies of mutable collections Prevents callers from bypassing your class's rules