Skip to main content

Overview

Value Objects are immutable domain objects that are defined by their attributes rather than a unique identity. They represent descriptive aspects of the domain with no conceptual identity. Two value objects with the same attributes are considered equal.

Key Characteristics

  • Immutable: Once created, cannot be modified (use frozen=True)
  • No Identity: Equality based on attributes, not ID
  • Self-Validating: Validate on construction
  • Side-Effect Free: Methods return new instances rather than modifying state
  • Replaceable: Can be freely replaced with another instance of equal value

Value Object Structure

Value objects in Soft-Bee API follow this pattern:
from dataclasses import dataclass
import re
from ..exceptions.domain_exceptions import InvalidValueException

@dataclass(frozen=True)
class ValueObjectName:
    """Value object description"""
    value: str  # or other type
    
    def __post_init__(self):
        if not self.is_valid():
            raise InvalidValueException(f"Invalid value: {self.value}")
    
    def is_valid(self) -> bool:
        """Validate the value"""
        return True  # Implement validation logic
    
    def __str__(self) -> str:
        return self.value
    
    def __eq__(self, other) -> bool:
        if isinstance(other, ValueObjectName):
            return self.value == other.value
        return False
    
    def __hash__(self) -> int:
        return hash(self.value)

Real Example: Email Value Object

The Email value object demonstrates validation and behavior:
import re
from dataclasses import dataclass
from typing import Optional
from ...domain.exceptions.auth_exceptions import InvalidEmailException

@dataclass(frozen=True)
class Email:
    """Value Object para email"""
    value: str
    
    def __post_init__(self):
        if not self.is_valid():
            raise InvalidEmailException(f"Invalid email address: {self.value}")
    
    def is_valid(self) -> bool:
        """Validar formato de email"""
        pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
        return bool(re.match(pattern, self.value))
    
    def get_domain(self) -> str:
        """Obtener dominio del email"""
        return self.value.split('@')[1] if '@' in self.value else ''
    
    def get_username(self) -> str:
        """Obtener nombre de usuario del email"""
        return self.value.split('@')[0] if '@' in self.value else ''
    
    def __str__(self) -> str:
        return self.value
    
    def __eq__(self, other) -> bool:
        if isinstance(other, Email):
            return self.value.lower() == other.value.lower()
        return False
    
    def __hash__(self) -> int:
        return hash(self.value.lower())
See the full implementation at src/features/auth/domain/value_objects/email.py.

Real Example: Password Value Object

The Password value object demonstrates complex validation:
from dataclasses import dataclass
from typing import Optional
import re
from ...domain.exceptions.auth_exceptions import WeakPasswordException

@dataclass(frozen=True)
class Password:
    """Value Object para password en texto plano (solo para validación)"""
    value: str
    
    def __post_init__(self):
        self.validate_strength()
    
    def validate_strength(self):
        """Validar fortaleza del password"""
        if len(self.value) < 8:
            raise WeakPasswordException("Password must be at least 8 characters long")
        
        if not re.search(r'[A-Z]', self.value):
            raise WeakPasswordException("Password must contain at least one uppercase letter")
        
        if not re.search(r'[a-z]', self.value):
            raise WeakPasswordException("Password must contain at least one lowercase letter")
        
        if not re.search(r'[0-9]', self.value):
            raise WeakPasswordException("Password must contain at least one number")
        
        if not re.search(r'[!@#$%^&*(),.?":{}|<>]', self.value):
            raise WeakPasswordException("Password must contain at least one special character")
    
    def __str__(self) -> str:
        return "[PROTECTED]"  # Nunca exponer el password real
See the full implementation at src/features/auth/domain/value_objects/password.py.

Value Object Design Principles

1. Immutability

Always use frozen=True to prevent modification:
@dataclass(frozen=True)
class Email:
    value: str

2. Validation

Validate in __post_init__ to ensure invalid objects cannot exist:
def __post_init__(self):
    if not self.is_valid():
        raise InvalidEmailException(f"Invalid email: {self.value}")

3. Equality

Implement __eq__ based on attributes, not identity:
def __eq__(self, other) -> bool:
    if isinstance(other, Email):
        return self.value.lower() == other.value.lower()
    return False

4. Hashing

Implement __hash__ to allow use in sets and as dict keys:
def __hash__(self) -> int:
    return hash(self.value.lower())

5. String Representation

Provide a meaningful string representation:
def __str__(self) -> str:
    return self.value

Creating a New Value Object

Step 1: Define the Value Object Class

# src/features/hives/domain/value_objects/hive_location.py
from dataclasses import dataclass
import re
from ..exceptions.hive_exceptions import InvalidLocationException

@dataclass(frozen=True)
class HiveLocation:
    """Value object for hive geographical location"""
    latitude: float
    longitude: float
    address: str
    
    def __post_init__(self):
        self.validate()
    
    def validate(self):
        if not -90 <= self.latitude <= 90:
            raise InvalidLocationException("Latitude must be between -90 and 90")
        
        if not -180 <= self.longitude <= 180:
            raise InvalidLocationException("Longitude must be between -180 and 180")
        
        if not self.address or len(self.address) < 5:
            raise InvalidLocationException("Address must be at least 5 characters")

Step 2: Add Behavior Methods

def distance_to(self, other: 'HiveLocation') -> float:
    """Calculate distance to another location in kilometers"""
    from math import radians, sin, cos, sqrt, atan2
    
    R = 6371  # Earth's radius in km
    
    lat1, lon1 = radians(self.latitude), radians(self.longitude)
    lat2, lon2 = radians(other.latitude), radians(other.longitude)
    
    dlat = lat2 - lat1
    dlon = lon2 - lon1
    
    a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
    c = 2 * atan2(sqrt(a), sqrt(1-a))
    
    return R * c

def is_near(self, other: 'HiveLocation', max_distance_km: float = 10) -> bool:
    """Check if another location is within specified distance"""
    return self.distance_to(other) <= max_distance_km

Step 3: Implement Equality and Hashing

def __eq__(self, other) -> bool:
    if isinstance(other, HiveLocation):
        return (
            abs(self.latitude - other.latitude) < 0.0001 and
            abs(self.longitude - other.longitude) < 0.0001 and
            self.address == other.address
        )
    return False

def __hash__(self) -> int:
    return hash((round(self.latitude, 4), round(self.longitude, 4), self.address))

def __str__(self) -> str:
    return f"{self.address} ({self.latitude}, {self.longitude})"

Common Value Object Patterns

Single-Value Object

Simple wrapper around a single value:
@dataclass(frozen=True)
class PhoneNumber:
    value: str
    
    def __post_init__(self):
        if not self.is_valid():
            raise InvalidPhoneException(f"Invalid phone: {self.value}")
    
    def is_valid(self) -> bool:
        pattern = r'^\+?1?\d{9,15}$'
        return bool(re.match(pattern, self.value))

Multi-Value Object

Combines multiple related values:
@dataclass(frozen=True)
class DateRange:
    start_date: datetime
    end_date: datetime
    
    def __post_init__(self):
        if self.start_date > self.end_date:
            raise InvalidDateRangeException("Start date must be before end date")
    
    def duration_days(self) -> int:
        return (self.end_date - self.start_date).days
    
    def contains(self, date: datetime) -> bool:
        return self.start_date <= date <= self.end_date

Enumeration Value Object

Restricted set of valid values:
from enum import Enum

class HiveStatus(Enum):
    ACTIVE = "active"
    INACTIVE = "inactive"
    QUARANTINE = "quarantine"
    ABANDONED = "abandoned"
    
    @classmethod
    def from_string(cls, value: str) -> 'HiveStatus':
        try:
            return cls(value.lower())
        except ValueError:
            raise InvalidStatusException(f"Invalid status: {value}")

Measurement Value Object

Value with unit of measurement:
@dataclass(frozen=True)
class Weight:
    value: float
    unit: str = "kg"
    
    def __post_init__(self):
        if self.value < 0:
            raise InvalidWeightException("Weight cannot be negative")
        
        if self.unit not in ["kg", "g", "lb", "oz"]:
            raise InvalidWeightException(f"Invalid unit: {self.unit}")
    
    def to_kg(self) -> float:
        conversions = {"kg": 1, "g": 0.001, "lb": 0.453592, "oz": 0.0283495}
        return self.value * conversions[self.unit]
    
    def __str__(self) -> str:
        return f"{self.value} {self.unit}"

Best Practices

Always Validate on Construction

# Good
def __post_init__(self):
    if not self.is_valid():
        raise InvalidException("...")

# Avoid
def __init__(self, value):
    self.value = value  # No validation

Use Frozen Dataclasses

# Good
@dataclass(frozen=True)
class Email:
    value: str

# Avoid
@dataclass
class Email:
    value: str  # Mutable!

Return New Instances for Transformations

# Good
def with_new_domain(self, domain: str) -> 'Email':
    username = self.get_username()
    return Email(f"{username}@{domain}")

# Avoid (would fail with frozen=True anyway)
def change_domain(self, domain: str):
    self.value = f"{self.get_username()}@{domain}"  # Mutation!

Implement Proper Equality

# Good - Case-insensitive email comparison
def __eq__(self, other) -> bool:
    if isinstance(other, Email):
        return self.value.lower() == other.value.lower()
    return False

# Avoid - Default identity comparison
# (relying on dataclass default)

Protect Sensitive Data in String Representation

# Good
def __str__(self) -> str:
    return "[PROTECTED]"

# Avoid
def __str__(self) -> str:
    return self.value  # Exposes password!

When to Use Value Objects

Use value objects when:
  • The concept has no unique identity
  • Equality is based on attributes, not identity
  • The value should be immutable
  • The value has validation rules
  • You want to encapsulate related attributes (e.g., coordinates)
  • You want type safety (e.g., Email instead of str)
Avoid value objects when:
  • The object needs to change over time
  • The object has a lifecycle
  • Identity matters (use an Entity instead)

Build docs developers (and LLMs) love