The Trap of Primitive Obsession Developers often default to Python primitives like floats or strings to represent complex domain concepts. While a float can hold a price, it cannot inherently ensure that the price is non-negative. This leads to "primitive obsession," where validation logic scatters throughout every function that touches the data. When you pass a simple float representing a discount, you lose context. Is 0.2 a flat 20% discount or 0.2 units of currency? By relying on primitives, you force your functions to defensively validate inputs every time, which doesn't scale and invites bugs. Defining the Value Object Originating from Domain-Driven Design, a Value Object is an immutable container that enforces its own invariants. Unlike entities, we compare value objects by their internal values rather than a unique identity. The core philosophy is simple: validate once upon creation. If an object exists, it is guaranteed to be valid. This shifts the burden of proof from the consuming function to the object itself. Implementation via Dataclasses The most straightforward way to implement this pattern is using dataclasses. By setting `frozen=True`, you ensure immutability. ```python from dataclasses import dataclass @dataclass(frozen=True) class Price: value: float def __post_init__(self): if self.value < 0: raise ValueError("Price must be non-negative") ``` In this model, the `__post_init__` method acts as a gatekeeper. Any attempt to instantiate a `Price` with a negative number immediately raises a `ValueError`. Inheriting from Primitives If accessing `.value` feels cumbersome, you can inherit directly from float. Since floats are already immutable, this preserves the value object's integrity while allowing the object to behave like a standard number. ```python class Percentage(float): def __new__(cls, value): if not 0 <= value <= 1: raise ValueError("Percentage must be between 0 and 1") return super().__new__(cls, value) ``` Using `__new__` allows you to validate the data before the object is even created. This allows you to perform math operations directly on the object without manually extracting the internal value. Practical Syntax Notes - **Immutability:** Always use `frozen=True` with dataclasses to prevent state changes after creation. - **Custom Creators:** Use `@classmethod` for alternative constructors, such as `from_percent(cls, value)` which might divide an integer by 100. - **Type Clarity:** Value objects act as documentation. A function signature requiring a `EmailAddress` type is far more descriptive than one requiring a `str`.
Pydantic
Libraries
- Mar 13, 2026
- Feb 13, 2026
- Mar 15, 2024
- Sep 23, 2022