Back to Blog

Value Objects: The Pattern Your Python Needs

- 5 min read

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:

  1. Email - the most common invalid primitive
  2. Money - if you handle payments
  3. UserId - prevent mixing up IDs
  4. 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