ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Lesson 3 of 10 25 min

Classes, objects and dataclasses

Model real things like orders, tickets and conversations as classes and dataclasses, choose between inheritance and composition, and decide when a plain function is the better tool.

Words in this lessonClass · Object · Constructor · Method · Inheritance · Composition · Dataclass
Class
A blueprint that describes what a kind of thing has and what it can do. Like a cake mould: it is not a cake, but every cake made from it has the same shape.
Object
One real thing built from a class, with its own data. Like one cake from the mould, with its own flavour and icing message.
Constructor
The method that sets up a new object when it is created. Like filling in the order slip the moment a new cake goes into the oven.
Method
A function that belongs to a class and works on one object's data. Like a button on a coffee machine: it acts on that machine, not on every machine in the shop.
Inheritance
Building a new class on an existing one, keeping its behaviour and changing only what differs. Like a seasonal recipe based on the classic: same steps, one ingredient swapped.
Composition
Building an object out of other objects it holds and uses. Like a Diwali hamper: it contains sweets and candles, but it is not a kind of sweet.
Dataclass
A Python shortcut for classes that mainly hold data. Like a pre-printed order form: the boxes are already drawn, you fill them in.
On this page
  1. Arjun's first fix: a checklist nobody has to follow
  2. The cake mould and the cakes
  3. Following one order from mould to cake
  4. Opening the hood: what an object really is
  5. Printing objects nicely: `__repr__`
  6. Two ways to build on a class: "is a" and "has a"
  7. Inheritance: "is a kind of"
  8. Composition: "has a"
  9. Dataclasses: the mould, without the paperwork
  10. The bug that started this lesson, caught twice
  11. Frozen dataclasses
  12. When OOP fits AI and backend apps
  13. Where it breaks
  14. Arjun explains it to Meera
  15. Try it yourself
  16. Common mistakes
  17. Recap
  18. Go deeper

On Tuesday morning, Meera has a question nobody wants. “Why did a customer in Baner get a ₹1,200 cake for free on Saturday?”

Arjun traces it in ten minutes, and his ears go red. Deep in the billing script, he’d typed order.get("totl", 0) instead of order.get("total", 0). The key "totl" didn’t exist, .get politely returned the default 0, and the payment link was generated for ₹0. The customer paid nothing. Nobody noticed for three days.

“How many other places could this happen?” Meera asks.

Arjun searches the codebase for order[ and order.get(. Sixty-three results, across nine files.

Important

The idea in one sentence: A class is a blueprint that bundles data together with the actions that work on that data, so every object built from it has a guaranteed shape; dataclasses let you write those blueprints in a few lines; and you reach for them when something in your app has a clear shape, state that changes, or behaviour that belongs with it.

Arjun’s first fix: a checklist nobody has to follow

Arjun’s instinct is to add a safety check. He writes a function that every part of the code should call before touching an order:

REQUIRED = {"order_id", "store", "total"}

def check_order(order):
    missing = REQUIRED - set(order)          # set difference: required keys not present
    if missing:
        raise ValueError(f"Order is missing {missing}")

check_order({"order_id": "CR-2101", "store": "Baner", "total": 1200})
print("first order OK")
check_order({"order_id": "CR-2102", "store": "Baner", "totl": 1200})
first order OK
ValueError: Order is missing {'total'}

It works when he calls it. Priya reads it and asks two questions.

“What stops the WhatsApp importer, written by someone else next month, from forgetting to call check_order?”

Nothing does.

“And it checks that total exists when the order is created. What stops someone reading it as order['totl'] later?”

Nothing does that either. The original bug was a typo on read, not on create. His check wouldn’t have caught it.

“A dictionary will hold any key, spelled any way, and give you anything back,” Priya says. “Checklists that people must remember don’t scale. What you want is an order that knows what an order is, and complains the moment anyone gets it wrong.”

The cake mould and the cakes

Priya draws a heart-shaped cake mould on the whiteboard, then three cakes next to it.

“Is the mould a cake?”

“No. It’s what you make cakes with.”

“And do all three cakes taste the same?”

“No. Same shape, different flavours, different messages in icing.”

That’s the whole idea:

  • The mould is a class: a blueprint that describes what every thing of its kind has and what it can do.
  • Each cake is an object (also called an instance): one real thing built from the blueprint, with its own values.
class Cake:
    def __init__(self, flavour, message):
        self.flavour = flavour      # this cake's own flavour
        self.message = message      # this cake's own icing message

    def describe(self):
        return f"A {self.flavour} cake that says '{self.message}'"


birthday = Cake("chocolate", "Happy 30th, Kabir!")
diwali = Cake("kesar pista", "Shubh Deepavali")

print(birthday.describe())
print(diwali.describe())
print(birthday.flavour)
A chocolate cake that says 'Happy 30th, Kabir!'
A kesar pista cake that says 'Shubh Deepavali'
chocolate

You create an object by calling the class like a function: Cake("chocolate", "..."). Class names use CapWords (Cake, SupportTicket), unlike the snake_case used for variables and functions, so you can tell at a glance which is which.

Two new words appear in that code: __init__ and self. They’re the heart of how classes work, so let’s watch them in action on one order.

Following one order from mould to cake

Here’s a first Order class. It holds an ID, a store, and a list of lines, and it knows how to add a line and total itself:

class Order:
    def __init__(self, order_id, store):
        self.order_id = order_id
        self.store = store
        self.lines = []

    def add(self, name, qty, price):
        self.lines.append({"name": name, "qty": qty, "price": price})

    def total(self):
        return sum(line["qty"] * line["price"] for line in self.lines)


order = Order("CR-2101", "Baner")
print(vars(order))

order.add("Diwali hamper", 2, 1499)
order.add("Butter croissant", 6, 85)
print(vars(order))

print(order.total())
print(Order.total(order))
{'order_id': 'CR-2101', 'store': 'Baner', 'lines': []}
{'order_id': 'CR-2101', 'store': 'Baner', 'lines': [{'name': 'Diwali hamper', 'qty': 2, 'price': 1499}, {'name': 'Butter croissant', 'qty': 6, 'price': 85}]}
3508
3508

vars(order) lets us peek at an object’s own data. Trace what happened, step by step:

Step Code What Python does Object’s data afterwards
1 Order("CR-2101", "Baner") Creates a blank object, then calls __init__ with self = that blank object (empty)
2 self.order_id = order_id Stores "CR-2101" on this object order_id
3 self.store = store Stores "Baner" order_id, store
4 self.lines = [] Gives this object its own empty list order_id, store, lines: []
5 order.add("Diwali hamper", 2, 1499) Calls add with self = order lines has 1 item
6 order.add("Butter croissant", 6, 85) Same again lines has 2 items
7 order.total() 2 × 1499 + 6 × 85 = 2998 + 510 unchanged; returns 3508

Now the two new words make sense:

  • __init__ (say “dunder init”, for the double underscores) is the constructor: the method Python runs automatically when a new object is created. Its job is to set up the object’s starting data. Think of it as filling in the order slip the moment a new cake goes in the oven.
  • self is “this particular object”. When you write self.lines = [], you’re saying “give this order its own list”. Values stored on self are called attributes.

And here’s the line that reveals the trick: Order.total(order) gives the same answer as order.total(). When you call a method on an object, Python quietly passes the object in as the first argument. That’s all self is. You never pass it yourself.

Opening the hood: what an object really is

Look again at the output of vars(order). It’s a dictionary. That’s not a coincidence. To a first approximation, a Python object is:

  1. A dictionary of its own data (its attributes), plus
  2. A link to its class, where the shared methods (functions defined inside a class) live.

When you write order.total, Python looks in the object’s own data first, then in its class. Every Order shares one copy of the total method, but each has its own order_id, store and lines.

So if an object is “a dict plus methods”, what did we gain? Three things:

  • One place for behaviour. total() lives with the data it totals. There aren’t nine files each with their own slightly different total function.
  • A guaranteed starting shape. Every Order is built by __init__, so every order has order_id, store and lines. Nobody can create one without them.
  • Loud typos. order.totl isn’t a key lookup with a polite default. It’s an attribute that doesn’t exist, and Python raises an error.
class Order:
    def __init__(self, order_id, store):
        self.order_id = order_id
        self.store = store
        self.lines = []

order = Order("CR-2101", "Baner")
try:
    print(order.totl)
except AttributeError as error:
    print("Caught:", error)
Caught: 'Order' object has no attribute 'totl'

If you don’t catch that error, Python’s full error report even adds a hint at the end: Did you mean: 'total'?.

Printing objects nicely: __repr__

Try print(order) and you get something unhelpful like <__main__.Order object at 0x10a2b3c40>: the class name and a memory address. Add a __repr__ method to control what’s shown:

class SupportTicket:
    def __init__(self, ticket_id, customer, message):
        self.ticket_id = ticket_id
        self.customer = customer
        self.message = message
        self.status = "open"          # every new ticket starts open
        self.replies = []             # ...with no replies

    def reply(self, text):
        self.replies.append(text)

    def close(self):
        self.status = "closed"

    def __repr__(self):
        return f"SupportTicket({self.ticket_id}, {self.status}, {len(self.replies)} replies)"


ticket = SupportTicket(501, "Rohan", "My order is 40 minutes late")
print(ticket)
ticket.reply("Sorry, Rohan! Your rider is 5 minutes away.")
ticket.close()
print(ticket)
SupportTicket(501, open, 0 replies)
SupportTicket(501, closed, 1 replies)

The ticket carries its own state (its status and replies), and the actions that change that state live right next to it. Keeping data and behaviour together like this is the core idea of object-oriented programming, or OOP. __repr__ is one of many special “dunder” methods Python looks for; another is __eq__, which decides what == means for your objects.

Two ways to build on a class: “is a” and “has a”

Arjun’s ticket system now needs special handling for allergy questions. A customer asking “does the plum cake contain walnuts?” might be about to eat it. Those tickets must always be urgent.

Inheritance: “is a kind of”

An allergy ticket is a kind of support ticket. It has everything a support ticket has, and changes one behaviour. Inheritance lets a new class build on an existing one, keeping everything and overriding only what’s different.

class SupportTicket:
    def __init__(self, ticket_id, message):
        self.ticket_id = ticket_id
        self.message = message

    def priority(self):
        return "normal"

    def summary(self):
        return f"#{self.ticket_id} [{self.priority()}] {self.message}"


class AllergyTicket(SupportTicket):           # inherits from SupportTicket
    def __init__(self, ticket_id, message, allergen):
        super().__init__(ticket_id, message)   # run the parent's setup first
        self.allergen = allergen

    def priority(self):                       # override the parent's version
        return "URGENT"


tickets = [
    SupportTicket(601, "Can I change my delivery address?"),
    AllergyTicket(602, "Does the plum cake contain walnuts?", allergen="nuts"),
]
for t in tickets:
    print(t.summary())
#601 [normal] Can I change my delivery address?
#602 [URGENT] Does the plum cake contain walnuts?

AllergyTicket never defines summary(); it inherits it from its parent (also called the base class). But when summary() calls self.priority(), self is an AllergyTicket, so Python finds the child’s version first. super().__init__(...) means “run my parent’s constructor too”, so the shared setup isn’t copied.

Composition: “has a”

A Diwali hamper contains kaju katli, cookies and a candle. Is a hamper a kind of cookie? No. It has cookies. When something is made of other things, build it from them. This is composition.

Our Order already does this with its list of lines. Making each line a proper object, rather than a dictionary, gives the lines the same protection the order has:

class OrderLine:
    def __init__(self, name, qty, price):
        self.name = name
        self.qty = qty
        self.price = price

    def total(self):
        return self.qty * self.price


class Order:
    def __init__(self, order_id):
        self.order_id = order_id
        self.lines = []                     # an Order HAS order lines

    def add(self, name, qty, price):
        self.lines.append(OrderLine(name, qty, price))

    def total(self):
        return sum(line.total() for line in self.lines)


order = Order("CR-2101")
order.add("Diwali hamper", 2, 1499)
order.add("Butter croissant", 6, 85)
print(order.total())
print([line.name for line in order.lines])
3508
['Diwali hamper', 'Butter croissant']

Note

Pause and think: Crumb sells a “Festive Joy” hamper for a fixed price, and it appears on the menu like any other item. Arjun proposes class Hamper(MenuItem), so a hamper inherits from menu item. Priya suggests a Hamper that holds a list of MenuItems. Who’s right? Could both be?

Show the answer

Both relationships are real, and that’s the point of asking the two questions separately. A hamper is a kind of thing you can sell (it has a name and a price on the menu), so inheriting from a general MenuItem is defensible. But a hamper also has items inside it, and that part must be composition: a list of MenuItem objects. What would be wrong is making Hamper inherit from Cookie or KajuKatli to get their allergens. When in doubt, prefer composition. Deep inheritance chains (Ticket → UrgentTicket → UrgentAllergyTicket → …) become brittle, because a change to any parent ripples into every child.

Dataclasses: the mould, without the paperwork

Look at OrderLine again. Its __init__ does nothing but copy three arguments onto self. For most data-holding classes, that’s all the constructor ever does, plus a __repr__ and an __eq__ you’d also write by hand. Python has a shortcut: the @dataclass decorator (a line starting with @ that adds abilities to the class or function defined below it).

from dataclasses import dataclass, field


@dataclass
class MenuItem:
    name: str
    price: int
    eggless: bool = False                               # a default value
    allergens: list[str] = field(default_factory=list)  # a fresh list per item


croissant = MenuItem("Butter croissant", 85)
brownie = MenuItem("Walnut brownie", 90, eggless=True, allergens=["nuts", "gluten"])

print(croissant)
print(brownie.allergens)
print(croissant == MenuItem("Butter croissant", 85))
MenuItem(name='Butter croissant', price=85, eggless=False, allergens=[])
['nuts', 'gluten']
True

From those few lines, @dataclass writes three methods for you:

Generated method What you’d otherwise write by hand
__init__ def __init__(self, name, price, eggless=False, allergens=None): self.name = name ...
__repr__ return f"MenuItem(name={self.name!r}, price={self.price!r}, ...)"
__eq__ Compare every field of two objects, return True if all match

The name: str parts are type hints: labels saying what type each field should hold. A dataclass needs them to know which fields exist. Python doesn’t check them at runtime (MenuItem("Croissant", "eighty") would be accepted). You’ll learn what they’re really for, and how to make them enforced, in the type hints and Pydantic lesson.

The bug that started this lesson, caught twice

from dataclasses import dataclass


@dataclass
class Order:
    order_id: str
    total: int


order = Order("CR-1042", 1200)

try:
    print(order.totl)
except AttributeError as error:
    print("Caught on read:", error)

try:
    Order("CR-1043", totl=1200)
except TypeError as error:
    print("Caught on create:", error)
Caught on read: 'Order' object has no attribute 'totl'
Caught on create: Order.__init__() got an unexpected keyword argument 'totl'

The typo fails whether you make it while reading or while creating, immediately, with a message that names the problem. A loud error while testing costs a minute. A quiet ₹0 in production cost a ₹1,200 cake.

Frozen dataclasses

Add frozen=True and the object can’t be changed after it’s created. That suits settings and records that should never be edited by accident:

from dataclasses import dataclass, FrozenInstanceError


@dataclass(frozen=True)
class StoreConfig:
    name: str
    delivery_radius_km: int


baner = StoreConfig("Baner", 6)
try:
    baner.delivery_radius_km = 50
except FrozenInstanceError as error:
    print("Caught:", error)
Caught: cannot assign to field 'delivery_radius_km'

Note

Pause and think: a = MenuItem("Butter croissant", 85) and b = MenuItem("Butter croissant", 85). Is a == b? Is a is b? Now c = a, and you run c.price = 90. What’s a.price?

Show the answer

a == b is True: the dataclass’s __eq__ compares the fields, and they match. a is b is False: is asks “are these the same object?”, and they’re two separate cakes from the same mould that happen to look identical. And a.price is 90. c = a doesn’t copy anything. It puts a second name tag on the same object, so changing it through c changes what a sees. This “two names, one object” effect causes real bugs when a list or object is shared between two parts of a program.

When OOP fits AI and backend apps

Arjun is now tempted to make everything a class. Priya stops him. “A function that turns a list of orders into a summary is perfectly happy as a function. Reach for a class when one of three things is true.”

  1. The thing has a clear shape. Orders, menu items, tickets, an LLM’s response. Use a dataclass, or, when the data arrives from outside your program, a Pydantic model (coming in two lessons).
  2. The thing has state that changes over time. A chat conversation remembers its messages. An agent remembers which tools it has tried. A rate limiter counts recent calls.
  3. You want several things to be swappable. A Claude provider and an OpenAI provider that both offer .complete(messages), so the rest of your app doesn’t care which one it’s talking to.

Here’s case 2 and case 3 together: a conversation that remembers its history, and holds a model it can swap. We use a fake model so it runs without an API key. You’ll plug in a real one in the AI workflows lesson.

from dataclasses import dataclass, field


class FakeLLM:
    """Stands in for a real model so we can test without an API key."""
    def complete(self, messages):
        return f"(fake reply to: {messages[-1]['content']})"


@dataclass
class Conversation:
    llm: FakeLLM
    system_prompt: str
    messages: list[dict] = field(default_factory=list)

    def ask(self, text):
        self.messages.append({"role": "user", "content": text})
        reply = self.llm.complete(self.messages)
        self.messages.append({"role": "assistant", "content": reply})
        return reply


chat = Conversation(FakeLLM(), system_prompt="You are Crumb's support assistant.")
print(chat.ask("Do you have eggless cakes?"))
print(chat.ask("Do they contain nuts?"))
print(len(chat.messages), "messages remembered")
(fake reply to: Do you have eggless cakes?)
(fake reply to: Do they contain nuts?)
4 messages remembered

Conversation has an LLM: composition. Because it only ever calls .complete(messages), you can hand it any object with that method. The fake one for tests, a Claude one in production, a local model on a laptop. You’ll see this pattern again and again: LangChain, LlamaIndex and the agent frameworks later in the course are built from exactly these pieces. And remember the list of message dictionaries from the basics lesson? Here’s where it lives: inside an object that owns it.

Where it breaks

A week later, Arjun’s ticket system has a strange bug: replying to one ticket adds the reply to every ticket. Here’s the code:

class SupportTicket:
    replies = []                         # defined on the CLASS, not on self

    def __init__(self, ticket_id):
        self.ticket_id = ticket_id

    def reply(self, text):
        self.replies.append(text)


a = SupportTicket(701)
b = SupportTicket(702)
a.reply("Refund issued")
print(b.replies)
['Refund issued']

replies = [] written directly in the class body (not inside __init__ with self.) is a class attribute: one list, shared by every ticket. Every self.replies.append adds to that same shared list. The fix is to create the list inside __init__ with self.replies = [], so each ticket gets its own. Dataclasses protect you from this: replies: list = [] in a dataclass raises ValueError: mutable default <class 'list'> for field replies is not allowed: use default_factory, which is why we write field(default_factory=list).

Other things to watch for:

  • The god class. One CrumbApp class with forty methods for orders, tickets, payments and emails. Split by responsibility: one small class per idea.
  • A class for everything. A class with one method and no state is a function in a costume. Write the function.
  • Deep inheritance. Three or more levels down, nobody remembers which parent defines what. Prefer composition.
  • Type hints aren’t checks. MenuItem("Croissant", "eighty") happily creates an item whose price is text. A dataclass guarantees the fields exist, not that their values are right. That gap is what Pydantic fills.

Arjun explains it to Meera

Arjun rewrites the billing script with an Order dataclass. He runs the test suite with the old typo deliberately put back in. It fails on the first line that touches the order.

“So what’s actually different?” Meera asks.

“Before, an order was a loose bag of labels. Anyone could write any label, spelled any way, and reading a misspelled one gave back zero,” Arjun says. “Now there’s a mould, a class, that says exactly what an order has: an ID, a store, lines and a total. Every order is made from that mould. If someone asks for totl, Python stops and says that field doesn’t exist.”

“And it can’t happen anywhere in the sixty-three places?”

“Not for orders made from the class. I’ve converted the billing path. The WhatsApp importer is next.”

“Please make all the bugs do that,” Meera says.

Try it yourself

Build the Diwali hamper with dataclasses and composition.

  1. Write a MenuItem dataclass with name, price and allergens (default: an empty list).
  2. Write a Hamper dataclass with a name and a list of items (default: empty), plus two methods: total() returns the sum of item prices, and contains(allergen) returns True if any item lists that allergen.
  3. Build a “Festive Joy” hamper with kaju katli (₹650, nuts), a plum cake (₹450, gluten and nuts) and a scented candle (₹299, no allergens).

Expected: total 1399, contains("nuts") is True, contains("dairy") is False.

Show the answer
from dataclasses import dataclass, field


@dataclass
class MenuItem:
    name: str
    price: int
    allergens: list[str] = field(default_factory=list)


@dataclass
class Hamper:
    name: str
    items: list[MenuItem] = field(default_factory=list)

    def total(self):
        return sum(item.price for item in self.items)

    def contains(self, allergen):
        return any(allergen in item.allergens for item in self.items)


festive = Hamper("Festive Joy", [
    MenuItem("Kaju katli", 650, ["nuts"]),
    MenuItem("Plum cake", 450, ["gluten", "nuts"]),
    MenuItem("Scented candle", 299),
])

print(festive.total())
print(festive.contains("nuts"))
print(festive.contains("dairy"))
1399
True
False

A hamper has menu items, so composition is the fit. The contains method is the kind of question a support bot will need to answer instantly, and having it on the object means the bot, the website and the packing slip all use the same logic.

Common mistakes

  • Forgetting self. def close(): inside a class fails when called, and status = "closed" (without self.) creates a local variable that vanishes when the method ends.
  • Forgetting the brackets. ticket = SupportTicket stores the blueprint itself, not a ticket. You need SupportTicket(...).
  • Mutable class attributes. A list or dict written in the class body is shared by every object. Create it in __init__, or use field(default_factory=list) in a dataclass.
  • Forgetting super().__init__(...) in a child class. The parent’s setup never runs, and attributes like self.message are missing.
  • Using inheritance for “has a”. If you’re writing class Order(list) or class Hamper(KajuKatli), use composition.

Recap

  • A class is a blueprint and an object is one thing built from it; under the hood, an object is roughly a dictionary of its own data plus a link to its class’s methods.
  • __init__ sets up each new object; self is “this particular object”, passed in automatically; methods are functions that belong to a class.
  • Inheritance means “is a kind of” and lets a child reuse and override a parent; composition means “has a” and is usually the safer choice.
  • @dataclass writes __init__, __repr__ and __eq__ for you; use field(default_factory=list) for list defaults and frozen=True for read-only records.
  • Classes make typos fail loudly, but type hints alone don’t check values.
  • In AI apps, use classes for things with a shape (records), state (conversations, agents) or swappable behaviour (model providers).

That night, the WhatsApp importer crashes at 2 a.m. A customer typed “two” in the quantity field, and the whole batch of orders stopped. “Not every bug is a typo,” Priya says the next morning. “Customers type words where numbers go. APIs time out. Files go missing. Next lesson, we learn to fail well.”

Go deeper

Check your understanding

3 quick questions
1. Why did switching orders from dictionaries to a dataclass stop the free-cake bug caused by order.get("totl", 0)?
2. A Conversation holds an LLM object and calls its complete() method. Which relationship is this?
3. Arjun writes replies = [] directly in the class body of SupportTicket, then calls self.replies.append(...). Replying to one ticket adds the reply to every ticket. Why?