Value Objects in Python — Deep Dive

Production-grade value objects in Python

Value objects appear simple on the surface, but production codebases need them to work with ORMs, serialization, comparison operators, and performance constraints. This guide covers advanced implementation patterns.

Foundation: the base value object

from dataclasses import dataclass
from typing import TypeVar, Generic

T = TypeVar("T")

@dataclass(frozen=True)
class ValueObject:
    """Base class that provides consistent behavior for all value objects."""

    def __post_init__(self):
        self._validate()

    def _validate(self) -> None:
        """Override in subclasses to add validation rules."""
        pass

Rich value objects with behavior

Money with arithmetic

from decimal import Decimal, ROUND_HALF_UP

@dataclass(frozen=True)
class Money(ValueObject):
    amount: Decimal
    currency: str

    def _validate(self) -> None:
        if not isinstance(self.amount, Decimal):
            raise TypeError(f"Amount must be Decimal, got {type(self.amount)}")
        if self.amount < 0:
            raise ValueError("Amount cannot be negative")
        if len(self.currency) != 3 or not self.currency.isalpha():
            raise ValueError(f"Invalid currency code: {self.currency}")

    def _ensure_compatible(self, other: "Money") -> None:
        if not isinstance(other, Money):
            raise TypeError(f"Cannot operate with {type(other)}")
        if self.currency != other.currency:
            raise ValueError(
                f"Currency mismatch: {self.currency} vs {other.currency}"
            )

    def __add__(self, other: "Money") -> "Money":
        self._ensure_compatible(other)
        return Money(self.amount + other.amount, self.currency)

    def __sub__(self, other: "Money") -> "Money":
        self._ensure_compatible(other)
        result = self.amount - other.amount
        if result < 0:
            raise ValueError("Result would be negative")
        return Money(result, self.currency)

    def __mul__(self, factor: int | Decimal) -> "Money":
        if isinstance(factor, int):
            factor = Decimal(factor)
        rounded = (self.amount * factor).quantize(
            Decimal("0.01"), rounding=ROUND_HALF_UP
        )
        return Money(rounded, self.currency)

    def __gt__(self, other: "Money") -> bool:
        self._ensure_compatible(other)
        return self.amount > other.amount

    def __ge__(self, other: "Money") -> bool:
        self._ensure_compatible(other)
        return self.amount >= other.amount

    @classmethod
    def zero(cls, currency: str) -> "Money":
        return cls(Decimal("0"), currency)

    @classmethod
    def from_cents(cls, cents: int, currency: str) -> "Money":
        return cls(Decimal(cents) / Decimal("100"), currency)

    def to_cents(self) -> int:
        return int(self.amount * 100)

Usage:

price = Money(Decimal("29.99"), "USD")
tax = price * Decimal("0.08")  # Money(2.40, "USD")
total = price + tax             # Money(32.39, "USD")

The type system prevents mixing currencies at runtime — no accidental USD + EUR operations.

Email address with normalization

import re

@dataclass(frozen=True)
class EmailAddress(ValueObject):
    address: str

    _PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")

    def __post_init__(self):
        # Normalize before validation
        object.__setattr__(self, "address", self.address.strip().lower())
        self._validate()

    def _validate(self) -> None:
        if not self._PATTERN.match(self.address):
            raise ValueError(f"Invalid email address: {self.address}")

    @property
    def domain(self) -> str:
        return self.address.split("@")[1]

    @property
    def local_part(self) -> str:
        return self.address.split("@")[0]

The object.__setattr__ trick allows normalization in frozen dataclasses — it bypasses the frozen check during initialization.

Composite value objects

@dataclass(frozen=True)
class DateRange(ValueObject):
    start: date
    end: date

    def _validate(self) -> None:
        if self.end <= self.start:
            raise ValueError(f"End ({self.end}) must be after start ({self.start})")

    @property
    def days(self) -> int:
        return (self.end - self.start).days

    def contains(self, d: date) -> bool:
        return self.start <= d <= self.end

    def overlaps(self, other: "DateRange") -> bool:
        return self.start < other.end and other.start < self.end

    def merge(self, other: "DateRange") -> "DateRange":
        if not self.overlaps(other):
            raise ValueError("Cannot merge non-overlapping ranges")
        return DateRange(
            start=min(self.start, other.start),
            end=max(self.end, other.end),
        )

    def split_at(self, d: date) -> tuple["DateRange", "DateRange"]:
        if not self.contains(d):
            raise ValueError(f"{d} not in range")
        return DateRange(self.start, d), DateRange(d, self.end)


@dataclass(frozen=True)
class Address(ValueObject):
    street: str
    city: str
    state: str
    zip_code: str
    country: str = "US"

    def _validate(self) -> None:
        if not self.street.strip():
            raise ValueError("Street cannot be empty")
        if not self.city.strip():
            raise ValueError("City cannot be empty")
        if self.country == "US" and not re.match(r"^\d{5}(-\d{4})?$", self.zip_code):
            raise ValueError(f"Invalid US zip code: {self.zip_code}")

    @property
    def one_line(self) -> str:
        return f"{self.street}, {self.city}, {self.state} {self.zip_code}"

ORM mapping strategies

SQLAlchemy composite columns

from sqlalchemy import Column, String, Numeric
from sqlalchemy.orm import composite

class OrderModel(Base):
    __tablename__ = "orders"

    id = Column(String, primary_key=True)
    total_amount = Column(Numeric(10, 2))
    total_currency = Column(String(3))
    shipping_street = Column(String(200))
    shipping_city = Column(String(100))
    shipping_state = Column(String(50))
    shipping_zip = Column(String(20))
    shipping_country = Column(String(2))

    total = composite(Money, total_amount, total_currency)
    shipping_address = composite(
        Address,
        shipping_street, shipping_city, shipping_state,
        shipping_zip, shipping_country,
    )

SQLAlchemy’s composite maps multiple columns to a single value object, preserving the rich domain model while storing flat columns.

Manual mapping in repositories

When SQLAlchemy composites are insufficient (nested value objects, custom logic):

class OrderRepository:
    def _to_domain(self, model: OrderModel) -> Order:
        return Order(
            id=model.id,
            total=Money(Decimal(str(model.total_amount)), model.total_currency),
            shipping=Address(
                model.shipping_street, model.shipping_city,
                model.shipping_state, model.shipping_zip,
                model.shipping_country,
            ),
        )

    def _to_model(self, order: Order) -> OrderModel:
        return OrderModel(
            id=order.id,
            total_amount=order.total.amount,
            total_currency=order.total.currency,
            shipping_street=order.shipping.street,
            shipping_city=order.shipping.city,
            shipping_state=order.shipping.state,
            shipping_zip=order.shipping.zip_code,
            shipping_country=order.shipping.country,
        )

Serialization

JSON serialization

import json
from dataclasses import asdict

class ValueObjectEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, ValueObject):
            return {
                "__type__": type(obj).__name__,
                **asdict(obj),
            }
        if isinstance(obj, Decimal):
            return str(obj)
        if isinstance(obj, date):
            return obj.isoformat()
        return super().default(obj)

def decode_value_object(data: dict):
    registry = {
        "Money": Money,
        "Address": Address,
        "EmailAddress": EmailAddress,
        "DateRange": DateRange,
    }
    type_name = data.pop("__type__", None)
    if type_name and type_name in registry:
        return registry[type_name](**data)
    return data

Pydantic integration

from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema

class Money:
    # ... (frozen dataclass as before)

    @classmethod
    def __get_pydantic_core_schema__(cls, source, handler: GetCoreSchemaHandler):
        return core_schema.no_info_plain_validator_function(
            cls._pydantic_validate,
            serialization=core_schema.plain_serializer_function_ser_schema(
                lambda v: {"amount": str(v.amount), "currency": v.currency}
            ),
        )

    @classmethod
    def _pydantic_validate(cls, value):
        if isinstance(value, cls):
            return value
        if isinstance(value, dict):
            return cls(Decimal(value["amount"]), value["currency"])
        raise ValueError(f"Cannot parse {value} as Money")

Performance considerations

Hashing and dictionary keys

Frozen dataclasses automatically generate __hash__, making value objects usable as dictionary keys and set members:

price_counts: dict[Money, int] = {}
price = Money(Decimal("9.99"), "USD")
price_counts[price] = price_counts.get(price, 0) + 1

Memory with __slots__

For value objects created in high volumes (millions of instances):

@dataclass(frozen=True, slots=True)
class Point:
    x: float
    y: float

The slots=True parameter (Python 3.10+) reduces memory per instance by roughly 30-40%.

Flyweight pattern for common values

Cache frequently used value objects to reduce allocations:

class Currency:
    _cache: dict[str, "Currency"] = {}

    def __new__(cls, code: str) -> "Currency":
        if code in cls._cache:
            return cls._cache[code]
        instance = super().__new__(cls)
        cls._cache[code] = instance
        return instance

    def __init__(self, code: str):
        self.code = code

Testing value objects

def test_money_equality():
    assert Money(Decimal("10"), "USD") == Money(Decimal("10"), "USD")
    assert Money(Decimal("10"), "USD") != Money(Decimal("10"), "EUR")

def test_money_prevents_currency_mixing():
    usd = Money(Decimal("10"), "USD")
    eur = Money(Decimal("5"), "EUR")
    with pytest.raises(ValueError, match="Currency mismatch"):
        usd + eur

def test_money_immutability():
    price = Money(Decimal("10"), "USD")
    with pytest.raises(AttributeError):
        price.amount = Decimal("20")

def test_email_normalization():
    email = EmailAddress("  Alice@Example.COM  ")
    assert email.address == "alice@example.com"

def test_invalid_value_objects_cannot_exist():
    with pytest.raises(ValueError):
        Money(Decimal("-5"), "USD")
    with pytest.raises(ValueError):
        EmailAddress("not-valid")
    with pytest.raises(ValueError):
        DateRange(date(2026, 3, 15), date(2026, 3, 1))

When value objects are not the right choice

  • Mutable state — If the object genuinely needs to change (like a shopping cart being edited), it is an entity, not a value object.
  • Large objects — A value object with 20+ fields might indicate a design problem. Consider splitting into smaller value objects.
  • Performance-critical paths — If you are creating millions of value objects per second, the validation overhead might matter. Profile first before optimizing.

The one thing to remember: Value objects in Python leverage frozen dataclasses for immutability, custom operators for rich behavior, and ORM composites for persistence — turning primitive obsession into expressive, self-validating domain types.

pythonarchitectureddd

See Also