Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Low-Level Design (Object-Oriented Design)  Module 9 – One design, six languages

LLD in Python 3.14: dataclasses and protocols

Write a low-level design in Python 3.14: frozen dataclasses for values, enums for fixed sets, protocols or ABCs for policies, and what each checks.

  • Intermediate
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot
Free core, Pro depth: 10 of 30 minutes free
Free: Which Python construct for which part of a design · A parking model in about a hundred lines · What Python checks, and when. With a pass: 3 in-depth parts and 3 interview answers, about 20 minutes.

What you will learn

  • Model entities and values with dataclasses, enums and properties
  • Choose between abstract base classes and protocols for abstractions
  • Explain what frozen, eq=False and __post_init__ change in a class, and what they leave unchecked
  • Use Python 3.14's deferred annotations and match statements in a design

Before you start

What you’ll learn · Before you start

What you will learn

  • Model entities and values with dataclasses, enums and properties
  • Choose between abstract base classes and protocols for abstractions
  • Explain what frozen, eq=False and __post_init__ change in a class, and what they leave unchecked
  • Use Python 3.14's deferred annotations and match statements in a design

Before you start

On this page

Python 3.14 gives a low-level design four building blocks, and each one is checked at a different moment. A dataclass writes the constructor, equality and repr of a class from its annotated fields, and with frozen=True it becomes a value object whose fields cannot be reassigned. An enum is a closed set, and the members of a StrEnum are also strings, so they print, compare and serialise as their text. A protocol describes an abstraction by its methods: any class with matching methods satisfies it without inheriting from it. An abstract base class works the other way round: implementations inherit from it, and Python refuses to create an instance of one that leaves a method out. Python itself checks little of this while a program runs, so a good design knows which checks come from the language, which from a type checker and which from tests.

This lesson builds a parking model from these parts and runs it. Every example ran on Python 3.14.8, and the ones with a Run button also run in your browser on Pyodide, which carries CPython 3.14.2. How to find the classes of a design is the subject of from a script to an object model; this lesson writes them in Python.

Which Python construct for which part of a design

A design’s types fall into four groups, and Python has a construct for each:

  • A value is nothing but its data (an amount of money, a spot number, a time range), and it becomes a @dataclass(frozen=True, slots=True). Equality and hashing come from the fields, so equal values are interchangeable and work as dictionary keys.
  • An entity, an identity whose data changes, such as a ticket, an account or a parking lot, is a @dataclass(eq=False) or a plain class. Two tickets with the same data are still two tickets.
  • A closed set of names without data, such as vehicle sizes or order statuses, is an Enum, or a StrEnum when the members travel as text.
  • An abstraction with several implementations, such as a fee policy or a payment gateway, is a Protocol or an abstract base class.

The last choice is the one interviewers probe, because the two express the same idea with different checks:

Protocol or abstract base class for an abstraction
Criterion typing.Protocolabc.ABC
Implementations must inherit from it No: matching methods are enoughYes, or be registered with register()
Who checks an implementation A type checker, before the program runsPython, when an instance is created
isinstance() works Only with @runtime_checkable, and it checks names onlyYes
Shared code for implementations None by defaultOrdinary methods in the base class
When to choose Policies you plug in, classes you do not own, small interfacesA family of classes that share code and must be complete

The parking model below uses all four kinds. Money and SpotId are values, Ticket is an entity, Size is a StrEnum, and two fee policies satisfy a FeePolicy protocol without inheriting from it.

Class diagram: a Lot holds Tickets and a FeePolicy protocol that two frozen fee classes match; Tickets use SpotId and Size.Lot+policyFeePolicy+opendict[str, Ticket]+park(number, spot, size, minute)Ticket+leave(number, minute)Money«protocol»FeePolicy+fee(size, minutes)Moneyfrozen dataclasses that inherit from nothing«dataclass, eq=False»Ticket+numberstr+spotSpotId+sizeSize+entered_minuteintfrozen, slotted dataclasses and an enumHourlyFee+per_hourMoneyFlatFee+amountMoney«frozen»SpotId+levelint+numberint«frozen»Money+paiseint«StrEnum»Size+BIKE · CAR · VANpolicy1matchesopen0..*spot, size

The parking design: an entity, value objects, an enum and a protocol nobody inherits from

Text description of the diagram

A UML class diagram in one column, from top to bottom.

  • Lot, an ordinary class: attributes policy: FeePolicy and open: dict[str, Ticket]; operations park(number, spot, size, minute): Ticket and leave(number, minute): Money. An association named policy goes from Lot to FeePolicy, with multiplicity 1.
  • FeePolicy, marked «protocol», with one operation, fee(size, minutes): Money.
  • A group labelled "frozen dataclasses that inherit from nothing" holds HourlyFee, with per_hour: Money, and FlatFee, with amount: Money. A dashed line labelled "matches" goes from the group to FeePolicy: both classes have a fee method of the right shape, so they satisfy the protocol structurally, without declaring it.
  • Ticket, marked «dataclass, eq=False»: number: str, spot: SpotId, size: Size and entered_minute: int. An association named open goes from Lot to Ticket, with multiplicity 0..*: a lot holds any number of open tickets.
  • A group labelled "frozen, slotted dataclasses and an enum" holds SpotId (level: int, number: int), Money (paise: int) and Size, marked «StrEnum», with the members BIKE, CAR and VAN. A line labelled "spot, size" goes from Ticket to the group.

A parking model in about a hundred lines

A parking design with values, an entity, an enum and a protocol Python · parking/parking.py
"""A small parking design in Python 3.14.

Values are frozen dataclasses, vehicle sizes are a StrEnum, a ticket is an entity with an identity, and the two fee
policies satisfy the FeePolicy protocol without inheriting from it.
"""

from dataclasses import FrozenInstanceError, dataclass
from enum import StrEnum
from typing import Protocol


class Size(StrEnum):
    BIKE = "bike"
    CAR = "car"
    VAN = "van"


@dataclass(frozen=True, slots=True)
class Money:
    paise: int

    def __post_init__(self) -> None:
        if self.paise < 0:
            raise ValueError(f"an amount cannot be negative: {self.paise} paise")

    def __add__(self, other: Money) -> Money:
        return Money(self.paise + other.paise)

    def __str__(self) -> str:
        return f"{self.paise // 100}.{self.paise % 100:02d}"


@dataclass(frozen=True, slots=True)
class SpotId:
    level: int
    number: int


class FeePolicy(Protocol):
    def fee(self, size: Size, minutes: int) -> Money: ...


@dataclass(frozen=True, slots=True)
class HourlyFee:
    """Every started hour is charged; vans pay double."""

    per_hour: Money

    def fee(self, size: Size, minutes: int) -> Money:
        hours = -(-minutes // 60)
        factor = 2 if size is Size.VAN else 1
        return Money(self.per_hour.paise * hours * factor)


@dataclass(frozen=True, slots=True)
class FlatFee:
    """One price per visit, whatever the size."""

    amount: Money

    def fee(self, size: Size, minutes: int) -> Money:
        return self.amount


@dataclass(eq=False)
class Ticket:
    """An entity: two tickets with the same data are still two tickets."""

    number: str
    spot: SpotId
    size: Size
    entered_minute: int


class Lot:
    def __init__(self, policy: FeePolicy) -> None:
        self.policy = policy
        self.open: dict[str, Ticket] = {}

    def park(self, number: str, spot: SpotId, size: Size, minute: int) -> Ticket:
        ticket = Ticket(number, spot, size, minute)
        self.open[number] = ticket
        return ticket

    def leave(self, number: str, minute: int) -> Money:
        ticket = self.open.pop(number)
        return self.policy.fee(ticket.size, minute - ticket.entered_minute)


a, b = SpotId(1, 7), SpotId(1, 7)
print("equal values:", a == b, "| one object:", a is b, "| spots in a set:", len({a, b, SpotId(2, 1)}))
try:
    a.number = 8
except FrozenInstanceError as error:
    print("frozen:", error)
try:
    Money(-500)
except ValueError as error:
    print("refused:", error)

print("size from text:", Size("van") is Size.VAN, "| printed as:", f"{Size.VAN}")

t1 = Ticket("T1", SpotId(1, 7), Size.CAR, 0)
t2 = Ticket("T1", SpotId(1, 7), Size.CAR, 0)
print("same data, two tickets:", t1 == t2)

for policy in (HourlyFee(Money(4000)), FlatFee(Money(10000))):
    lot = Lot(policy)
    lot.park("T1", SpotId(1, 7), Size.CAR, minute=0)
    lot.park("T2", SpotId(1, 8), Size.VAN, minute=30)
    total = lot.leave("T1", minute=130) + lot.leave("T2", minute=95)
    print(f"{type(policy).__name__}: car 130 min + van 65 min = {total}")

Output

equal values: True | one object: False | spots in a set: 2
frozen: cannot assign to field 'number'
refused: an amount cannot be negative: -500 paise
size from text: True | printed as: van
same data, two tickets: False
HourlyFee: car 130 min + van 65 min = 280.00
FlatFee: car 130 min + van 65 min = 200.00

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 parking.py

Each line of the output is one property of the design. Two SpotId objects with the same fields are equal although they are separate objects, so a set holds two spots, not three. Assigning to a field of a frozen value raises FrozenInstanceError, and Money refuses a negative amount in __post_init__, the method that a dataclass calls right after its generated constructor. Size("van") returns the existing member, and the member prints as van. Two tickets with the same data are not equal, because eq=False keeps the identity equality that every object inherits.

The lot never asks what kind of policy it holds: it calls fee(). With hourly charging, the car’s 130 minutes start 3 hours at 40.00 each, and the van’s 65 minutes start 2 hours at the doubled rate, 280.00 in all; the flat policy charges 100.00 per visit. Swapping the policy changed one argument and no class.

One line of the file is new in Python 3.14. Money.__add__ names Money in its annotations while the class is still being defined, and without quotes. Earlier versions evaluated those annotations at once and stopped there with a NameError; Python 3.14 evaluates annotations only when something reads them.

What Python checks, and when

Each construct protects a design at one moment, and each stops at a known edge:

  • __post_init__ runs every time an instance is created, so a check written there sees every value.
  • frozen=True refuses assignment to a field, but not changes inside a list that a field holds.
  • A plain @dataclass sets __hash__ to None, so an entity written that way cannot go into a set.
  • An abstract base class refuses an incomplete subclass when you create an instance, not when you define it.
  • isinstance() with a runtime-checkable protocol only checks that the names exist.
  • @override marks a method as an override; Python never checks the mark, but type checkers do.
  • match takes dataclasses apart field by field, and assert_never reports a state that has no case.

Knowing these edges is what separates a Python design that looks right from one that stays right when the next requirement arrives.

Check yourself

3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 3 Vehicle sizes are a fixed set, and each size is stored in a database column and sent in JSON as text. Which construct fits them best?

    Choose one answer.

    Show the answer to question 1

    Answer: A StrEnum, whose members are strings

    A StrEnum gives a closed set that a typo cannot extend, and because each member is also a str, it prints, compares and serialises as its text. Plain constants are open: any other string is just as acceptable to the code.

  2. Question 2 of 3 In the parking model, which types are value objects that should be frozen dataclasses?

    Choose every answer that is right.

    Show the answer to question 2

    Answer:

    • Money
    • SpotId

    A spot number and an amount are described completely by their fields, so two equal ones are interchangeable: frozen dataclasses with equality and hashing from those fields. A ticket and a lot have identities that outlive changes to their data, so they are entities.

  3. Question 3 of 3 HourlyFee does not inherit from FeePolicy. Why can the lot still use it?

    Choose one answer.

    Show the answer to question 3

    Answer: FeePolicy is a protocol, and HourlyFee has a fee method of the shape the protocol describes

    A protocol is satisfied structurally: any object with a matching method fits. A type checker verifies the match before the program runs; at run time Python simply calls fee() on whatever object the lot was given.

Value objects that stay valid: checks, copies and hashing Pro: opens with a Pro, Premium or Ultimate pass

A value object is only as trustworthy as its weakest field. This part checks values once, at the point where they are made, reproduces the ways a frozen dataclass still changes, and gives entities the identity they need.

In this part:

  • Checks in __post_init__, normalised once, and replace()
  • Frozen is shallow: a cart that changes a paid order, reproduced
  • Entities: why a plain @dataclass cannot go into a set

3 runnable examples (Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Read a complete sample first: Low-level design: parking lot (the model)

ABCs, protocols and @override: what each one checks Pro: opens with a Pro, Premium or Ultimate pass

Abstractions are where a Python design can be wrong for months without anyone noticing, because each kind of check happens at a different moment, or not at all. This part runs every check against broken implementations and counts what each one catches.

In this part:

  • An abstract base class refuses an incomplete class only when you create one
  • A runtime-checkable protocol checks names: 3 of 4 accepted, 1 works
  • A misspelled override, and a test that finds it at run time
  • Choosing, with the checks side by side

3 runnable examples (Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Python 3.14 in a design: annotations, enums and match Pro: opens with a Pro, Premium or Ultimate pass

Python 3.14 changes when annotations are evaluated, which removes an old obstacle to designs whose classes refer to each other, and pattern matching turns a closed set of states into code that reads like the state diagram.

In this part:

  • Annotations evaluated on demand: three formats, and the same file on 3.12
  • Enum, StrEnum and match: states taken apart by field
  • Practise: a canteen order rewritten with dataclasses, an enum and a protocol
  • A canteen order rewritten with dataclasses, an enum and a protocol
    • Results of the sample tests

3 runnable examples (Python) · 1 exercise with sample tests · about 5 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

JSON to Code Generator Turn a JSON sample into first-draft dataclasses, then decide which are values and which are entities.

Interview questions

  1. Fresher to mid · Warm-up

    What does frozen=True give a Python dataclass, and what does it not give it?

    It makes assignment to the instance's fields raise FrozenInstanceError, and together with the default eq=True it makes the decorator generate a __hash__ from the fields, so equal instances hash alike and can be dictionary keys or set elements. That is what a value object needs. It does not make the object deeply immutable: a field that holds a list still refers to that list, and anyone who holds the list can change it. So a frozen value object should hold only immutable things, such as numbers, strings, tuples and other frozen values, and check its fields in __post_init__, which runs every time an instance is created.

  2. Mid to senior

    Three teams will write payment-gateway adapters for your Python checkout service. Do you define the gateway as an abstract base class or as a Protocol, and how do you make sure every adapter really fits before it reaches production?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Fresher to mid

    Order is a frozen dataclass, yet support reports that a paid order's total changed after the customer edited their cart. How can that happen, and how would you redesign Order so that it cannot?

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    Your team moves a Python 3.12 code base to 3.14, and someone opens a change that removes the quotes from every forward reference in annotations. What can break, and how do you roll the change out safely?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Write values as frozen, slotted dataclasses and entities as eq=False dataclasses or plain classes.
  • Use an Enum for a closed set, and a StrEnum when its members travel as text.
  • Describe a plug-in abstraction with a Protocol, and a family that shares code with an abstract base class.
  • Check values in __post_init__, where every instance passes through.
  • Know which checks Python makes while the program runs and which only a type checker or a test makes.
  • Python 3.14 evaluates annotations only when they are read, so classes can name each other without quotes.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.