Chapter 14 — Dataclasses, Value Objects, and Domain Modeling
1. Opening problem
A simple data-focused class often contains repetitive code:
class Point:
def __init__(
self,
x: float,
y: float,
) -> None:
self.x = x
self.y = y
def __repr__(self) -> str:
...
def __eq__(
self,
other: object,
) -> bool:
...
A dataclass can generate this behavior:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
But dataclasses are not merely boilerplate removal.
Their options affect:
- equality;
- ordering;
- hashing;
- mutability;
- memory;
- pattern matching;
- inheritance;
- domain semantics.
This chapter explains how to use dataclasses intentionally.
2. Basic dataclass
from dataclasses import dataclass
@dataclass
class UserRecord:
user_id: int
email: str
Generated behavior commonly includes:
__init__;__repr__;__eq__.
Usage:
first = UserRecord(
1,
"steve@example.com",
)
second = UserRecord(
1,
"steve@example.com",
)
print(first == second)
The result is True because equality compares fields.
That may be correct for a record, but not always for a domain entity.
3. Entity versus value object
A value object is defined by its value.
Examples:
- money amount and currency;
- email address;
- date range;
- geographic coordinate;
- version number.
Two equal value objects are interchangeable.
An entity is defined by identity.
Examples:
- user with ID 42;
- order with ID A-100;
- account with a persistent identifier.
Two entity instances may represent the same entity even if fields differ.
Dataclass default equality compares all declared fields. That may be wrong for entities.
4. Entity equality
from dataclasses import dataclass, field
@dataclass(eq=False)
class User:
user_id: int
email: str
display_name: str
def __eq__(
self,
other: object,
) -> bool:
if not isinstance(
other,
User,
):
return NotImplemented
return (
self.user_id
== other.user_id
)
def __hash__(self) -> int:
return hash(self.user_id)
Caution:
If user_id can change, hashing is unsafe.
In many mutable entity designs, entities should not be hashable.
__hash__ = None
or simply avoid implementing hashing.
5. Frozen dataclasses
@dataclass(frozen=True)
class EmailAddress:
value: str
Assignment fails:
email.value = "new@example.com"
A frozen dataclass approximates immutability.
It is not deep immutability:
@dataclass(frozen=True)
class Example:
values: list[int]
The field binding cannot be replaced, but the list can still mutate.
Prefer immutable nested types:
@dataclass(frozen=True)
class Example:
values: tuple[int, ...]
6. Validation with __post_init__
@dataclass(frozen=True)
class EmailAddress:
value: str
def __post_init__(
self,
) -> None:
normalized = (
self.value
.strip()
.lower()
)
if "@" not in normalized:
raise ValueError(
"Invalid email"
)
object.__setattr__(
self,
"value",
normalized,
)
Why object.__setattr__?
Because frozen dataclasses block normal assignment, including inside __post_init__.
Use this intentionally for normalization during construction.
7. Default values
@dataclass
class Settings:
timeout_seconds: float = 5.0
retries: int = 3
Fields with defaults must generally come after fields without defaults.
Incorrect:
@dataclass
class User:
active: bool = True
email: str
Dataclass constructor ordering would be invalid.
Reorder fields or use keyword-only fields.
8. Mutable defaults and default_factory
Incorrect:
@dataclass
class Team:
members: list[str] = []
Dataclasses reject common mutable defaults.
Correct:
from dataclasses import field
@dataclass
class Team:
members: list[str] = field(
default_factory=list
)
A new list is created for every instance.
Factory with initial data:
def default_roles() -> list[str]:
return ["reader"]
@dataclass
class User:
roles: list[str] = field(
default_factory=default_roles
)
9. Excluding fields from representation
@dataclass
class ApiCredentials:
client_id: str
secret: str = field(
repr=False
)
Now repr() omits the secret.
This reduces accidental leakage but does not make the secret secure.
Also consider:
- logs;
- exceptions;
- serialization;
- debugging tools;
- copies;
- metrics labels.
10. Excluding fields from comparison
@dataclass
class CacheEntry:
key: str
value: bytes
loaded_at: float = field(
compare=False
)
Equality ignores loaded_at.
Use this only when the field truly does not contribute to semantic equality.
A field excluded from equality but included in hashing can violate hash rules. Dataclass options should be reviewed together.
11. Ordering
@dataclass(
order=True,
frozen=True,
)
class Version:
major: int
minor: int
patch: int
Comparison follows field order:
Version(1, 10, 0) > Version(1, 2, 9)
This is correct for numeric version components.
But automatic ordering may be wrong for:
- semantic versions with prerelease tags;
- priority values with custom rules;
- entities;
- strings with locale-specific order.
Use order=True only when tuple-like field comparison matches domain meaning.
12. Hashing rules
Dataclass hashing depends on options such as:
eq;frozen;unsafe_hash.
Typical safe value object:
@dataclass(
frozen=True,
)
class UserId:
value: int
A hash is generated when appropriate.
Avoid:
@dataclass(
unsafe_hash=True,
)
class MutableUser:
email: str
If email changes after insertion into a set, lookup becomes unreliable.
The word unsafe is a warning, not decoration.
13. Slots
@dataclass(
slots=True,
)
class Point:
x: float
y: float
Potential benefits:
- lower per-instance memory;
- fixed attribute names;
- reduced accidental attributes;
- possible access improvements.
Trade-offs:
- inheritance complexity;
- weak-reference considerations;
- no normal instance dictionary;
- dynamic frameworks may expect
__dict__.
Use slots when:
- many instances exist;
- memory matters;
- fixed shape is desirable;
- compatibility has been tested.
14. Keyword-only dataclasses
@dataclass(
kw_only=True,
)
class RequestOptions:
timeout: float = 5.0
retries: int = 3
secure: bool = True
Usage:
options = RequestOptions(
timeout=10.0,
secure=False,
)
This improves readability for configuration-like objects.
You can also mark individual fields keyword-only.
15. InitVar
An InitVar is accepted by __init__ and passed to __post_init__ but is not stored as a normal field.
from dataclasses import InitVar
@dataclass(frozen=True)
class PasswordHash:
encoded: str = field(
init=False
)
raw_password: InitVar[str]
def __post_init__(
self,
raw_password: str,
) -> None:
encoded = hash_password(
raw_password
)
object.__setattr__(
self,
"encoded",
encoded,
)
Be careful: handling secrets inside objects requires broader security design. This example demonstrates mechanics, not a complete password-storage architecture.
16. Derived fields
@dataclass(frozen=True)
class Rectangle:
width: float
height: float
area: float = field(
init=False
)
def __post_init__(
self,
) -> None:
if (
self.width <= 0
or self.height <= 0
):
raise ValueError(
"Dimensions must be positive"
)
object.__setattr__(
self,
"area",
self.width * self.height,
)
Question:
Should area be stored or computed as a property?
Stored derived field:
- faster repeated access;
- duplicated state;
- must remain consistent.
Property:
@property
def area(self) -> float:
return self.width * self.height
For cheap calculations, prefer a property.
17. replace
from dataclasses import replace
updated = replace(
settings,
timeout_seconds=10.0,
)
This creates a new instance.
It is useful for immutable configuration and value objects.
However, replace still runs construction semantics and may interact with init=False fields and custom invariants.
18. Serialization warning
from dataclasses import asdict
payload = asdict(user)
asdict recursively converts dataclasses and deep-copies nested values.
Potential problems:
- expensive;
- exposes internal fields;
- leaks secrets;
- couples API format to internal model;
- recursively transforms objects unexpectedly.
Prefer explicit boundary serializers:
def user_to_response(
user: User,
) -> dict[str, object]:
return {
"id": user.user_id,
"email": user.email,
}
Internal model and external contract should evolve independently.
19. Dataclass inheritance
@dataclass
class BaseEvent:
event_id: str
@dataclass
class UserCreated(
BaseEvent
):
user_id: int
Inheritance can work, but default-field ordering across base classes can become awkward.
Also consider whether event types need inheritance or a discriminated union.
Composition can be clearer:
@dataclass(frozen=True)
class EventMetadata:
event_id: str
occurred_at: str
@dataclass(frozen=True)
class UserCreated:
metadata: EventMetadata
user_id: int
20. Pattern matching
Dataclasses support structural pattern matching.
@dataclass(frozen=True)
class Success:
value: object
@dataclass(frozen=True)
class Failure:
message: str
match result:
case Success(value):
...
case Failure(message):
...
This can model algebraic-data-type-like results.
Use caution with positional matching. Keyword matching is often more stable:
case Success(value=value):
...
Field order changes can otherwise affect patterns.
21. Dataclass versus alternatives
Normal class
Best when:
- behavior dominates;
- lifecycle is complex;
- invariants require controlled methods;
- equality is custom;
- construction is specialized.
Dataclass
Best when:
- fields are central;
- generated representation and equality are useful;
- value-object or DTO semantics fit.
NamedTuple
Best when:
- tuple compatibility matters;
- immutable positional records are useful;
- very lightweight behavior is needed.
TypedDict
Best when:
- dictionary runtime shape must remain;
- JSON-like structures are involved;
- static typing is needed without object behavior.
Plain dictionary
Best when:
- structure is highly dynamic;
- schema is truly not fixed;
- boundary parsing occurs elsewhere.
22. Value-object case study: Money
@dataclass(frozen=True)
class Money:
amount_cents: int
currency: str
def __post_init__(
self,
) -> None:
normalized_currency = (
self.currency
.strip()
.upper()
)
if len(
normalized_currency
) != 3:
raise ValueError(
"Currency must use "
"a three-letter code"
)
object.__setattr__(
self,
"currency",
normalized_currency,
)
def __add__(
self,
other: object,
) -> "Money":
if not isinstance(
other,
Money,
):
return NotImplemented
if (
self.currency
!= other.currency
):
raise ValueError(
"Currency mismatch"
)
return Money(
self.amount_cents
+ other.amount_cents,
self.currency,
)
The class is:
- immutable;
- hashable;
- normalized;
- value-equal;
- explicit about invalid addition.
23. TypeScript comparison
TypeScript often uses:
type UserRecord = {
id: number;
email: string;
};
or:
class EmailAddress {
constructor(
readonly value: string
) {}
}
Python dataclasses combine runtime class behavior with generated methods.
Differences:
- annotations are not runtime validation;
frozen=Trueis not deep immutability;- generated equality follows fields;
asdictcan expose internals;- hashing depends on configuration;
__post_init__is a runtime hook.
24. Common mistakes
Using dataclasses for every class
Behavior-heavy services often need normal classes.
Assuming frozen means deeply immutable
Nested mutable values remain mutable.
Automatic entity equality
Entities may compare by identity, not all fields.
Unsafe hashing
Never hash mutable identity fields.
asdict as an API serializer
Use explicit boundary mapping.
order=True without domain analysis
Field order may not match business order.
Mutable default without factory
Use default_factory.
Secret leakage through representation
Use repr=False and broader logging discipline.
25. English vocabulary
| Term | Meaning |
|---|---|
| dataclass | class with generated data-oriented methods |
| value object | immutable concept defined by its value |
| entity | object defined by identity |
| post-initialization | hook after generated initialization |
| default factory | callable producing a new default value |
| derived field | field calculated from other fields |
| frozen | normal field assignment is blocked |
| deep immutability | nested objects are also immutable |
| serialization | conversion to a transport or storage format |
| semantic equality | equality based on domain meaning |
Useful sentences:
- “Default field equality does not match entity identity.”
- “The frozen dataclass is only shallowly immutable.”
- “The mutable field requires a default factory.”
- “Automatic ordering matches tuple-style field comparison.”
- “The API serializer should not depend on
asdict.” - “This value object normalizes input during post-initialization.”
26. Speaking task
Explain for nine minutes:
When should a Python class become a dataclass?
Compare entities, value objects, DTOs, and services.
27. Writing task
Write a 400-word design review of a mutable, hashable User dataclass whose equality compares email, display name, last-login timestamp, and active status.
28. Exercises
Exercise 1
Create a frozen, normalized ProductCode value object.
Exercise 2
Create a User entity dataclass whose equality depends only on immutable user ID.
Exercise 3
Create a RetryConfig with validation, keyword-only construction, and slots.
Exercise 4
Create an immutable DateRange with start, end, duration, and overlap behavior.
Exercise 5
Explain when a TypedDict is better than a dataclass.
29. Complete solutions
Solution 1
from dataclasses import dataclass
@dataclass(frozen=True)
class ProductCode:
value: str
def __post_init__(
self,
) -> None:
normalized = (
self.value
.strip()
.upper()
)
if not normalized:
raise ValueError(
"Product code must not be blank"
)
if not normalized.replace(
"-",
"",
).isalnum():
raise ValueError(
"Invalid product code"
)
object.__setattr__(
self,
"value",
normalized,
)
Solution 2
from dataclasses import dataclass
@dataclass(eq=False)
class User:
user_id: int
email: str
display_name: str
def __eq__(
self,
other: object,
) -> bool:
if not isinstance(
other,
User,
):
return NotImplemented
return (
self.user_id
== other.user_id
)
__hash__ = None
The entity is mutable and therefore deliberately unhashable.
Solution 3
from dataclasses import dataclass
@dataclass(
frozen=True,
slots=True,
kw_only=True,
)
class RetryConfig:
max_attempts: int = 3
initial_delay_seconds: float = 0.5
multiplier: float = 2.0
def __post_init__(
self,
) -> None:
if self.max_attempts < 1:
raise ValueError(
"max_attempts must be positive"
)
if (
self.initial_delay_seconds
< 0
):
raise ValueError(
"delay must not be negative"
)
if self.multiplier < 1:
raise ValueError(
"multiplier must be at least 1"
)
Solution 4
from dataclasses import dataclass
from datetime import date, timedelta
@dataclass(
frozen=True,
order=True,
)
class DateRange:
start: date
end: date
def __post_init__(
self,
) -> None:
if self.end < self.start:
raise ValueError(
"end must not precede start"
)
@property
def duration(
self,
) -> timedelta:
return (
self.end
- self.start
)
def overlaps(
self,
other: "DateRange",
) -> bool:
return (
self.start <= other.end
and other.start <= self.end
)
Solution 5
Use a TypedDict when the runtime object should remain a dictionary, especially at JSON or framework boundaries.
Use a dataclass when behavior, construction, methods, representation, equality, or domain semantics justify a real object.
30. Chapter checkpoint
You should now be able to explain:
- dataclass-generated behavior;
- entities versus value objects;
- frozen dataclasses;
- post-initialization;
- default factories;
- comparison and ordering;
- hashing safety;
- slots;
- explicit serialization;
- choosing dataclasses versus alternatives.