Value Objects

Объекты-значения в Domain-Driven Design

Что такое Value Object?

Value Object — объект, который определяется полностью значением его атрибутов, а не идентификатором. Два Value Object равны, если все их атрибуты равны.

🔹 Entity vs 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! (тот же адрес)

🔹 Характеристики Value Object

  • Неизменяемость (Immutable) — не может быть изменён после создания
  • Равенство по значению — два Value Object равны, если все поля равны
  • Нет идентификатора — идентифицируется только значением
  • Самостоятельная валидация — содержит логику валидации

Примеры Value Objects

🔹 Email

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

🔹 Money

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")

🔹 Address

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)

🔹 DateRange

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")

Использование Value Objects

🔹 В Entity

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)

🔹 В Domain Service

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")

Преимущества Value Objects

  • Валидация — валидация происходит при создании
  • Безопасность — невозможно создать невалидный объект
  • Явность — код становится более читаемым
  • Переиспользование — можно использовать в разных местах
  • Тестируемость — легко тестировать изолированно
  • Бизнес-логика — логика инкапсулирована в Value Object

🔹 Без Value Objects (проблемы)

# ❌ Проблемы с примитивами
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 (решение)

# ✅ 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 Objects

  1. Неизменяемость — Value Object не может быть изменён после создания
  2. Равенство по значению — два Value Object равны, если все поля равны
  3. Валидация — валидация происходит при создании
  4. Бизнес-логика — содержит логику работы с значением
  5. Самодостаточность — не зависит от внешнего состояния
"Value Object — это не про данные. Это про смысл.
Value Object инкапсулирует значение и его бизнес-правила."

📚 Когда использовать Value Objects?

  • Примитивные типы с валидацией — Email, Phone, URL
  • Составные значения — Address, DateRange, Coordinates
  • Значения с логикой — Money, Percentage, Temperature
  • Измерения — Distance, Weight, Volume
  • Простые примитивы — int, str (если нет валидации)