LLD Cohort · Week 1 · Sat 14 Mar 2026 · recording 3h34m

OOP Fundamentals & Class Relationships

This class lays the foundation for LLD. It covers what a class and an object really are, the four pillars of OOP (encapsulation, abstraction, inheritance and polymorphism), and the three ways classes relate to each other (association, aggregation and composition). It ends with a UML cheat sheet. The teacher coded in Java; every example here is rewritten in Python.

Each topic: one-line idea → the problem → real-life picture → the design step by step → Python code → doubts & corrections → remember

Course roadmap (short) ▶ 0:22:00 – 0:50:14

The LLD track moves from fundamentals to design patterns and then to solving real design problems.

LLD track, in order

  1. OOP fundamentals: class/object and the 4 pillars. Polymorphism matters most.
  2. Relationships & UML: association, aggregation and composition.
  3. SOLID principles, with small project assignments.
  4. Design patterns: creational, then structural, then behavioral, with live code and which pattern fits which problem.
  5. Problem practice ("the grind"): about 15–20 standard problems, such as a notification system, ATM, vending machine, parking lot, Splitwise, rate limiter and search/filter system. Solve each in 45–60 minutes with a timer.
  6. Mock interviews, in pairs.
Remember

"Patterns and principles are only about 50% of the job. They are the pre-computation. Practising problems is what clears interviews."

Setup: keep one Git repo with an lld/ folder, and push everything you practise. Notes and recordings go on Google Classroom.

Why OOP? Class vs object ▶ 1:01:02 – 1:29:16

A class is a blueprint that defines state (data) and behavior (methods). An object is one real instance of that blueprint, with memory and its own state.

The problem

Say you have 5 users. Each has their own data, and some functions must act on that user's data. You can do this in C with a struct and functions that take the struct as an argument. It gets painful as the system grows, though. C also has no private, so any code can change any field, and nothing enforces the rules.

Real-world problems have replication (thousands of users, cars and orders, all the same shape with different data), hierarchy, data hiding and varying behavior. OOP is an idea that models these directly. A language that supports the 4 pillars (encapsulation, abstraction, inheritance and polymorphism) is called an OOP language.

Real-life picture: the car blueprint

Cars built from one blueprint leave the showroom about 99% identical; only the chassis number differs. After a year, one has 40,000 km on it, a dent and a new stereo, while another sat in a garage. They came from the same blueprint, but each now has its own state. The blueprint is the class: you can't drive a blueprint. Each car on the road is an object.

The Amazon user. Every Amazon user has a profile, an order list, addresses, a wishlist and recommendations. There are millions of them, all "different yet the same". Each user changes their own data only through actions such as place order, cancel and change address. Those actions are the methods.

ClassObject
Blueprint / templateReal instance of the class
Defines state (fields) and behavior (methods)Has its own values for that state
No memory for the fields yetMemory is allocated when it is created
One class, many objectsObjects talk to each other through method calls

Methods are the controllers of state

You don't reach inside a car and change the fuel level yourself. You call start() or accelerate(), and those methods change several fields in a controlled way. How do you know an object has "changed"? Its field values are different from before. Methods are the doors through which the outside world asks an object to change.

Python
class Car:
    """The blueprint. No car exists until we create one."""

    def __init__(self, model: str, fuel_litres: float) -> None:
        # state (data) of ONE car
        self.model = model
        self.fuel_litres = fuel_litres
        self.speed_kmph = 0
        self.odometer_km = 0.0
        self.running = False

    # behavior = controllers of the state
    def start(self) -> None:
        self.running = True

    def accelerate(self, delta: int) -> None:
        if not self.running:
            raise RuntimeError("Start the car first")
        self.speed_kmph += delta

    def drive(self, km: float) -> None:
        self.odometer_km += km
        self.fuel_litres -= km / 15        # about 15 km per litre


# usage: two objects from one blueprint
car1 = Car("Swift", fuel_litres=40)
car2 = Car("Swift", fuel_litres=40)        # identical "out of the showroom"

car1.start(); car1.accelerate(60); car1.drive(300)
print(car1.odometer_km, round(car1.fuel_litres, 1))   # 300.0 20.0
print(car2.odometer_km, car2.fuel_litres)             # 0.0 40   (own state, untouched)

When do you need a class, and when not?

Python
# Stateless helper: a plain module-level function is fine in Python
def add(a: int, b: int) -> int:
    return a + b

print(add(25, 30))   # 55
Java vs Python

In Java, everything must live inside a class, even "Hello World", so stateless helpers become static methods on a utility class such as Math.sqrt. Python allows plain functions in a module, and that is the idiomatic home for stateless code. Use @staticmethod only when the helper clearly belongs to one class's namespace.

Small correction:

C can fake privacy. You declare an "opaque" struct in the header and define it only inside the .c file. The language doesn't enforce privacy at the struct level, though, and that was the teacher's point: it is possible but fragile.

Doubt: When should I avoid creating a class?
→

When the functions are not related to any shared state, for example maths utilities, a "draw a circle with radius r" call into a graphics library, or route handlers that only forward to a service. Group them in a module. Create a class when functions and data are inter-related and you need reuse and control over that state.

Doubt: But Java still forces a class even for utilities?
→

Yes. That is a language rule, not an OOP rule. C++ and Python don't force it.

Key takeaways
  • Class = blueprint (state + behavior, no field memory). Object = instance with its own state.
  • OOP exists to model real-world replication, hierarchy, data hiding and varying behavior.
  • Methods are the controllers of state. Outsiders change an object through methods, not fields.
  • Stateless computation doesn't need a class. Use a module function or a utility.

Encapsulation ▶ 1:29:16 – 1:43:48

Bundle data with the methods that use it, and let the outside world change that data only through controlled methods. The core idea is control and privacy.

The problem

If age is a public field, any client code can write user.age = -5, and nothing stops it. Now picture 1,000 places in the codebase that assign age directly. When a bad value appears, you have to check all 1,000.

Real-life picture

"Without my permission, nobody should know about my data, and nobody should change it." Your salary, date of birth and email are yours. People can ask through proper channels, and you decide what to allow. The class author spent time and care designing the blueprint, so they want control over how the real world uses its variables.

The litmus test

Ask whether client code (outside the class) can read or change a field directly, bypassing validation. If it can, encapsulation is violated. "The language allows it" is no defence. Defend the OOP principle, not the language.

Remember

Creating a class is not encapsulation by itself. The class is only the boundary. Encapsulation means hiding the fields and making sure nobody can bypass the controlled methods. "State should only change through controlled behavior. Never trust the client with data. Expose controlled behavior."

Building it step by step

Bad version: public fields, no checks.

Python
class User:
    def __init__(self, name: str, age: int) -> None:
        self.name = name
        self.age = age

u = User("Asha", 30)
u.age = -5            # nobody validates this, so the object is now invalid
print(u.age)          # -5

Encapsulated version: internal fields plus @property, so every write goes through one validation point.

Python
class User:
    def __init__(self, name: str, age: int) -> None:
        self._name = name     # leading _ means "internal, don't touch from outside"
        self.age = age        # goes through the setter below, so it is validated at creation too

    @property
    def name(self) -> str:    # read-only: there is no setter
        return self._name

    @property
    def age(self) -> int:
        return self._age

    @age.setter
    def age(self, value: int) -> None:
        # the ONE place that guards this field (centralised validation)
        if not 0 <= value <= 150:
            raise ValueError(f"invalid age: {value}")
        self._age = value


# usage
u = User("Asha", 30)
u.age = 31                 # looks like a field, but runs the setter
print(u.name, u.age)       # Asha 31
try:
    u.age = -5
except ValueError as e:
    print(e)               # invalid age: -5
try:
    u.name = "Hacker"
except AttributeError as e:
    print("name is read-only")   # name is read-only

Python's "private": _name and __name

Python has no real private keyword. It relies on two conventions:

Python
class Account:
    def __init__(self, salary: int) -> None:
        self.__salary = salary          # stored as _Account__salary

    def salary_band(self) -> str:       # controlled behavior: expose a band, not the number
        return "high" if self.__salary > 100_000 else "normal"

a = Account(120_000)
print(a.salary_band())                  # high
try:
    print(a.__salary)
except AttributeError:
    print("no attribute __salary")      # no attribute __salary
print(a._Account__salary)               # 120000  (still reachable, so this is NOT security)
Java vs Python

Java's private is enforced by the compiler. Python works on trust ("we are all consenting adults"). In Python you get encapsulation by keeping state in _fields, exposing @property getters and setters (or behavior methods) that validate, and never touching another object's _fields. Apply the teacher's litmus test to the public API: can a client set a bad value through it without validation?

Why it matters

Doubt: If an interface declares a variable, isn't that public, so encapsulation is broken?
→

Yes. A field in a Java interface is implicitly public static final. It is a constant shared by every implementor, not per-object state, and anyone can read it. You can't encapsulate it per object. Use interfaces for behavior (method contracts), not for state. The nearest Python equivalent is a class attribute on a base class, which is also shared by all instances.

Small correction:

In class this was said as "interface variables are public static void". The correct phrase is public static final. void is a return type, not a modifier.

Doubt: If I don't write an access specifier, is the field private by default?
→

It depends on the language. C++ class members default to private. Java defaults to package-private. In Python every attribute is public unless you use the _ convention. Keep your reasoning language-agnostic.

Key takeaways
  • Encapsulation = hidden state + controlled behavior. Making a class alone doesn't count.
  • Litmus test: if a client can bypass validation and set a field, encapsulation is broken.
  • In Python, use _field + @property with a validating setter. __field only mangles the name.
  • One controlled setter gives you one place to validate and one place to debug.

Abstraction ▶ 2:07:00 – 2:12:40

Expose what an object does and hide how it does it. You achieve it with interfaces or abstract classes.

The problem

If client code knows the internal details (which data structure, which SMS provider), every change inside forces changes in every client. The two sides are tightly coupled.

Real-life picture

You use a service through its API and don't care what happens inside. In the same way, a client calls notification.send() and never sees whether an SMS gateway, an email server or a push service does the work. Internals are protected, so outsiders can't break them.

The idea in one line

Define a contract (method names + meaning). Any implementation that honours the contract can be plugged in without changing the client. Use it whenever there are, or may later be, several ways to do the same job.

Classic example: Stack is an Abstract Data Type

Is a stack an array or a linked list? Neither. A stack is defined only by its operations and what they mean: push, pop, top and size. You can implement it with an array or a linked list, and the client can't tell the difference.

Python
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Optional


class Stack(ABC):
    """The contract: WHAT a stack does. No HOW."""
    @abstractmethod
    def push(self, item: Any) -> None: ...
    @abstractmethod
    def pop(self) -> Any: ...
    @abstractmethod
    def top(self) -> Any: ...
    @abstractmethod
    def size(self) -> int: ...


class ArrayStack(Stack):
    """HOW #1: contiguous memory (a Python list)."""
    def __init__(self) -> None:
        self._items: list[Any] = []
    def push(self, item: Any) -> None: self._items.append(item)
    def pop(self) -> Any: return self._items.pop()
    def top(self) -> Any: return self._items[-1]
    def size(self) -> int: return len(self._items)


@dataclass
class _Node:
    value: Any
    next: Optional["_Node"] = None


class LinkedListStack(Stack):
    """HOW #2: non-contiguous memory (nodes linked by references)."""
    def __init__(self) -> None:
        self._head: Optional[_Node] = None
        self._count = 0
    def push(self, item: Any) -> None:
        self._head = _Node(item, self._head)
        self._count += 1
    def pop(self) -> Any:
        node = self._head
        self._head = node.next
        self._count -= 1
        return node.value
    def top(self) -> Any: return self._head.value
    def size(self) -> int: return self._count


def reverse(word: str, stack: Stack) -> str:
    """Client code: depends only on the contract."""
    for ch in word:
        stack.push(ch)
    return "".join(stack.pop() for _ in range(stack.size()))


# usage
print(reverse("design", ArrayStack()))       # ngised
print(reverse("design", LinkedListStack()))  # ngised
# Stack()  -> TypeError: Can't instantiate abstract class Stack
classDiagram
  class Stack {
    +push(item)
    +pop()
    +top()
    +size()
  }
  class ArrayStack
  class LinkedListStack
  ArrayStack ..|> Stack
  LinkedListStack ..|> Stack

Side note: array vs linked list memory

Java vs Python

Java has the interface keyword. Python uses an abstract base class: inherit from abc.ABC and mark methods with @abstractmethod. A subclass that misses one can't be instantiated, which matches Java's "must implement all methods" rule. Python also supports duck typing and typing.Protocol, but an ABC is the clearest way to state an LLD contract.

Key takeaways
  • Abstraction = expose what, hide how.
  • It reduces coupling. You can swap or add implementations without touching the client.
  • A Stack is an ADT. An array or a linked list is just one possible "how".
  • In Python, express contracts with ABC + @abstractmethod.

Inheritance ▶ 1:43:48 – 2:07:00

A child class reuses the parent's properties and behavior, adds its own, and can override what it wants to do differently. It models an IS-A relationship.

The problem

Without inheritance, every new class rebuilds from scratch what similar classes already have, and the same logic is copied in many places.

Real-life picture

A new car model isn't built from zero. Tyres, engine design and airbags are decades of evolution that it inherits. Humans inherit eyes and speech from a long history in the same way. Software is collective work too, and inheritance lets you stand on what the parent already built.

Vocabulary

Real inheritance vs "forced inheritance"

Inherit from a classImplement an interface
You get properties: real logic. It is a "gift from the parent".You get expectations: declarations only. "You must implement this."
The child may reuse or override.The child must implement every method.

An abstract class mixes both. Some methods have a definition you want reused, and others are abstract and must be implemented. Rule of thumb: put non-negotiable shared behavior (every animal breathes) in an abstract base class. Put optional capabilities (flying) in an interface.

Python
from abc import ABC, abstractmethod


class Animal(ABC):
    def __init__(self, name: str) -> None:
        self.name = name

    def breathe(self) -> str:                  # gift: shared logic, reused as-is
        return f"{self.name} breathes"

    @abstractmethod
    def make_sound(self) -> str:               # expectation: each child must define it
        ...


class Flyable(ABC):                            # optional capability = "interface"
    @abstractmethod
    def fly(self) -> str: ...


class Dog(Animal):
    def make_sound(self) -> str:
        return "Woof"


class Bird(Animal, Flyable):                   # IS-A Animal, and CAN fly
    def __init__(self, name: str, wingspan_cm: int) -> None:
        super().__init__(name)                 # set up the parent part first
        self.wingspan_cm = wingspan_cm

    def make_sound(self) -> str:
        return "Tweet"

    def fly(self) -> str:
        return f"{self.name} flies with a {self.wingspan_cm} cm wingspan"


# usage
d, b = Dog("Bruno"), Bird("Kiwi", 20)
print(d.breathe(), "|", d.make_sound())   # Bruno breathes | Woof
print(b.breathe(), "|", b.fly())          # Kiwi breathes | Kiwi flies with a 20 cm wingspan
classDiagram
  class Animal {
    +name
    +breathe()
    +make_sound()
  }
  class Flyable {
    +fly()
  }
  class Dog {
    +make_sound()
  }
  class Bird {
    +wingspan_cm
    +make_sound()
    +fly()
  }
  Dog --|> Animal
  Bird --|> Animal
  Bird ..|> Flyable
Java vs Python

Java separates extends (one class) from implements (many interfaces). Python has one mechanism, class inheritance, and allows several bases, so class Bird(Animal, Flyable) plays both roles. Also, Java automatically calls the parent constructor (super()) first. Python does not. If the child defines __init__, you must call super().__init__(...) yourself, or the parent's fields are never set.

"Why inherit if I can just hold a parent object?"

A student asked this. If class C keeps an A() inside, it can call all of A's methods. What does inheritance add? Two things. First, customisation: the child can keep methods 1–4 and override method 5. Second, substitutability: a child can be used anywhere an A is expected, and that is what polymorphism is built on.

Python
class A:
    def m4(self) -> str: return "A.m4"
    def m5(self) -> str: return "A.m5"
    def run(self) -> str: return f"{self.m4()} + {self.m5()}"


class B(A):                     # IS-A: reuse m4, override only m5
    def m5(self) -> str: return "B.m5 (custom)"


class C:                        # HAS-A: holds an A, can call it, can't change it
    def __init__(self) -> None:
        self.a = A()
    def run(self) -> str: return self.a.run()


print(B().run())   # A.m4 + B.m5 (custom)   (A's run() picks up B's override)
print(C().run())   # A.m4 + A.m5            (no way to customise m5 from outside)
Remember

"Prefer composition over inheritance" is a guideline, not a law. Understand why first. If you deviate from a principle, know that you are deviating. The real problem is people who break principles without knowing it.

Doubt: If two unrelated classes (Bird, Mammal) both need count_legs(), interface or abstract class?
→

For a capability that unrelated classes may share, use an interface (an ABC with only abstract methods). For something non-negotiable that every member of a family has, such as breathe(), use an abstract base class with the shared definition. A robot is not an animal, so it shouldn't inherit breathe().

Doubt: How is an abstract class different from an interface?
→

An abstract class can carry real definitions you want reused ("use this logic, don't write your own") as well as abstract methods the child must fill in. An interface carries only declarations: the expectations.

Key takeaways
  • Inheritance = IS-A. The child reuses, extends and can override the parent.
  • A class gives a "gift" (logic). An interface gives "expectations" (a contract). An abstract class gives both.
  • Check that the IS-A sentence makes real-world sense. LSP formalises this later.
  • In Python, call super().__init__() explicitly. Use an ABC for interfaces and multiple bases for "extends + implements".
  • Inheritance beats "just hold a parent object" when you need overrides or substitutability.

Polymorphism ▶ 2:12:40 – 2:33:00

"One interface, many implementations": the same method call does different things depending on the actual object. The teacher called this the most important pillar for LLD, because most design patterns are built on it.

The problem

Without it, the client needs an if/elif chain such as "if SMS do this, if email do that, if push do the other". The chain grows every time a new channel is added, and it gets copied into every caller.

Real-life picture: "The client is lazy and loves clarity"

The client wants to write one line, notification.send(message), and nothing more. Behind it sit SMS, email and push classes, each with its own send. At runtime, the object actually stored in notification decides where the call goes. Don't confuse the client with every definition in your pocket. Expose one API.

Two kinds

Compile-time (static)Runtime (dynamic)
Method overloading: same name, different parameters, in the same classMethod overriding: the child redefines the parent's method
Chosen by the compiler from the argument typesChosen at runtime from the actual object

Python has no method overloading, so use default arguments

In Python, a second def area(...) in the same class simply replaces the first. To get "one name, different parameter lists", use default or optional arguments. The class example was "pass a radius for a circle, or length and breadth for a rectangle".

Python
import math


class Shape:
    def area(self, a: float, b: float | None = None) -> float:
        if b is None:                 # one argument: treat as circle radius
            return math.pi * a * a
        return a * b                  # two arguments: rectangle length x breadth


s = Shape()
print(round(s.area(2), 2))   # 12.57
print(s.area(3, 4))          # 12
Java vs Python

Java picks between overloads at compile time from the argument types. Python has no compile-time overloading. Use default arguments, *args, or (for dispatch on the type of the first argument) functools.singledispatchmethod. Overriding works just like in Java, and every Python method is "virtual" by default.

The key rule: reference type vs object type

In Java, A a2 = new B(); is allowed because a B IS-A A. Two rules follow:

  1. The reference type (left side) decides what you can call. a2.m1() fails to compile with "method m1 is undefined for type A". A parent knows nothing about methods a child added later. A 1950 car has no idea about 2025 features.
  2. The object type (right side) decides which implementation runs for overridden methods. a2.send() runs B's send. That is runtime polymorphism. If B doesn't override, the parent's version runs.
Python
class A:
    def m5(self) -> str: return "A.m5"
    def send(self) -> str: return "A.send"


class B(A):
    def m1(self) -> str: return "B.m1"           # new method, unknown to A
    def send(self) -> str: return "B.send"       # override


a1: A = A()
a2: A = B()            # allowed: a B IS-A A
b: B = B()

print(a1.send())       # A.send
print(a2.send())       # B.send   (the actual object decides)
print(a2.m5())         # A.m5     (not overridden, so the parent's version runs)
print(b.m1(), b.m5())  # B.m1 A.m5
# a2.m1()  -> a type checker (mypy) reports: "A" has no attribute "m1"
Java vs Python

Python variables have no fixed type, so at runtime a2.m1() would actually work (duck typing). The "reference type decides what you can call" rule is enforced in Python only by a type checker (mypy, pyright) reading the a2: A annotation. The design rule still holds: code against the parent's API, and don't reach for child-only methods through a parent reference.

The notification example, step by step

Bad version: the client branches on the type.

Python
def notify(pref: str, msg: str) -> None:
    if pref == "sms":
        print(f"SMS: {msg}")
    elif pref == "email":
        print(f"Email: {msg}")
    # every new channel means editing this function, and every copy of it

Improved version: the client depends on the Notification abstraction, and the call is routed at runtime.

Python
from abc import ABC, abstractmethod


class Notification(ABC):
    @abstractmethod
    def send(self, message: str) -> None: ...


class SmsNotification(Notification):
    def send(self, message: str) -> None:
        print(f"SMS  -> {message}")


class EmailNotification(Notification):
    def send(self, message: str) -> None:
        print(f"Email-> {message}")


class PushNotification(Notification):
    def send(self, message: str) -> None:
        print(f"Push -> {message}")


def get_notification(user_pref: str) -> Notification:
    channels = {"sms": SmsNotification, "email": EmailNotification, "push": PushNotification}
    return channels[user_pref]()


# usage: the client writes ONE statement; the object decides the behavior
for pref in ("sms", "email", "push"):
    n: Notification = get_notification(pref)
    n.send("Your order has shipped")
# SMS  -> Your order has shipped
# Email-> Your order has shipped
# Push -> Your order has shipped
classDiagram
  class Client
  class Notification {
    +send(message)
  }
  class SmsNotification {
    +send(message)
  }
  class EmailNotification {
    +send(message)
  }
  class PushNotification {
    +send(message)
  }
  Client --> Notification : uses
  SmsNotification --|> Notification
  EmailNotification --|> Notification
  PushNotification --|> Notification
Doubt: With A obj = new B(), can I call B's own new methods?
→

Not through the A reference. The class debated this at length. The left side decides what is accessible, and a parent can't know what a child added. You would need a cast to B. If you find yourself casting a lot, your abstraction is missing a method.

Doubt: When I create new B(), does A's constructor run?
→

Yes. In Java the parent's constructor always runs first (an implicit super()). In Python, only if B's __init__ calls super().__init__(), or if B doesn't define __init__ at all.

Key takeaways
  • Polymorphism = one interface, many implementations, decided by the actual object at runtime.
  • Reference type = what you can call. Object type = which version runs.
  • Python has no overloading. Use default arguments, *args or singledispatchmethod.
  • The client depends on the abstraction (Notification), never on the concrete classes.
  • Most design patterns are polymorphism + inheritance in different shapes.

Class relationships: association, aggregation, composition ▶ 2:47:28 – 3:31:16

The relationship tells you how strongly two classes depend on each other. Association means they use each other. Aggregation means one has (collects) the other. Composition means one owns the other, and the part lives and dies with it.

The problem

Real systems are full of collaborating objects. Amazon has users, orders, payments, carts, inventory, catalogs and recommendations. Naming how they relate makes a design readable. It also tells you which objects depend on which, and what must be cleaned up before what. Not every pair is related: an order service and a payment service may not know each other at all. Only draw a relationship you can justify with a meaningful verb ("doctor treats patient"; "doctor ... engineer"? There's no verb, so there's no line).

Python
Association   (weakest, most general: "uses / knows about")
  └── Aggregation   ("has-a": collects references, independent lifecycles)
        └── Composition   (strongest: "owns", lifecycle-dependent)

Every aggregation and composition is also an association. Inheritance (IS-A) is a separate kind of relationship.

1. Association: "uses / knows about"

Real-life picture: doctor and patient

A doctor treats a patient. Neither owns the other, and each lives its own life. You also have little control over the other side: if the doctor treats you badly, you can't remove them from the hospital. The same goes for a waiter who serves you; you can only complain. Other examples: student learns LLD, employee works in company.

Python
class Patient:
    def __init__(self, name: str) -> None:
        self.name = name


class Doctor:
    def __init__(self, name: str) -> None:
        self.name = name

    def treat(self, patient: Patient) -> str:   # uses a Patient; keeps no list, creates nothing
        return f"Dr. {self.name} treats {patient.name}"


print(Doctor("Rao").treat(Patient("Meera")))   # Dr. Rao treats Meera

2. Aggregation: "has-a" (a collection of independent objects)

Real-life picture: a team, and Zomato

A manager forms a team from players who already exist. The team keeps a list of them and can add, remove or iterate over them, which is more control than association gives you. If the team is dissolved, the players still exist and join another team. Aggregator apps are literally aggregation. Zomato aggregates restaurants. If Zomato disappears, the restaurants keep making dosa. Practo, BookMyShow and MakeMyTrip work the same way. Other examples: library has books, cart has products, company has employees.

Python
class Player:
    def __init__(self, name: str) -> None:
        self.name = name


class Team:
    def __init__(self, name: str) -> None:
        self.name = name
        self._players: list[Player] = []          # stores REFERENCES

    def add_player(self, player: Player) -> None:  # player is created OUTSIDE and passed in
        self._players.append(player)

    def roster(self) -> list[str]:
        return [p.name for p in self._players]


# usage
p1, p2 = Player("Virat"), Player("Rohit")   # they exist before any team
team = Team("Blue")
team.add_player(p1)
team.add_player(p2)
print(team.roster())    # ['Virat', 'Rohit']

del team                # the team is gone...
print(p1.name)          # Virat   (...the players live on)

3. Composition: "owns" (strongest)

Real-life picture: house and rooms

Rooms have no meaning without their house. Knock the house down and the rooms go with it. The owner creates the parts itself (typically in its constructor), so the parts' lifecycle depends on the owner. Other examples: order consists of order items, body has heart.

Python
import weakref


class Room:
    def __init__(self, name: str) -> None:
        self.name = name


class House:
    def __init__(self) -> None:
        # the House CREATES its rooms; nobody outside made them, so it owns them
        self._rooms = [Room("Kitchen"), Room("Bedroom")]

    def room_names(self) -> list[str]:
        return [r.name for r in self._rooms]


# usage
house = House()
print(house.room_names())                 # ['Kitchen', 'Bedroom']

kitchen = weakref.ref(house._rooms[0])    # peek only; a weak ref doesn't keep it alive
del house                                 # destroy the owner...
print(kitchen())                          # None   (...its rooms are gone too, in CPython)
Java vs Python

In both languages, "destroyed" means "no references left, so the garbage collector frees it". CPython frees an object as soon as its last reference disappears, which is why the demo prints None right away. Composition is a design rule: the owner creates the parts and never hands out ownership. If you leak a Room reference to outside code, it would outlive the house.

classDiagram
  class Doctor {
    +treat(patient)
  }
  class Patient
  class Team {
    +add_player(player)
    +roster()
  }
  class Player
  class House {
    +room_names()
  }
  class Room
  Doctor --> Patient : treats
  Team o-- Player : has
  House *-- Room : owns

Quick identification test

  1. Does A create B, so that B can't outlive A? → Composition (filled diamond ◆ on A's side).
  2. Does A keep a collection or reference of Bs that exist independently? → Aggregation (hollow diamond ◇ on A's side).
  3. Do they only interact or know about each other? → Association (plain line or arrow, labelled with the verb).
  4. Does "B is an A" make sense? → Inheritance (generalisation). Implementing an interface is realisation.
PairRelationshipWhy
Doctor – PatientAssociationTreats; no list, no ownership (in this model)
Clinic app – DoctorsAggregationKeeps a list; doctors change clinics and exist without it
Team – PlayerAggregationPlayers are passed in and outlive the team
Company – EmployeesAggregationThe employee still exists if the company closes
Order – ProductAssociation / aggregationCancelling the order doesn't destroy the Samsung phone
Order – OrderItemCompositionOrder items don't exist without the order
House – RoomCompositionRooms are created by and die with the house
Remember

LLD is subjective, and the answer depends on what your code does. If the Doctor class keeps a list of patients, that pair becomes aggregation. If it creates them, it becomes composition. Car–Engine (suggested in class) is composition only if the car builds its engine and it is never swapped out. If engines are created elsewhere and fitted in, it is aggregation.

Common mistake:

Don't nest forever. Someone will say "the list inside Team is technically composition". It is, but pick the layer you are analysing (your own domain classes) and stop there. The same "where is the boundary?" question comes up later with SOLID.

Doubt: "Employee works in Company" is association. What about "Company has Employees"?
→

Aggregation. The company keeps a list of employees, but they exist independently, because people change jobs. Composition would be wrong, since there is no lifecycle dependency.

Doubt: Isn't Order–Product composition, since an order needs a product?
→

No. Ask about the lifecycle instead. If you cancel the order, is the phone destroyed? No. It can join other orders. So it is an association (or aggregation). OrderItem is the part that dies with the order.

Doubt: Is inheritance also a kind of association?
→

In a broad sense the child "knows about" the parent. In UML, though, generalisation (IS-A) is drawn and reasoned about separately from the HAS-A family.

Doubt: B extends A, and A also creates a B inside itself. Which relationship is it?
→

Both: B IS-A A, and A HAS-A (owns) B. That combination leads straight into the Composite design pattern, which is continued next class.

Key takeaways
  • Association (uses) ⊃ aggregation (has, independent) ⊃ composition (owns, lifecycle-bound).
  • The one deciding question: if the container dies, does the part die too?
  • Who creates the part? Created inside means composition. Passed in from outside means aggregation.
  • Aggregator apps (Zomato, Practo, BookMyShow) are a handy picture of aggregation.
  • Only draw relationships you can justify with a verb, and fix the boundary of your analysis.

Homework: read up on the OOP pillars, the three relationships and static variables. Code A/B overriding, Team/Player and House/Room yourself. Next class is SOLID.

UML class diagram cheat sheet ▶ 1:48:20 – 1:50:10 · 3:21:09 – 3:25:53

UML (Unified Modeling Language) is how you sketch classes and their relationships before writing code. Any picture of classes plus relationships drawn without code counts.

A class box

Python
┌──────────────────┐
│    ClassName     │   name
├──────────────────┤
│ - private_field  │   fields (state)
│ + public_field   │
├──────────────────┤
│ + public_method()│   methods (behavior)
│ - helper()       │
└──────────────────┘
SymbolMeaning
+public
-private
#protected
~package / default
Solid line, hollow triangle pointing at the parentInheritance / generalisation (B extends A)
Dashed line, hollow triangle pointing at the interfaceRealisation (B implements I)
Plain line or arrow, labelled with a verbAssociation
Hollow diamond ◇ on the container sideAggregation
Filled diamond ◆ on the owner sideComposition
classDiagram
  class Animal
  class Dog
  class Flyable
  class Bird
  class Employee
  class Company
  class Team
  class Player
  class Order
  class OrderItem
  Dog --|> Animal : is a
  Bird ..|> Flyable : implements
  Employee --> Company : works in
  Team o-- Player : has
  Order *-- OrderItem : consists of
Small correction:

In class, "star" was suggested for default access, and the protected symbol was left open. Standard UML uses ~ for package/default and # for protected.

Tip

Label lines with meaningful verbs ("consists of", "works in", "treats") rather than a bare "has-a". The verb makes the story of the design readable. In interviews, the most-used pieces are IS-A vs HAS-A and spotting aggregation, because a list of things hints that a relationship exists.

Key takeaways
  • A class box has three parts: name, fields, methods. Visibility symbols are + - # ~.
  • Triangle = IS-A (solid line for extends, dashed for implements). Diamond = HAS-A (hollow for aggregation, filled for composition).
  • The diamond always sits on the container or owner side.