Apps Artificial Intelligence Cloud Computing CSS Cybersecurity Data Science Database Go JavaScript Linux Python Rust Software Engineering Web Development

Design Value Objects with Python Dataclasses

2 min read .
Design Value Objects with Python Dataclasses

Python dictionaries are convenient for passing a few values around, but they become fragile when a value has invariants or behavior that deserves a name. dataclasses can express small domain value objects without repetitive constructors and representation methods.

Start with domain meaning

A price represented as a dictionary is easy to misuse:

price = {"amount": 1299, "currency": "USD"}

Any caller can omit a key or accidentally mix cents and dollars. A value object makes the contract explicit:

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Money:
    amount_minor: int
    currency: str

The field name communicates the unit, and Money can appear directly in type hints.

Validate invariants after initialization

Use __post_init__ for constructor-time validation:

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Percentage:
    value: float

    def __post_init__(self) -> None:
        if not 0.0 <= self.value <= 100.0:
            raise ValueError("percentage must be between 0 and 100")

frozen=True blocks normal attribute assignment after construction. It is not a security boundary and does not recursively freeze referenced mutable objects.

Use keyword-only fields when call sites are ambiguous

Configuration-like values are often clearer with keyword-only construction:

@dataclass(kw_only=True)
class RetryPolicy:
    max_attempts: int = 3
    base_delay_seconds: float = 0.5

Callers then write:

policy = RetryPolicy(
    max_attempts=5,
    base_delay_seconds=1.0,
)

This is especially useful when adjacent arguments have the same type.

Understand generated equality

Dataclasses compare declared fields by default. That is appropriate for genuine value objects:

Money(100, "USD") == Money(100, "USD")

It may be wrong for entities. Two user records with identical names are not necessarily the same user. Use generated equality only when field equality matches domain meaning.

Decide whether hashing is valid

Frozen dataclasses can often be hashable when their compared fields are hashable. That is useful for immutable identifiers, coordinates, or configuration keys.

Do not force hashing onto mutable logical state. Dictionary and set keys must keep stable hash and equality behavior while stored.

What slots changes

slots=True removes the ordinary per-instance __dict__ and restricts undeclared attributes. That can reduce memory overhead and catch misspelled assignments.

The trade-off is reduced flexibility for code that expects dynamic attributes or __dict__. Inheritance can also require more care. Apply slots because the model benefits from a fixed shape, not as automatic decoration.

Keep external parsing separate

Dataclasses do not automatically validate arbitrary JSON or convert nested dictionaries into nested dataclass objects.

At an HTTP or message boundary, parse untrusted data explicitly and then construct the internal value:

def parse_money(payload: dict[str, object]) -> Money:
    amount = payload.get("amount_minor")
    currency = payload.get("currency")

    if not isinstance(amount, int):
        raise ValueError("amount_minor must be an integer")
    if not isinstance(currency, str):
        raise ValueError("currency must be a string")

    return Money(amount, currency)

This keeps permissive transport formats separate from stricter domain models.

Use default_factory for mutable defaults

Collection defaults should be created per instance:

from dataclasses import dataclass, field

@dataclass
class Batch:
    items: list[str] = field(default_factory=list)

Each Batch receives its own list.

Common pitfalls

Treating frozen as deep immutability

A frozen object can still contain mutable lists, dictionaries, or other mutable objects.

Excluding meaningful fields from comparisons

field(compare=False) is useful for caches and derived implementation details, but dangerous when the field changes domain identity.

Turning every class into a dataclass

Dataclasses fit data-centered types. A class dominated by I/O, lifecycle, or orchestration behavior may be clearer as a regular class.

Conclusion

Dataclasses are most useful when they express a real model: named fields, meaningful equality, explicit invariants, and controlled mutation. Combine frozen, slots, keyword-only fields, and validation only when those choices match the value’s semantics. The goal is a clearer contract, not merely fewer lines of boilerplate.

Related Posts

chevron-up