Объекты-значения в Domain-Driven Design
Value Object — объект, который определяется полностью значением его атрибутов, а не идентификатором. Два Value Object равны, если все их атрибуты равны.
# Entity — имеет идентификатор
class User: # Entity
def __init__(self, user_id: int, name: str, email: str):
self.id = user_id # Идентификатор
self.name = name
self.email = email
def __eq__(self, other):
return isinstance(other, User) and self.id == other.id
user1 = User(1, "Alice", "alice@example.com")
user2 = User(1, "Bob", "bob@example.com")
assert user1 == user2 # True! (тот же ID)
# Value Object — нет идентификатора
class Email: # Value Object
def __init__(self, address: str):
self.address = address.lower()
def __eq__(self, other):
return isinstance(other, Email) and self.address == other.address
email1 = Email("ALICE@EXAMPLE.COM")
email2 = Email("alice@example.com")
assert email1 == email2 # True! (тот же адрес)
class Email:
def __init__(self, address: str):
if not self._is_valid(address):
raise ValueError(f"Invalid email address: {address}")
self._address = address.lower()
@property
def address(self) -> str:
return self._address
def _is_valid(self, address: str) -> bool:
if "@" not in address:
return False
parts = address.split("@")
if len(parts) != 2:
return False
local, domain = parts
if not local or not domain:
return False
if "." not in domain:
return False
return True
def __eq__(self, other) -> bool:
if not isinstance(other, Email):
return False
return self._address == other._address
def __hash__(self) -> int:
return hash(self._address)
def __str__(self) -> str:
return self._address
# Использование
email = Email("alice@example.com")
user = User(name="Alice", email=email) # Email как Value Object
class Money:
def __init__(self, amount: float, currency: str):
if amount < 0:
raise ValueError("Amount cannot be negative")
if currency not in ["USD", "EUR", "RUB"]:
raise ValueError(f"Unsupported currency: {currency}")
self._amount = amount
self._currency = currency
@property
def amount(self) -> float:
return self._amount
@property
def currency(self) -> str:
return 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 __sub__(self, other: 'Money') -> 'Money':
if self._currency != other._currency:
raise ValueError("Cannot subtract different currencies")
return Money(self._amount - other._amount, self._currency)
def __mul__(self, multiplier: float) -> 'Money':
return Money(self._amount * multiplier, self._currency)
def __eq__(self, other) -> bool:
if not isinstance(other, Money):
return False
return self._amount == other._amount and self._currency == other._currency
def __str__(self) -> str:
return f"{self._amount} {self._currency}"
# Использование
price = Money(100.0, "USD")
discount = Money(10.0, "USD")
total = price - discount # Money(90.0, "USD")
class Address:
def __init__(self, street: str, city: str, country: str, zip_code: str):
if not street or not city or not country:
raise ValueError("Street, city, and country are required")
self._street = street
self._city = city
self._country = country
self._zip_code = zip_code
@property
def street(self) -> str:
return self._street
@property
def city(self) -> str:
return self._city
@property
def country(self) -> str:
return self._country
@property
def zip_code(self) -> str:
return self._zip_code
def __eq__(self, other) -> bool:
if not isinstance(other, Address):
return False
return (
self._street == other._street and
self._city == other._city and
self._country == other._country and
self._zip_code == other._zip_code
)
def __hash__(self) -> int:
return hash((self._street, self._city, self._country, self._zip_code))
def __str__(self) -> str:
return f"{self._street}, {self._city}, {self._country} {self._zip_code}"
# Использование
address = Address("123 Main St", "New York", "USA", "10001")
user = User(name="Alice", address=address)
from datetime import date
class DateRange:
def __init__(self, start_date: date, end_date: date):
if start_date > end_date:
raise ValueError("Start date must be before end date")
self._start_date = start_date
self._end_date = end_date
@property
def start_date(self) -> date:
return self._start_date
@property
def end_date(self) -> date:
return self._end_date
def contains(self, date: date) -> bool:
return self._start_date <= date <= self._end_date
def overlaps(self, other: 'DateRange') -> bool:
return (
self._start_date <= other._end_date and
self._end_date >= other._start_date
)
def days(self) -> int:
return (self._end_date - self._start_date).days
def __eq__(self, other) -> bool:
if not isinstance(other, DateRange):
return False
return (
self._start_date == other._start_date and
self._end_date == other._end_date
)
def __str__(self) -> str:
return f"{self._start_date} - {self._end_date}"
# Использование
booking_period = DateRange(date(2024, 1, 1), date(2024, 1, 7))
if booking_period.contains(date(2024, 1, 3)):
print("Date is in range")
class User: # Entity
def __init__(self, user_id: int, name: str, email: Email, address: Address):
self.id = user_id
self.name = name
self.email = email # Value Object
self.address = address # Value Object
def change_email(self, new_email: Email):
# Email валидируется при создании
self.email = new_email
def change_address(self, new_address: Address):
# Address валидируется при создании
self.address = new_address
# Использование
email = Email("alice@example.com")
address = Address("123 Main St", "New York", "USA", "10001")
user = User(1, "Alice", email, address)
# Валидация происходит автоматически
try:
invalid_email = Email("invalid-email") # ValueError!
except ValueError as e:
print(e)
class PricingService:
def calculate_total(self, items: List[OrderItem]) -> Money:
total = Money(0.0, "USD")
for item in items:
item_total = item.price * item.quantity
total = total + item_total
return total
class OrderItem:
def __init__(self, product_id: int, price: Money, quantity: int):
self.product_id = product_id
self.price = price # Value Object
self.quantity = quantity
@property
def total(self) -> Money:
return self.price * self.quantity
# Использование
items = [
OrderItem(1, Money(10.0, "USD"), 2),
OrderItem(2, Money(20.0, "USD"), 1)
]
pricing_service = PricingService()
total = pricing_service.calculate_total(items) # Money(40.0, "USD")
# ❌ Проблемы с примитивами
class User:
def __init__(self, name: str, email: str, balance: float):
self.name = name
self.email = email # Может быть невалидным
self.balance = balance # Может быть отрицательным
def add_balance(self, amount: float):
# Нужно валидировать каждый раз
if amount < 0:
raise ValueError("Amount cannot be negative")
self.balance += amount
def send_email(self, to: str):
# Нужно валидировать email каждый раз
if "@" not in to:
raise ValueError("Invalid email")
# ...
# Проблемы:
# - Валидация размазана по коду
# - Легко забыть валидировать
# - Дублирование кода
# ✅ Value Objects решают проблемы
class User:
def __init__(self, name: str, email: Email, balance: Money):
self.name = name
self.email = email # Всегда валидный
self.balance = balance # Всегда валидный
def add_balance(self, amount: Money):
# Валидация уже в Money
self.balance = self.balance + amount
def send_email(self, to: Email):
# Email всегда валидный
# ...
# Преимущества:
# - Валидация в одном месте
# - Невозможно создать невалидный объект
# - Код более читаемый
"Value Object — это не про данные. Это про смысл.
Value Object инкапсулирует значение и его бизнес-правила."