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.
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 aStrEnumwhen the members travel as text. - An abstraction with several implementations, such as a fee policy or a payment gateway, is a
Protocolor an abstract base class.
The last choice is the one interviewers probe, because the two express the same idea with different checks:
| Criterion | typing.Protocol | abc.ABC |
|---|---|---|
| Implementations must inherit from it | No: matching methods are enough | Yes, or be registered with register() |
| Who checks an implementation | A type checker, before the program runs | Python, when an instance is created |
| isinstance() works | Only with @runtime_checkable, and it checks names only | Yes |
| Shared code for implementations | None by default | Ordinary methods in the base class |
| When to choose | Policies you plug in, classes you do not own, small interfaces | A 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.
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 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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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=Truerefuses assignment to a field, but not changes inside a list that a field holds.- A plain
@dataclasssets__hash__toNone, 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.@overridemarks a method as an override; Python never checks the mark, but type checkers do.matchtakes dataclasses apart field by field, andassert_neverreports 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.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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).
Interview questions
-
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.
-
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?
-
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?
-
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?
Key takeaways
- Write values as frozen, slotted dataclasses and entities as
eq=Falsedataclasses or plain classes. - Use an
Enumfor a closed set, and aStrEnumwhen 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
- dataclasses: Data Classes (Python 3.14) (Python Software Foundation)
- typing: Support for type hints (Protocol, runtime_checkable, override, assert_never) (Python Software Foundation)
- abc: Abstract Base Classes (Python Software Foundation)
- enum: Support for enumerations (Python Software Foundation)
- annotationlib: Functionality for introspecting annotations (Python Software Foundation)
- What's new in Python 3.14 (Python Software Foundation)
- The match statement (Python 3.14 language reference) (Python Software Foundation)
- PEP 544: Protocols: Structural subtyping (static duck typing) (Python Software Foundation)
- PEP 649: Deferred Evaluation Of Annotations Using Descriptors (Python Software Foundation)
- PEP 749: Implementing PEP 649 (Python Software Foundation)
- PEP 698: Override Decorator for Static Typing (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress