Skip to content

ValueObject

Value Objects are immutable, identity-less domain objects. They are defined by their attributes rather than by a unique identifier. Two value objects with the same field values are considered equal.

Class Definition

ValueObject is always immutable — there is no way to change a value object after construction.

from aod.domain import ValueObject


class Money(ValueObject):
    amount: float
    currency: str

Constructor Parameters

ValueObject.__init__() accepts keyword arguments for every annotated field on the subclass. The constructor validates all values using the validation model (Pydantic with constraints and invariants).

price = Money(amount=9.99, currency="USD")

Each annotated field becomes a constructor parameter:

Parameter Type Description
amount float Monetary amount. Required unless a default is provided
currency str Currency code (e.g. "USD"). Required unless a default is provided

Fields without defaults are required. Fields with defaults are optional:

class Config(ValueObject):
    host: str = "localhost"
    port: int = 8080
    debug: bool = False

config = Config()
assert config.host == "localhost"

Key Characteristics

Immutable

Value objects cannot be changed after creation. Any attempt to set an attribute raises MutationForbiddenException:

price.amount = 10.0  # MutationForbiddenException!

Unlike entities (which allow mutation inside public methods), value objects block mutation unconditionally:

class Address(ValueObject):
    street: str
    city: str
    country: str

addr = Address(street="123 Main St", city="Springfield", country="US")
addr.city = "Shelbyville"  # MutationForbiddenException!

No Identity

Value objects have no id field. They are compared by their attributes:

class Email(ValueObject):
    value: str

e1 = Email(value="alice@example.com")
e2 = Email(value="alice@example.com")
assert e1 == e2  # True — same value, same object

Marking a field with Field(id=True) on a ValueObject raises InvalidValueObjectFieldError at class creation time. ValueObjects are identity-less by design — they cannot have identity fields.

from aod.domain import ValueObject, Field

# Raises InvalidValueObjectFieldError:
class Bad(ValueObject):
    id: str = Field(id=True)  # Error!
    name: str

Structural Equality

Two value objects with the same attributes are considered equal:

class Color(ValueObject):
    r: int
    g: int
    b: int

red1 = Color(r=255, g=0, b=0)
red2 = Color(r=255, g=0, b=0)
assert red1 == red2  # True

green = Color(r=0, g=255, b=0)
assert red1 != green  # True

Default Values and Optional Fields

Default values make constructor parameters optional:

from typing import Optional


class Address(ValueObject):
    street: str
    city: str
    country: str
    postal_code: Optional[str] = None

addr = Address(street="123 Main St", city="Springfield", country="US")
assert addr.postal_code is None

Complex Value Objects

Value objects can contain other value objects:

class Money(ValueObject):
    amount: float
    currency: str

class OrderTotal(ValueObject):
    subtotal: Money
    tax: Money
    total: Money

total = OrderTotal(
    subtotal=Money(amount=100.0, currency="USD"),
    tax=Money(amount=8.0, currency="USD"),
    total=Money(amount=108.0, currency="USD"),
)

Type Hints

Value objects support all Python type hints, including collections, Optional, datetime, and nested value objects:

from datetime import datetime
from typing import Optional


class AuditInfo(ValueObject):
    created_at: datetime
    updated_at: Optional[datetime] = None
    created_by: str
    version: int = 1

Validation with Pydantic

Value objects can use @field_invariance for field-level validation:

from aod.domain.validation import field_invariance


class Money(ValueObject):
    amount: float
    currency: str

    @field_invariance("amount")
    def amount_must_be_positive(cls, v: float) -> float:
        if v < 0:
            raise ValueError("amount must be positive")
        return v

price = Money(amount=-10.0, currency="USD")  # InvarianceException!

Reconstruct

reconstruct() is a classmethod that creates value objects without validation. Useful for loading persisted data where validation has already been applied.

money = Money.reconstruct(amount=9.99, currency="USD")

Parameters

Parameter Type Description
**kwargs Any Same keyword arguments as the constructor, but validation is skipped

__post_init__ Hook

Override __post_init__() for initialisation logic after all fields are set. It runs during normal __init__ but NOT during reconstruct().

class Email(ValueObject):
    value: str

    def __post_init__(self):
        self._event_emitter.emit(EmailCreatedEvent(value=self.value))

When to Use __post_init__ vs @field_invariance

Both run at construction time but serve different purposes:

Concern __post_init__ @field_invariance
What it does Post-construction logic using the instantiated object (self) Validates a field value before it is stored
Use case Emit creation events, compute derived data Enforce business rules on field values
Runs on reconstruct() No — only on normal __init__ No
Has self Yes No — receives cls and the raw value
Can mutate fields Yes (during the hook) No

Use __post_init__ for operations that need the constructed instance. Use @field_invariance to validate that a value satisfies a domain rule before it is accepted.

Testing

build()

from aod.testing import build

money = build(Money, amount=9.99, currency="USD")
Parameter Type Description
cls type[T] The value object class to instantiate
**kwargs Any Field values. Same as constructor but validation is skipped

events_of()

from aod.testing import events_of

events = events_of(money)
Parameter Type Description
obj ValueObject The value object to extract events from

Returns list[Event].

Common Patterns

Money

class Money(ValueObject):
    amount: float
    currency: str

    def add(self, other: Money) -> Money:
        if self.currency != other.currency:
            raise ValueError("Cannot add different currencies")
        return Money(amount=self.amount + other.amount, currency=self.currency)

Email

class Email(ValueObject):
    value: str

    @field_invariance("value")
    def validate_email(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("Invalid email")
        return v.lower()

DateRange

from datetime import datetime
from aod.domain.validation import invariance


class DateRange(ValueObject):
    start: datetime
    end: datetime

    @invariance
    def end_after_start(cls, data: dict) -> dict:
        if data["end"] <= data["start"]:
            raise ValueError("end must be after start")
        return data

Exceptions

Exception Raised When
MutationForbiddenException Attempting to mutate a value object field after construction
ModelValidationError Pydantic validation fails during __init__
InvalidValueObjectFieldError A ValueObject field is marked with Field(id=True)

Next Steps

Entity & RootEntity

Learn about mutable domain objects with identity

Service

Learn about stateless domain operations

Event System

Learn about domain events

Validation

Learn about invariants and validators