Zum Hauptinhalt springen

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=True is not deep immutability;
  • generated equality follows fields;
  • asdict can 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

TermMeaning
dataclassclass with generated data-oriented methods
value objectimmutable concept defined by its value
entityobject defined by identity
post-initializationhook after generated initialization
default factorycallable producing a new default value
derived fieldfield calculated from other fields
frozennormal field assignment is blocked
deep immutabilitynested objects are also immutable
serializationconversion to a transport or storage format
semantic equalityequality 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:

  1. dataclass-generated behavior;
  2. entities versus value objects;
  3. frozen dataclasses;
  4. post-initialization;
  5. default factories;
  6. comparison and ordering;
  7. hashing safety;
  8. slots;
  9. explicit serialization;
  10. choosing dataclasses versus alternatives.