Skip to content

Common Arguments

Applies to: DDD · CQRS · Event Sourcing

description

A long form description of the field. This value can be used by database adapters to provide additional context to a field.

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

domain = Domain()


@domain.aggregate
class Building:
    permit: List(
        content_type=String, description="Licences and Approvals", required=True
    )

required

Indicates if the field is required (must have a value). If True, the field is not allowed to be blank. Default is False.

from protean import Domain
from protean.fields import String

domain = Domain()


@domain.aggregate
class Person:
    name: String(required=True)

Leaving the field blank or not specifying a value will raise a ValidationError:

In [1]: p = Person()
...
ValidationError: {'name': ['is required']}

identifier

If True, the field is an identifier for the entity (a la primary key in RDBMS).

from protean import Domain
from protean.fields import String

domain = Domain()


@domain.aggregate
class Person:
    email: String(identifier=True)
    name: String(required=True)

The field is validated to be unique and non-blank:

In [1]: from protean.utils.reflection import declared_fields

In [2]: p = Person(email='john.doe@example.com', name='John Doe')

In [3]: declared_fields(p)["email"].identifier
Out[3]: True

In [4]: p = Person(name='John Doe')
...
ValidationError: {'email': ['is required']}

Aggregates and Entities need at least one field to be marked as an identifier. If you don’t specify one, Protean will automatically add a field called id to act as the primary identifier. This means that you don’t need to explicitly set identifier=True on any of your fields unless you want to override the default behavior or the name of the field.

Alternatively, you can use Identifier field type for primary identifier fields.

By default, Protean dynamically generates UUIDs as values of identifier fields unless explicitly provided. You can customize the type of value accepted with identity-strategy config parameter. More details are in Configuration section.

default

The default value for the field if no value is provided.

This can be a value or a callable object. If callable, the function will be called every time a new object is created.

from datetime import UTC, datetime

from protean.domain import Domain
from protean.fields import DateTime, String

publishing = Domain(__name__)


def utc_now():
    return datetime.now(UTC)


@publishing.aggregate
class Post:
    title: String(max_length=50)
    created_at: DateTime(default=utc_now)
In [1]: post = Post(title='Foo')

In [2]: post.to_dict()
Out[2]:
{'title': 'Foo',
 'created_at': '2024-05-09T00:58:10.781744+00:00',
 'id': '4f6b1fef-bc60-44c2-9ba6-6f844e0d31b0',
 '_version': -1}

Mutable object defaults

Protean copies a list or dict default for each instance, so default=[] and default={} are safe. The copy is shallow: objects nested inside the list or dict are still shared between instances. When the default holds mutable objects, wrap it in a callable that builds a new one each time.

For example, to give a List field a default list of topics, use a function:

from protean.domain import Domain
from protean.fields import List, String

domain = Domain(__name__)


def standard_topics():
    return ["Music", "Cinema", "Politics"]


@domain.aggregate
class Adult:
    name: String(max_length=255)
    topics: List(default=standard_topics)

Initializing an Adult aggregate will populate the defaults correctly:

In [1]: adult = Adult(name="John Doe")

In [2]: adult.to_dict()
Out[2]:
{'name': 'John Doe',
 'topics': ['Music', 'Cinema', 'Politics'],
 'id': '14381a6f-b62a-4135-a1d7-d50f68e2afba',
 '_version': -1}

Lambda expressions

You can use lambda expressions to specify an anonymous function:

import random

from protean.domain import Domain
from protean.fields import Integer

domain = Domain(__name__)

dice_sides = [4, 6, 8, 10, 12, 20]


@domain.aggregate
class Dice:
    sides: Integer(default=lambda: random.choice(dice_sides))

    def throw(self):
        return random.randint(1, self.sides)
In [1]: dice = Dice()

In [2]: dice.to_dict()
Out[2]: {'sides': 6, 'id': '0536ade5-f3a4-4e94-8139-8024756659a7', '_version': -1}

In [3]: dice.throw()
Out[3]: 3

This is a great option when you want to pass parameters to a function.

unique

Indicates if the field values must be unique within the repository. If True, this field's value is validated to be unique among all entities.

from protean import Domain
from protean.fields import String

domain = Domain()


@domain.aggregate
class Person:
    name: String(required=True)
    email: String(unique=True)

Obviously, this field's integrity is enforced at the database layer when an entity is persisted. If an entity instance specifies a duplicate value in a field marked unique, a ValidationError will be raised:

In [1]: p1 = Person(name='John Doe', email='john.doe@example.com')

In [2]: domain.repository_for(Person).add(p1)
Out[2]: <Person: Person object (id: b2c592d5-bd78-4e1e-a9d1-eea20ab5374a)>

In [3]: p2 = Person(name= 'Jane Doe', email='john.doe@example.com')

In [4]: domain.repository_for(Person).add(p2)
ERROR: Failed saving entity because {'email': ["Person with email 'john.doe@example.com' is already present."]}
...
ValidationError: {'email': ["Person with email 'john.doe@example.com' is already present."]}

We will explore more about persistence in Application Layer guide.

choices

A set of allowed choices for the field value. When supplied as an Enum, the value of the field is validated to be one among the specified options.

from enum import Enum

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

domain = Domain()


class BuildingStatus(Enum):
    WIP = "WIP"
    DONE = "DONE"


@domain.aggregate
class Building:
    name: String(max_length=50)
    floors: Integer()
    status: String(choices=BuildingStatus)

The choices are enforced when the field is initialized or updated:

In [1]: building = Building(name="Atlantis", floors=3, status="WIP")

In [2]: building.to_dict()
Out[2]:
{'name': 'Atlantis',
 'floors': 3,
 'status': 'WIP',
 'id': 'c803c763-32d7-403f-b432-8835a258430e',
 '_version': -1}

In [3]: building.status = "COMPLETED"
...
ValidationError: {'status': ["Input should be 'WIP' or 'DONE'"]}

referenced_as

The name of the field as referenced in the database or external systems. Defaults to the field's name.

from protean import Domain
from protean.fields import String

domain = Domain()


@domain.aggregate
class Person:
    email: String(unique=True)
    name: String(referenced_as="fullname", required=True)

Protean will now persist the value under fullname instead of name.

In [1]: from protean.utils.reflection import declared_fields, attributes

In [2]: list(declared_fields(Person))
Out[2]: ['email', 'name', 'id']

In [3]: list(attributes(Person))
Out[3]: ['email', 'fullname', 'id', '_version']

validators

Additional validators to apply to the field value.

Validators are callable Class instances that are invoked whenever a field's value is changed. Protean's String field, for example, has two default validators: MinLengthValidator and MaxLengthValidator classes associated with min_length and max_length attributes.

from typing import Any

from protean import Domain
from protean.exceptions import ValidationError
from protean.fields import String

domain = Domain()


class EmailDomainValidator:
    def __init__(self, domain="example.com"):
        self.domain = domain
        self.message = f"Email does not belong to {self.domain}"

    def __call__(self, value: str) -> Any:
        if not value.endswith(self.domain):
            raise ValidationError(self.message)


@domain.aggregate
class Employee:
    email: String(identifier=True, validators=[EmailDomainValidator("mydomain.com")])

If the value fails to satisfy the validation, a ValidationError will be thrown with the custom error message.

In [1]: e = Employee(email="john@mydomain.com")

In [2]: e.to_dict()
Out[2]: {'email': 'john@mydomain.com', '_version': -1}

In [3]: e2 = Employee(email="john@otherdomain.com")
...
ValidationError: {'email': ['Email does not belong to mydomain.com']}

error_messages

A dictionary of custom error messages, keyed by error kind such as required, invalid, unique, or invalid_choice. The field accepts the argument and records it, but the simple fields in protean.fields do not use it yet: a validation error still reports the default message.

from protean import Domain
from protean.fields import Integer

domain = Domain()


@domain.aggregate
class Building:
    doors: Integer(
        required=True, error_messages={"required": "Every building needs some!"}
    )

Leaving doors out still raises the default message, not the custom one:

In [1]: Building()
...
ValidationError: {'doors': ['is required']}

renamed_from

The old name (or a list of old names) this field was renamed from. Declaring a rename lets a stored payload written under the old key deserialize into the renamed field without an upcaster.

@domain.aggregate
class Order:
    order_id = Identifier(identifier=True)


@domain.event(part_of=Order)
class OrderPlaced:
    order_id = Identifier(identifier=True)
    customer_name = String(renamed_from="name")  # single old name
    total = Float(renamed_from=["amount", "sum"])  # or a list of aliases

A stored OrderPlaced payload carrying the old name key loads into customer_name; the current name always wins if both are present, and any stale alias keys are dropped. The rename is also emitted into the IR, so protean ir diff reports a single safe field_renamed change instead of a breaking remove + add, and the generated Avro schema carries an aliases entry so an external Avro consumer resolves the old name on the wire. See Evolving Events Over Time for the end-to-end workflow and Event Versioning and Evolution for the strategies behind it.