Skip to content

Value Objects

DDD CQRS ES

Value Objects represent distinct domain concepts, with attributes, behavior and validations built into them. They don't have distinct identities, so they are identified by attributes values. They tend to act primarily as data containers, enclosing attributes of primitive types.

Defining a Value Object

Consider the example of an Email Address. You can treat a user's email as a plain string. The checks for a correct value (an email address) then go either in the lifecycle methods of the User class or in separate business logic in the services layer.

But an Email is more than just another string in the system. It has well-defined, explicit rules associated with it, like:

  • The presence of an @ symbol
  • A string with acceptable - characters (like . or _) before the @ symbol
  • A valid domain URL right after the @ symbol
  • The domain URL to be among the list of acceptable domains, if defined
  • A total length of less 255 characters

So it makes better sense to make Email a Value Object. It has a simple string representation to the outer world, but it keeps a distinct local_part (the part of the email address before @) and domain_part (the domain part of the address). Any value assignment must satisfy the domain rules listed above.

Below is a sample implementation of the Email concept as a Value Object:

from protean.domain import Domain
from protean.exceptions import ValidationError
from protean.fields import String, ValueObject

domain = Domain()


class EmailValidator:
    def __init__(self):
        self.error = "Invalid email address"

    def __call__(self, value):
        """Business rules of Email address"""
        if (
            # should contain one "@" symbol
            value.count("@") != 1
            or value.startswith(("@", "."))
            or value.endswith(("@", "."))
            or value in ["..", ".@", "@."]
            or len(value.split("@")[0]) > 64
            or any(len(label) > 63 for label in value.split("@")[1].split("."))
            or not all(
                label[0].isalnum()
                and label[-1].isalnum()
                and all(c.isalnum() or c == "-" for c in label)
                for label in value.split("@")[1].split(".")
            )
            or not all(c.isprintable() and not c.isspace() for c in value)
        ):
            raise ValidationError(self.error)


@domain.value_object
class Email:
    """An email address value object, with two identified parts:
    * local_part
    * domain_part
    """

    # This is the external facing data attribute
    address: String(max_length=254, required=True, validators=[EmailValidator()])


@domain.aggregate
class User:
    email = ValueObject(Email)
    name: String(max_length=30)
    timezone: String(max_length=30)

The complex validation logic of an email address is contained in a validator class attached to the Email Value Object. Assigning an invalid email address now raises a ValidationError.

In [1]: Email(address="john.doe@gmail.com")
Out[1]: <Email: Email object ({'address': 'john.doe@gmail.com'})>

In [2]: Email(address="john.doegmail.com")
...
ValidationError: {'address': ['Invalid email address']}

Email is now a Value Object that can be used across your application.

Note

This example was for illustration purposes only. It is better to validate an email address with regex.

Configuration

A value object's behavior can be customized by passing options to the @domain.value_object decorator.

abstract

Marks a value object as abstract if True. Abstract value objects cannot be instantiated and are meant to be subclassed. Useful for defining shared fields across multiple concrete value object types.

part_of

Associates the value object with a specific aggregate. While optional, setting part_of registers the value object within the aggregate's cluster and ensures it participates in the aggregate's validation lifecycle.

part_of takes the aggregate class or its name as a string. A string is resolved to the class at domain.init(), and it must name an aggregate. A name that matches no aggregate, such as a typo or an entity's name, makes domain.init() raise ConfigurationError.

Embedding Value Objects

Value Objects can be embedded into Aggregates and Entities with the ValueObject field:

from protean.domain import Domain
from protean.exceptions import ValidationError
from protean.fields import String, ValueObject

domain = Domain()


class EmailValidator:
    def __init__(self):
        self.error = "Invalid email address"

    def __call__(self, value):
        """Business rules of Email address"""
        if (
            # should contain one "@" symbol
            value.count("@") != 1
            or value.startswith(("@", "."))
            or value.endswith(("@", "."))
            or value in ["..", ".@", "@."]
            or len(value.split("@")[0]) > 64
            or any(len(label) > 63 for label in value.split("@")[1].split("."))
            or not all(
                label[0].isalnum()
                and label[-1].isalnum()
                and all(c.isalnum() or c == "-" for c in label)
                for label in value.split("@")[1].split(".")
            )
            or not all(c.isprintable() and not c.isspace() for c in value)
        ):
            raise ValidationError(self.error)


@domain.value_object
class Email:
    """An email address value object, with two identified parts:
    * local_part
    * domain_part
    """

    # This is the external facing data attribute
    address: String(max_length=254, required=True, validators=[EmailValidator()])


@domain.aggregate
class User:
    email = ValueObject(Email)
    name: String(max_length=30)
    timezone: String(max_length=30)

Note

You can also specify a Value Object's class name as input to the ValueObject field, which will be resolved when the domain is initialized. This can help avoid the problem of circular references.

@domain.aggregate
class User:
    email = ValueObject("Email")
    name: String(max_length=30)
    timezone: String(max_length=30)

You can supply an email address when you create a user object. The Email value object runs its own validations.

...
In [1]: user = User(
   ...:     email_address='john.doe@gmail.com',
   ...:     name='John Doe',
   ...:     timezone='America/Los_Angeles'
   ...: )

In [2]: user.to_dict()
Out[2]:
{'name': 'John Doe',
 'timezone': 'America/Los_Angeles',
 'id': '9b03b7ff-ccfa-41f8-9467-b98588aa4302',
 'email': {'address': 'john.doe@gmail.com'},
 '_version': -1}

Supplying an invalid email address throws a ValidationError:

In [3]: User(
   ...:     email_address='john.doegmail.com',
   ...:     name='John Doe',
   ...:     timezone='America/Los_Angeles'
   ...: )
ValidationError: {'address': ['Invalid email address']}

Assigning Values

Value Objects are typically initialized along with the enclosing entity.

from protean.domain import Domain
from protean.fields import Decimal, 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 decimal value
    """

    currency: String(max_length=3, required=True)
    amount: Decimal(precision=19, scale=4, required=True, min_value=0)


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

Balance holds money, so amount is a Decimal field. The examples import Python's decimal.Decimal as D so it does not clash with the field of the same name: from decimal import Decimal as D.

You assign a value by passing a Balance object:

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

In [2]: account.to_dict()
Out[2]:
{'name': 'Checking',
 'id': '74731f8b-a58e-4666-858b-b2e57e42ce68',
 'balance': {'currency': 'USD', 'amount': '100.00'},
 '_version': -1}

It is also possible to initialize a Value Object by its attributes:

...
In [1]: account = Account(
   ...:     balance_currency="USD",
   ...:     balance_amount=D("100.00"),
   ...:     name="Checking"
   ...:     )

In [2]: account.to_dict()
Out[2]:
{'name': 'Checking',
 'id': 'a41a0ac9-9e6d-4300-96e3-054c70201e51',
 'balance': {'currency': 'USD', 'amount': '100.00'},
 '_version': -1}

The attribute names are a combination of the field name defined in Account class (balance) and the field names defined in the Balance Value Object (currency and amount).

The Account object is the same in every way in either case. But you can assign by attributes only when you initialize an entity. You cannot update an attribute value directly after initialization, because Value Objects are immutable: they cannot be changed once initialized. Read more in Immutability section.

Replacing the whole Value Object, rather than editing its attributes, also keeps every invariant satisfied at all times.

Note

In your own code, create and pass Value Objects by their class. Attributes are generally used by Protean during persistence and retrieval.

Nested Value Objects

Value objects can be composed of other value objects, forming richer domain concepts:

@domain.value_object
class GeoLocation:
    latitude: Float(required=True)
    longitude: Float(required=True)


@domain.value_object
class Address:
    street: String(max_length=200)
    city: String(max_length=100)
    zip_code: String(max_length=10)
    location = ValueObject(GeoLocation)

When a value object is embedded in an aggregate, its fields are flattened one level for persistence. Each column name joins the aggregate's field name and the value object's field name with an underscore. A nested value object is not flattened further: it is stored whole in one column.

Aggregate field VO field Database column
address street address_street
address city address_city
address zip_code address_zip_code
address location address_location (the whole GeoLocation)

Build a nested value object and pass it in:

@domain.aggregate
class Store:
    name: String(max_length=100)
    address = ValueObject(Address)


domain.init(traverse=False)

with domain.domain_context():
    store = Store(
        name="Downtown",
        address=Address(
            street="123 Main St",
            city="Springfield",
            zip_code="62701",
            location=GeoLocation(latitude=39.78, longitude=-89.65),
        ),
    )
    assert store.address.location.latitude == 39.78

The aggregate also accepts the flattened names from the table, one level deep. A nested value object is still passed as an object. Deeper names such as address_location_latitude are rejected with a ValidationError:

with domain.domain_context():
    store = Store(
        name="Downtown",
        address_street="123 Main St",
        address_city="Springfield",
        address_zip_code="62701",
        address_location=GeoLocation(latitude=39.78, longitude=-89.65),
    )
    assert store.address.city == "Springfield"

Dict-Based Initialization

Value objects can be initialized from dictionaries, which is especially useful when receiving data from APIs or external sources:

from decimal import Decimal as D

with domain.domain_context():
    account = Account(
        balance={"currency": "USD", "amount": D("100.00")},
        name="Checking",
    )
    # Protean converts the dict to a Balance value object
    assert account.balance == Balance(currency="USD", amount=D("100.00"))

Dict-based initialization also works for nested value objects: Protean automatically converts any dict that matches the value object's field structure.

Invariants

When a validation spans across multiple fields, you can specify it in an invariant method. These methods are executed every time the value object is initialized.

from protean import Domain, invariant
from protean.exceptions import ValidationError
from protean.fields import Decimal, String

domain = Domain()


@domain.value_object
class Balance:
    currency: String(max_length=3, required=True)
    amount: Decimal(precision=19, scale=4, required=True)

    @invariant.post
    def check_balance_is_positive_if_currency_is_USD(self):
        if self.amount < 0 and self.currency == "USD":
            raise ValidationError({"balance": ["Balance cannot be negative for USD"]})
In [1]: Balance(currency="USD", amount=D("-100.00"))
...
ValidationError: {'balance': ['Balance cannot be negative for USD']}

Field Validators vs. Invariants

Protean offers two ways to validate value object data:

  • Field-level validators: Callable validators attached to individual fields (e.g. validators=[EmailValidator()]). Use these for single-field format validation, "is this a valid email?" or "is this a valid phone number?"
  • @invariant.post methods: Cross-field business rules that span multiple attributes (e.g. "balance cannot be negative for USD"). Use these when validation depends on the combination of two or more fields.

As a rule of thumb: if the rule involves only one field, use a field validator; if it involves multiple fields, use an invariant.

Refer to the Invariants guide for a deeper explanation, and Validation Layering for the overall strategy.

The defaults() Hook

Override the defaults() method when a value object attribute's default depends on other attribute values:

@domain.value_object
class Duration:
    start: DateTime(required=True)
    end: DateTime(required=True)
    total_seconds: Float()

    def defaults(self):
        if self.total_seconds is None and self.start and self.end:
            self.total_seconds = (self.end - self.start).total_seconds()

defaults() runs during initialization, after all field values have been set but before invariants are checked.

Equality

Two value objects are considered to be equal if their values are equal.

from protean import Domain
from protean.fields import Decimal, String

domain = Domain()


@domain.value_object
class Balance:
    currency: String(max_length=3, required=True)
    amount: Decimal(precision=19, scale=4, required=True)
In [1]: bal1 = Balance(currency='USD', amount=D('100.00'))

In [2]: bal2 = Balance(currency='USD', amount=D('100.00'))

In [3]: bal3 = Balance(currency='CAD', amount=D('100.00'))

In [4]: bal1 == bal2
Out[4]: True

In [5]: bal1 == bal3
Out[5]: False

Value objects compare and hash their field values, so D('100.0') and D('100.00') count as the same amount. A Balance holding one equals a Balance holding the other, and both hash the same. to_dict() still keeps each amount as it was given, so the two render as "100.0" and "100.00". A value object never equals a value object of a different class, including a subclass, even when the field values match.

Identity

Unlike Aggregates and Entities, Value Objects do not have any inbuilt concept of unique identities. This allows two instances of value objects to be swapped or even be replaced by a single object instance.

Because Value Objects have no unique identity, no functionality related to identity or uniqueness applies to them.

For example, when you mark a Value Object field with unique = True or identifier = True, Protean raises an IncorrectUsageError exception.

In [1]: @domain.value_object
   ...: class Balance:
   ...:     currency = String(max_length=3, unique=True)
   ...:     amount = Decimal(precision=19, scale=4)
...
IncorrectUsageError: "Value Objects cannot contain fields marked 'unique' (field 'currency')"

A Value Object also has no id_field. Asking for it returns None:

In [4]: from protean.utils.reflection import id_field

In [5]: id_field(Balance) is None
Out[5]: True

Immutability

A Value Object cannot be altered once initialized. Trying to do so raises an IncorrectUsageError.

In [1]: bal1 = Balance(currency='USD', amount=D('100.00'))

In [2]: bal1.currency = "CAD"
...
IncorrectUsageError: "Value Objects are immutable and cannot be modified once created"

Replacing Fields

Since value objects are immutable, you cannot modify them after creation. Instead, use replace() to create a new instance with selected fields changed, similar to dataclasses.replace():

from decimal import Decimal as D

balance = Balance(currency="USD", amount=D("100.00"))
updated = balance.replace(amount=D("200.00"))

assert updated.amount == D("200.00")
assert updated.currency == "USD"  # unchanged fields are preserved
assert balance.amount == D("100.00")  # original is not modified

replace() copies all current field values, overlays the provided keyword arguments, and constructs a new instance of the same class. Invariants are re-validated on the new instance, so invalid replacements are rejected. Here Balance is the version from Invariants, which rejects negative USD amounts:

from protean import Domain, invariant
from protean.exceptions import ValidationError
from protean.fields import Decimal, String

domain = Domain()


@domain.value_object
class Balance:
    currency: String(max_length=3, required=True)
    amount: Decimal(precision=19, scale=4, required=True)

    @invariant.post
    def check_balance_is_positive_if_currency_is_USD(self):
        if self.amount < 0 and self.currency == "USD":
            raise ValidationError({"balance": ["Balance cannot be negative for USD"]})



balance = Balance(currency="USD", amount=D("100.00"))

try:
    balance.replace(amount=D("-100.00"))
except ValidationError as exc:
    print(exc.messages)  # {'balance': ['Balance cannot be negative for USD']}

Passing field=None explicitly sets the field to None. It does not keep the old value. Only omitted fields retain their original values:

@domain.value_object
class Profile:
    name: String(max_length=50, required=True)
    nickname: String(max_length=50)


profile = Profile(name="Alice", nickname="Ali")
updated = profile.replace(nickname=None)

assert updated.nickname is None  # explicitly set to None

Unknown field names raise IncorrectUsageError:

from protean.exceptions import IncorrectUsageError

try:
    balance.replace(nonexistent=42)
except IncorrectUsageError as exc:
    print(exc)  # Unknown field(s) for Balance: nonexistent

replace() also works with nested value objects. Pass a new value object instance for the nested field, or omit it to preserve the original.

Hashability

Because value objects are immutable and define equality by their attributes, they are hashable by default. This means you can use them as dictionary keys or in sets:

prices = {
    Balance(currency="USD", amount=D("9.99")): "budget",
    Balance(currency="USD", amount=D("99.99")): "premium",
}

unique_emails = {Email(address="a@b.com"), Email(address="c@d.com")}

Projecting Entities into Value Objects

When building commands and events, you often need a value object that mirrors an entity's fields, for example, to carry OrderItem data in a PlaceOrder command. Manually duplicating the fields is tedious and error-prone. Protean provides value_object_from_entity() to auto-generate the VO class:

from protean import value_object_from_entity
from protean.fields import List


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


@domain.entity(part_of=Order)
class OrderItem:
    product_name: String(max_length=100)
    quantity: Integer()
    unit_price: Decimal(precision=19, scale=4)
    internal_notes: String(max_length=500)


# Auto-generate a VO mirroring OrderItem's fields
OrderItemVO = value_object_from_entity(OrderItem)

The generated OrderItemVO has the same fields as OrderItem, with these adjustments:

  • Identity fields become optional: Identifier and unique fields are made optional with None defaults, since identity is not a value concern.
  • Reference fields are excluded: Foreign key references are infrastructure concerns, not domain values.
  • HasOne/HasMany associations are recursively converted: Child entities become nested value objects or lists of value objects.
  • Private fields (prefixed with _) are skipped.
  • Length and range limits are not copied: each field keeps its type, choices, required flag, and default, but limits such as max_length and min_value are left behind.

You can customize the generated class name and exclude specific fields:

OrderItemVO = value_object_from_entity(
    OrderItem,
    name="OrderItemPayload",
    exclude={"internal_notes"},
)

Inline field descriptor

For inline use in commands and events, use the ValueObjectFromEntity field descriptor instead of calling value_object_from_entity() separately:

from protean.fields import ValueObjectFromEntity


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

This derives the VO class from the entity at class-body evaluation time, no separate variable needed.

Round-trip: VO back to Entity

To convert a value object back into an entity instance (e.g., in a command handler), use the from_value_object() classmethod on the entity:

@domain.command_handler(part_of=Order)
class PlaceOrderHandler:
    @handle(PlaceOrder)
    def handle_place_order(self, command: PlaceOrder):
        items = [OrderItem.from_value_object(item) for item in command.items]
        order = Order(customer_id=command.customer_id, items=items)
        return order

from_value_object() calls vo.to_dict(), strips None values for identifier/unique fields (so auto-generated defaults kick in), and constructs the entity. This is the inverse of value_object_from_entity().

Fact event refactoring

Protean's fact event generation internally uses value_object_from_entity() to convert entity associations in aggregates. If your aggregate has fact_events=True and contains HasOne/HasMany entity associations, the generated fact event automatically includes value object projections of those entities.

Common Errors

Exception When it occurs
ValidationError Field validation fails during construction (e.g. missing required field, invalid format). Contains a messages dict.
ValidationError An @invariant.post check raises a validation error (e.g. "balance cannot be negative for USD").
IncorrectUsageError Trying to modify a value object attribute after creation (value objects are immutable). Use replace() instead.
IncorrectUsageError Passing an unknown field name to replace().
IncorrectUsageError Defining a field with unique=True or identifier=True, value objects have no concept of identity.

See also

Concept overview: Value Objects: Immutable objects defined by their attributes, not identity.

Decision guidance: Choosing Element Types: When to use a value object vs. an entity.

Patterns: