Skip to content

BaseValueObject

Base class for value objects -- immutable domain elements without identity, defined entirely by their attributes. Two value objects with the same attributes are considered equal.

Key methods:

  • replace(**kwargs) -- Create a copy with selected fields changed (see Replacing Fields)
  • to_dict() -- Return field values as a dictionary
  • defaults() -- Override to set computed defaults during initialization

See Value Objects guide for practical usage and Value Objects concept for design rationale.

Bases: BaseModel, OptionsMixin

Base class for value objects -- immutable domain elements without identity, defined entirely by their attributes.

Two value objects with the same attribute values are considered equal. Value objects are always embedded within aggregates or entities and cannot exist independently. They become immutable after construction -- any attempt to modify an attribute raises IncorrectUsageError.

Fields are declared using standard Python type annotations with optional Field constraints. Value objects cannot contain identifier or unique fields, and cannot have associations (HasOne, HasMany).

Supports @invariant.post decorators for validation rules that are checked after construction.

Source code in src/protean/core/value_object.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def __init__(self, *args: Any, **kwargs: Any) -> None:
    # Support template dict pattern: VO({"key": "val"}, key2="val2")
    # Keyword args take precedence over template dict values.
    if args:
        merged: dict[str, Any] = {}
        for template in args:
            if not isinstance(template, dict):
                raise AssertionError(
                    f"Positional argument {template} passed must be a dict. "
                    f"This argument serves as a template for loading common "
                    f"values.",
                )
            merged.update(template)
        merged.update(kwargs)
        kwargs = merged
    try:
        super().__init__(**kwargs)
    except PydanticValidationError as e:
        raise ValidationError(convert_pydantic_errors(e))

defaults

defaults() -> None

Placeholder for defaults. Override in subclass when an attribute's default depends on other attribute values.

Source code in src/protean/core/value_object.py
236
237
238
def defaults(self) -> None:
    """Placeholder for defaults. Override in subclass when
    an attribute's default depends on other attribute values."""

replace

replace(**kwargs: Any) -> Self

Return a new value object with specified fields replaced.

Similar to dataclasses.replace() — copies all current field values, overlays the provided kwargs, and constructs a new instance of the same class. Invariants are re-validated on the new instance.

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

Raises IncorrectUsageError if any key in kwargs is not a declared field.

Source code in src/protean/core/value_object.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
def replace(self, **kwargs: Any) -> Self:
    """Return a new value object with specified fields replaced.

    Similar to ``dataclasses.replace()`` — copies all current field values,
    overlays the provided *kwargs*, and constructs a new instance of the
    same class.  Invariants are re-validated on the new instance.

    Passing ``field=None`` explicitly sets the field to ``None`` (it does
    not keep the old value).  Only omitted fields retain their original
    values.

    Raises ``IncorrectUsageError`` if any key in *kwargs* is not a
    declared field.
    """
    fields = getattr(self, _FIELDS, {})
    unknown = set(kwargs) - set(fields)
    if unknown:
        raise IncorrectUsageError(
            f"Unknown field(s) for {type(self).__name__}: "
            + ", ".join(sorted(unknown))
        )

    new_data: dict[str, Any] = {}
    for fname in fields:
        new_data[fname] = kwargs.get(fname, getattr(self, fname))

    return type(self)(**new_data)

to_dict

to_dict() -> dict[str, Any]

Return data as a dictionary.

Source code in src/protean/core/value_object.py
289
290
291
292
293
294
def to_dict(self) -> dict[str, Any]:
    """Return data as a dictionary."""
    result: dict[str, Any] = {}
    for fname, shim in getattr(self, _FIELDS, {}).items():
        result[fname] = shim.as_dict(getattr(self, fname, None))
    return result

value_object_from_entity

Create a BaseValueObject subclass whose fields mirror entity_cls.

This eliminates the boilerplate of manually duplicating an entity's fields into a value object for use in commands and events.

PARAMETER DESCRIPTION
entity_cls

The entity (or aggregate) class to project.

TYPE: type

name

Override the generated class name (default: {EntityName}ValueObject).

TYPE: str | None DEFAULT: None

exclude

Field names to omit from the generated value object.

TYPE: set[str] | None DEFAULT: None

RETURNS DESCRIPTION
type[BaseValueObject]

A new BaseValueObject subclass.

Source code in src/protean/core/value_object.py
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
def value_object_from_entity(
    entity_cls: type,
    name: str | None = None,
    exclude: set[str] | None = None,
) -> type[BaseValueObject]:
    """Create a ``BaseValueObject`` subclass whose fields mirror *entity_cls*.

    This eliminates the boilerplate of manually duplicating an entity's fields
    into a value object for use in commands and events.

    Args:
        entity_cls: The entity (or aggregate) class to project.
        name: Override the generated class name (default: ``{EntityName}ValueObject``).
        exclude: Field names to omit from the generated value object.

    Returns:
        A new ``BaseValueObject`` subclass.
    """
    from protean.fields.basic import ValueObjectList

    exclude = exclude or set()
    vo_name = name or f"{entity_cls.__name__}ValueObject"

    annotations: dict[str, Any] = {}
    namespace: dict[str, Any] = {}
    # ValueObjectList is lazily imported above; use Any in the annotation
    # to avoid referencing it at module-import time.
    association_descriptors: dict[str, Any] = {}
    model_field_info = getattr(entity_cls, "model_fields", {})

    for key, value in get_fields(entity_cls).items():
        if key in exclude:
            continue
        if isinstance(value, Reference):
            continue
        if key.startswith("_"):
            continue

        if isinstance(value, HasOne):
            # Recursively convert associated entity to VO
            child_vo_cls = value_object_from_entity(value.to_cls)
            vo_descriptor = ValueObjectField(value_object_cls=child_vo_cls)
            annotations[key] = Optional[child_vo_cls]
            namespace[key] = None
            association_descriptors[key] = vo_descriptor

        elif isinstance(value, HasMany):
            # Recursively convert to list of VOs
            child_vo_cls = value_object_from_entity(value.to_cls)
            vo_descriptor = ValueObjectField(value_object_cls=child_vo_cls)
            list_descriptor = ValueObjectList(content_type=vo_descriptor)
            annotations[key] = list[child_vo_cls]
            namespace[key] = PydanticField(default_factory=list)
            association_descriptors[key] = list_descriptor

        elif isinstance(value, ValueObjectField):
            vo_cls = value.value_object_cls
            # Preserve the required flag from the original descriptor
            if getattr(value, "required", False):
                annotations[key] = vo_cls
            else:
                annotations[key] = Optional[vo_cls]
                namespace[key] = None
            association_descriptors[key] = value

        elif isinstance(value, ResolvedField):
            finfo = model_field_info.get(key)
            if finfo:
                annotations[key] = finfo.annotation
                if finfo.default is not PydanticUndefined:
                    namespace[key] = finfo.default
                elif finfo.default_factory is not None:
                    namespace[key] = PydanticField(
                        default_factory=finfo.default_factory
                    )

    # Make identifier/unique fields optional (they are identity concerns,
    # not value concerns).
    container_fields = get_fields(entity_cls)
    for key in list(annotations.keys()):
        field_obj = container_fields.get(key)
        if isinstance(field_obj, ResolvedField) and (
            field_obj.identifier or field_obj.unique
        ):
            annotations[key] = annotations[key] | None
            namespace[key] = None

    ns = {"__annotations__": annotations, **namespace}
    value_object_cls = type(vo_name, (BaseValueObject,), ns)

    # Inject association descriptors into __container_fields__
    cf = getattr(value_object_cls, _FIELDS, {})
    cf.update(association_descriptors)
    setattr(value_object_cls, _FIELDS, cf)

    return value_object_cls