3

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:

  1. Must be immutable
  2. May be nested
  3. Can contain single value (like postal code). I call it single-value ValueObject
  4. Single-value ValueObject can be compared to scalar values.
  5. Must be self-validated with the option of accumulating of all errors
  6. 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:

  1. Am I doing something explicitly wrong according to DDD? )
  2. How to best deal with single value VO? Currently I'm using special class for such values but looks like it's overkill.
  3. 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?
  4. How to escape from every time class decorating with code like @dataclass(frozen=True, repr=False)? Meta-class maybe?
  5. It will become impossible to construct "old" ValueObject if validation rules change after some time. How to work with such legacy objects in future?
Igorek
  • 31
  • 3
  • https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/ – VoiceOfUnreason Jan 09 '22 at 03:58
  • Main point of "parse don't validate" is to focus on datatypes which are already validated. And I agree, throughout my app I will use ComplexVO without any validation because ValueObject is datatype itself. Maybe you mean to parse/validate raw input even before constructing value object? And what about business validation rules (which I believe must be inside VO)? – Igorek Jan 09 '22 at 10:00

0 Answers0