Python Dataclasses and Value Objects
Standard Dataclasses
from dataclasses import dataclass, field, KW_ONLY, replace
from datetime import datetime
from uuid import UUID, uuid4
@dataclass(frozen=True, slots=True) # frozen=immutable, slots=memory efficient
class Money:
amount: int # always store as smallest unit (cents)
currency: str = "USD"
def __post_init__(self) -> None:
if self.amount < 0:
raise ValueError(f"Money amount cannot be negative: {self.amount}")
if len(self.currency) != 3:
raise ValueError(f"Currency must be 3-letter ISO code: {self.currency}")
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("Cannot add different currencies")
return Money(self.amount + other.amount, self.currency)
def __str__(self) -> str:
return f"{self.amount / 100:.2f} {self.currency}"
@dataclass
class Order:
id: UUID = field(default_factory=uuid4)
items: list["OrderItem"] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.utcnow)
_: KW_ONLY # everything below must be keyword-only
user_id: int = 0
def total(self) -> Money:
if not self.items:
return Money(0)
return sum((item.subtotal() for item in self.items[1:]), self.items[0].subtotal())
# Update immutable dataclass (returns new instance)
updated_order = replace(order, user_id=42)
msgspec for High-Performance Serialization
import msgspec
class UserEvent(msgspec.Struct, frozen=True):
user_id: int
event_type: str
timestamp: datetime
metadata: dict[str, str] = {}
# Encode/decode
event = UserEvent(user_id=1, event_type="login", timestamp=datetime.utcnow())
encoded = msgspec.json.encode(event) # bytes, much faster than json.dumps
decoded = msgspec.json.decode(encoded, type=UserEvent)
# Validation
try:
bad = msgspec.json.decode(b'{"user_id": "not-an-int"}', type=UserEvent)
except msgspec.ValidationError as e:
print(e) # user_id: expected int, got str
attrs (for complex cases)
import attrs
@attrs.define(frozen=True)
class Address:
street: str = attrs.field(validator=attrs.validators.min_len(1))
city: str
country: str = attrs.field(validator=attrs.validators.in_(["US", "CA", "GB"]))
postal_code: str = attrs.field()
@postal_code.validator
def _validate_postal_code(self, attribute, value: str) -> None:
if not value.replace("-", "").isalnum():
raise ValueError(f"Invalid postal code: {value}")
# Evolution pattern
@attrs.define
class Config:
host: str = "localhost"
port: int = 8080
timeout: float = 30.0
@classmethod
def from_env(cls) -> "Config":
import os
return cls(
host=os.getenv("HOST", "localhost"),
port=int(os.getenv("PORT", "8080")),
)