ValueObject seems so easy in theory, but I wasn't able to find appropriate solution to implement it according to DDD in python. Hope for your help ;)
My valueobject's requirements:
- Must be immutable
- May be nested
- Can contain single value (like postal code). I call it single-value ValueObject
- Single-value ValueObject can be compared to scalar values.
- Must be self-validated with the option of accumulating of all errors
- Can be constructed from and serialized to different formats (json here)
According to requirements I came up with smth like this:
import copy
from typing import Any
from dataclasses import dataclass, fields, _is_dataclass_instance
def _asdict_inner(obj, dict_factory):
"""from dataclass._asdict_inner"""
if isinstance(obj, (SingleValueObject, )):
return _asdict_inner(obj.value, dict_factory)
elif _is_dataclass_instance(obj):
result = []
for f in fields(obj):
value = _asdict_inner(getattr(obj, f.name), dict_factory)
result.append((f.name, value))
return dict_factory(result)
elif isinstance(obj, tuple) and hasattr(obj, '_fields'):
return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj])
elif isinstance(obj, (list, tuple)):
return type(obj)(_asdict_inner(v, dict_factory) for v in obj)
elif isinstance(obj, dict):
return type(obj)((_asdict_inner(k, dict_factory),
_asdict_inner(v, dict_factory))
for k, v in obj.items())
else:
return copy.deepcopy(obj)
@dataclass(frozen=True, repr=False)
class ValueObject:
def as_dict(self) -> dict:
return _asdict_inner(self, dict)
def __repr__(self, ident: int = 0):
res = f'{self.__class__.__name__}(\n'
for f in fields(self):
attr = getattr(self, f.name)
res += f'{" " * (ident + 2)}{f.name}: '
if isinstance(attr, ValueObject) and not isinstance(attr, SingleValueObject):
res += attr.__repr__(ident=ident + 2)
else:
res += f'{attr}\n'
res += f'{" " * ident})\n'
return res
@dataclass(frozen=True, eq=False)
class SingleValueObject(ValueObject):
value: Any
def validate(self) -> None:
return
def __post_init__(self):
self.validate()
def __str__(self):
return str(self.value)
def __eq__(self, other: Any) -> bool:
return self.value == other
@dataclass(frozen=True, eq=False)
class PostalCode(SingleValueObject):
value: str
def validate(self):
if self.value != '111':
raise ValueError('PostalCode error')
@dataclass(frozen=True, repr=False)
class Address(ValueObject):
city: str
postal_code: PostalCode
@classmethod
def from_dict(cls, addr: dict):
return cls(
city=addr.get('city'),
postal_code=PostalCode(addr.get('postal_code'))
)
@dataclass(frozen=True, repr=False)
class User(ValueObject):
first_name: str
last_name: str
@classmethod
def from_dict(cls, user: dict):
return cls(
first_name=user.get('first_name'),
last_name=user.get('last_name'),
)
@dataclass(frozen=True, repr=False)
class ComplexVO(ValueObject):
prop1: str
prop2: int
addr: Address
user: User
@classmethod
def from_dict(cls, complex_vo: dict):
address = Address.from_dict(complex_vo.get('address'))
user = User.from_dict(complex_vo.get('user'))
prop1 = complex_vo.get('prop1', 'prop1_default')
prop2 = 1 if prop1 == 'prop1_default' else 2
return cls(
prop1=prop1,
prop2=prop2,
addr=address,
user=user
)
if __name__ == '__main__':
c = ComplexVO.from_dict(dict(
address=dict(
city='city',
postal_code='111'
),
user=dict(
first_name='f_name',
last_name='l_name',
),
))
print('Repr:', c)
print('Serialize:', c.as_dict())
print('SingleValue compare', PostalCode('111') == '111')
If execute:
Repr: ComplexVO(
prop1: prop1_default
prop2: 1
addr: Address(
city: city
postal_code: 111
)
user: User(
first_name: f_name
last_name: l_name
)
)
Serialize: {'prop1': 'prop1_default', 'prop2': 1, 'addr': {'city': 'city', 'postal_code': '111'}, 'user': {'first_name': 'f_name', 'last_name': 'l_name'}}
SingleValue compare True
Questions:
- Am I doing something explicitly wrong according to DDD? )
- How to best deal with single value VO? Currently I'm using special class for such values but looks like it's overkill.
- Validation seems easy if fail fast - just raise an ValueError, but I want to accumulate all errors before fail (especially when thinking about multiple nesting - each VO has its own validation rules). How to do it best?
- How to escape from every time class decorating with code like
@dataclass(frozen=True, repr=False)
? Meta-class maybe? - It will become impossible to construct "old" ValueObject if validation rules change after some time. How to work with such legacy objects in future?