How many bugs have you fixed because someone passed an email where a user ID was expected? Or mixed up cents and dollars? Or used an invalid status string? Value Objects eliminate these bugs entirely.
The Problem with Primitives
Consider this common Python code:
def create_user(email: str, name: str, age: int) -> User:
# Is this email valid?
# Is name empty?
# Is age negative?
... Every function that receives these values must validate them. Or trust that someone else did. The type system only tells us "it's a string" - not "it's a valid email".
Worse, nothing prevents this:
# Oops! Name and email swapped
create_user(name, email, age) Value Objects to the Rescue
A Value Object wraps a primitive and guarantees its validity:
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class Email:
value: str
def __post_init__(self):
if not re.match(r'^[^@]+@[^@]+\.[^@]+$', self.value):
raise ValueError(f"Invalid email: {self.value}")
@property
def domain(self) -> str:
return self.value.split('@')[1]
def __eq__(self, other: object) -> bool:
if not isinstance(other, Email):
return False
return self.value == other.value Now your function signature tells the whole story:
def create_user(email: Email, name: Name, age: Age) -> User:
# All values are guaranteed valid
# Can't accidentally swap email and name - different types!
... Common Value Objects
Money
@dataclass(frozen=True)
class Money:
cents: int
currency: Currency
def __post_init__(self):
if self.cents < 0:
raise ValueError("Money cannot be negative")
@classmethod
def from_dollars(cls, dollars: float, currency: Currency) -> "Money":
return cls(cents=int(dollars * 100), currency=currency)
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise CurrencyMismatchError()
return Money(self.cents + other.cents, self.currency)
def format(self) -> str:
return f"{self.currency.symbol}{self.cents / 100:.2f}" DateRange
from datetime import date
@dataclass(frozen=True)
class DateRange:
start: date
end: date
def __post_init__(self):
if self.end < self.start:
raise ValueError("End must be after start")
def contains(self, d: date) -> bool:
return self.start <= d <= self.end
def overlaps(self, other: "DateRange") -> bool:
return self.start <= other.end and self.end >= other.start
@property
def days(self) -> int:
return (self.end - self.start).days Address
@dataclass(frozen=True)
class Address:
street: str
city: str
postal_code: str
country: Country
def __post_init__(self):
if not self.street.strip():
raise ValueError("Street cannot be empty")
def format(self) -> str:
return f"{self.street}, {self.city}, {self.postal_code}, {self.country.name}" Using Value Objects with SQLAlchemy
SQLAlchemy's composite types make Value Objects work seamlessly with your models:
from sqlalchemy.orm import composite
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
_email = Column("email", String(255))
@property
def email(self) -> Email:
return Email(self._email)
@email.setter
def email(self, value: Email) -> None:
self._email = value.value
# Now this works:
user.email = Email("john@example.com")
session.commit()
print(user.email.domain) # "example.com" Why AI Loves Value Objects
When AI coding assistants see your code, Value Objects give them critical context:
# AI doesn't know what these strings mean
def process_payment(amount: str, currency: str) -> None: ...
# AI understands exactly what's expected
def process_payment(amount: Money) -> None: ... The AI can't accidentally suggest passing a user ID where money is expected. The types make the intent explicit.
Start Today
You don't need to convert your entire codebase. Start with:
- Email - the most common invalid primitive
- Money - if you handle payments
- UserId - prevent mixing up IDs
- DateRange - for booking/scheduling systems
Each Value Object you add prevents a category of bugs forever.
Want more patterns?
Chapter 6 (Value Objects) of Pragmatic DDD with Python covers 15+ real-world Value Objects with complete implementations and SQLAlchemy integration.
Get the book