Mastering the Value Object Pattern in Python

The Trap of Primitive Obsession

Developers often default to

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

, 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

. By setting frozen=True, you ensure immutability.

from dataclasses import dataclass

@dataclass(frozen=True)
class Price:
    value: float
Mastering the Value Object Pattern in Python
Stop Passing Primitives Everywhere (Use Value Objects)
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](entity://types/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.
Mastering the Value Object Pattern in Python

Fancy watching it?

Watch the full video and context

2 min read