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.
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).
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:
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.
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()¶
| 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()¶
| 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