Skip to content

Container Fields

Applies to: DDD · CQRS · Event Sourcing

ValueObject

Represents a field that holds a value object. This field is used to embed a Value Object within an entity.

Arguments

  • value_object_cls: The class of the value object to be embedded.
from protean import Domain
from protean.fields import Float, String, ValueObject

domain = Domain()


@domain.value_object
class Balance:
    """A composite amount object, containing two parts:
    * currency code - a three letter unique currency code
    * amount - a float value
    """

    currency: String(max_length=3, required=True)
    amount: Float(required=True, min_value=0.0)


@domain.aggregate
class Account:
    balance = ValueObject(Balance)
    name: String(max_length=30)

You can provide an instance of the Value Object as input to the value object field:

In [1]: account = Account(
   ...:     balance=Balance(currency="USD", amount=100.0),
   ...:     name="Checking"
   ...: )

In [2]: account.to_dict()
Out[2]:
{'name': 'Checking',
 'id': '513b8a78-e00f-45ce-bb6f-11ef0cccbec6',
 'balance': {'currency': 'USD', 'amount': 100.0},
 '_version': -1}

ValueObjectFromEntity

A convenience variant of ValueObject that auto-generates its value object class from an entity. Instead of manually defining a VO that mirrors an entity's fields, this descriptor derives it at class-body evaluation time.

Arguments

  • entity_cls: The entity (or aggregate) class to project into a value object. Identity/unique fields become optional, Reference fields are excluded, and HasOne/HasMany associations are recursively converted.
from protean.fields import (
    HasMany,
    Identifier,
    Integer,
    List,
    String,
    ValueObjectFromEntity,
)


@domain.aggregate
class Order:
    customer_id: Identifier(required=True)
    items = HasMany("OrderItem")


@domain.entity(part_of=Order)
class OrderItem:
    product_id: String(max_length=50, required=True)
    quantity: Integer(min_value=1)


@domain.command(part_of=Order)
class PlaceOrder:
    customer_id: Identifier(required=True)
    items: List(content_type=ValueObjectFromEntity(OrderItem))

This is equivalent to calling value_object_from_entity(OrderItem) and passing the result to a ValueObject field. Use whichever form is clearer for your use case.

See Projecting Entities into Value Objects for the full guide including round-trip conversion back to entities.

List

A field that represents a list of values.

Optional Arguments

  • content_type: The type of items in the list. It can be a simple field such as String, Integer, Float, Decimal, Boolean, Date, DateTime, Identifier, Text or Dict, or a ValueObject field (see List of Value Objects). Auto raises a ValidationError. Association fields such as HasMany and Reference are not supported. When you leave it out, the list accepts items of any type.
from protean import Domain
from protean.fields import List, String

domain = Domain()


@domain.aggregate
class User:
    email: String(max_length=255, required=True, unique=True)
    roles: List(content_type=String)

The value is provided as a list, and the values in the list are validated to be of the right type.

In [1]: user = User(email="john.doe@gmail.com", roles=['ADMIN', 'EDITOR'])

In [2]: user.to_dict()
Out[2]:
{'email': 'john.doe@gmail.com',
 'roles': ['ADMIN', 'EDITOR'],
 'id': '582d946b-409b-4b15-b3be-6a90284264b3',
 '_version': -1}

In [3]: user2 = User(email="jane.doe@gmail.com", roles=[1, 2])
...
ValidationError: {'roles': ['Input should be a valid string', 'Input should be a valid string']}

List of Value Objects

A List field can even hold a list of ValueObject instances. The content of the List will be persisted as a list of dicts, so the field will behave essentially like List(Dict()) when it comes to persistence. However, it will have the added benefit of a validation structure of content within the List.

from protean import Domain
from protean.fields import HasOne, List, String, ValueObject

domain = Domain()


@domain.value_object
class Address:
    street: String(max_length=100)
    city: String(max_length=25)
    state: String(max_length=25)
    country: String(max_length=25)


@domain.entity(part_of="Order")
class Customer:
    name: String(max_length=50, required=True)
    email: String(max_length=254, required=True)
    addresses: List(content_type=ValueObject(Address))


@domain.aggregate
class Order:
    customer = HasOne(Customer)
In [1]: order = Order(
   ...:         customer=Customer(
   ...:             name="John Doe",
   ...:             email="john@doe.com",
   ...:             addresses=[
   ...:                 Address(street="123 Main St", city="Anytown", state="CA", country="USA"),
   ...:                 Address(street="321 Side St", city="Anytown", state="CA", country="USA"),
   ...:             ],
   ...:         )
   ...:     )

In [2]: order.to_dict()
Out[2]:
{'id': '4a9538bf-1eb1-4621-8ced-86bcc4362a51',
 'customer': {'name': 'John Doe',
  'email': 'john@doe.com',
  'addresses': [{'street': '123 Main St',
    'city': 'Anytown',
    'state': 'CA',
    'country': 'USA'},
   {'street': '321 Side St',
    'city': 'Anytown',
    'state': 'CA',
    'country': 'USA'}],
  'id': 'f5c5a750-e9fe-47db-877e-44b7c0ca1dfc'},
 '_version': -1}

In [3]: domain.repository_for(Order).add(order)
Out[3]: <Order: Order object (id: 4a9538bf-1eb1-4621-8ced-86bcc4362a51)>

In [4]: retrieved_order = domain.repository_for(Order).get(order.id)

In [5]: len(retrieved_order.customer.addresses)
Out[5]: 2

To change the list, append to it or assign a new list, then save the aggregate through its repository. The change is stored only when you save.

In [6]: retrieved_order.customer.addresses.append(
   ...:     Address(street="456 Side St", city="Anytown", state="CA", country="USA")
   ...: )

In [7]: len(domain.repository_for(Order).get(order.id).customer.addresses)
Out[7]: 2

In [8]: domain.repository_for(Order).add(retrieved_order)
Out[8]: <Order: Order object (id: 4a9538bf-1eb1-4621-8ced-86bcc4362a51)>

In [9]: len(domain.repository_for(Order).get(order.id).customer.addresses)
Out[9]: 3

Dict

A field that represents a dictionary.

Optional Arguments

  • pickled: Whether the dict should be pickled when stored. Defaults to False.
from protean import Domain
from protean.fields import Dict, String

domain = Domain()


@domain.aggregate
class UserEvent:
    name: String(max_length=255)
    payload: Dict()

A regular dictionary can be supplied as value to payload:

In [1]: event=UserEvent(
   ...:     name="UserRegistered",
   ...:     payload={'name': 'John Doe', 'email': 'john.doe@example.com'}
   ...: )

In [2]: event.to_dict()
Out[2]:
{'name': 'UserRegistered',
 'payload': {'name': 'John Doe', 'email': 'john.doe@example.com'},
 'id': '44e9143f-f4a6-40da-9128-4b6c013420d4',
 '_version': -1}

Note

Some database implementations (like Postgresql) can store dicts as JSON by default. You can force it to store the pickled value as a Python object by specifying pickled=True. Databases that don’t support lists store the field as a python object.

Dict of Value Objects

A Dict field can hold a string-keyed code → value object map with value_type. Each value is a ValueObject instance, reconstructed and validated on load and serialized to a plain dict for storage (a single JSON column), just like a list of value objects:

from protean.fields import Dict, String, ValueObject


@domain.value_object
class Address:
    street: String(max_length=100)
    city: String(max_length=25)


@domain.aggregate
class Customer:
    name: String(max_length=50)
    addresses: Dict(value_type=ValueObject(Address))  # {"home": Address(...), ...}

The values must be value objects; use the untyped Dict() for loose JSON of primitives. Reach for this when modelling a small, embedded code → concept map (see the reference-data pattern).