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.
See Also
- Python Aggregate Pattern Why grouping related objects under a single gatekeeper prevents data chaos in your Python application.
- Python Bounded Contexts Why the same word means different things in different parts of your code — and why that is perfectly fine.
- Python Bulkhead Pattern Why smart Python apps put walls between their parts — like a ship that stays afloat even with a hole in the hull.
- Python Circuit Breaker Pattern How a circuit breaker saves your app from crashing — explained with a home electrical fuse analogy.
- Python Clean Architecture Why your Python app should look like an onion — and how that saves you from painful rewrites.