Fields
DDD CQRS ES
Fields are the attributes you declare on aggregates, entities, value objects, commands, and events. They define what data an element carries, which values are valid, how defaults are supplied, and how relationships are expressed.
This guide walks through the everyday task of putting fields on a domain element: picking the right type, making them required, giving them defaults, constraining values, and hooking up relationships. For the full list of field types and every argument they accept, see the Fields Reference.
Declaring a field
Fields are declared inside a domain element using one of Protean's field
functions — String, Integer, Float, DateTime, List, and so on.
The recommended form is an annotation:
from protean import Domain
from protean.fields import String, Float, DateTime
domain = Domain()
@domain.aggregate
class Product:
name: String(max_length=100, required=True)
price: Float(min_value=0)
created_at: DateTime(default="utc_now")
You can also assign fields as class attributes (name = String(...)) — the
two styles are interchangeable and can be mixed freely. A third escape
hatch lets you use raw Pydantic annotations when you need more control.
See Defining Fields for the
differences between styles and a caveat about
from __future__ import annotations.
Choosing a field type
Protean groups field types into three families. Pick from the family that matches the shape of the data you're modeling:
| You need to store... | Use a... |
|---|---|
| A primitive value (string, number, date, boolean, id) | Simple field |
| A list, dict, or embedded value object | Container field |
| A relationship to another entity or aggregate | Association field |
A typical aggregate uses all three:
from protean.fields import String, Float, DateTime, List, HasMany
@domain.aggregate
class Order:
customer_name: String(max_length=100, required=True) # simple
placed_at: DateTime(default="utc_now") # simple
tags: List(content_type=String) # container
items = HasMany("LineItem") # association
Lifecycle state gets its own type — use Status
when the value must move through a defined state machine. See the
Status Transitions guide for
patterns like atomic_change and event-sourced flows.
Making a field required
By default, every field is optional. Mark a field required=True to
reject construction when the value is missing or blank:
@domain.aggregate
class Customer:
email: String(required=True)
name: String(max_length=100)
Attempting to build a Customer without an email raises a
ValidationError with a messages dict pointing at the offending field:
In [1]: Customer(name="Jane Doe")
...
ValidationError: {'email': ['is required']}
Identifier fields are an exception — they are auto-generated when you don't provide one. See Identity for how identity generation is configured.
Setting defaults
Use default for a literal value or a callable that produces one. Call
the callable (don't invoke it yourself) so Protean can evaluate it at
construction time:
from datetime import datetime, timezone
def _utc_now():
return datetime.now(timezone.utc)
@domain.aggregate
class ShoppingCart:
created_at: DateTime(default=_utc_now)
currency: String(default="USD")
Don't use mutable defaults
Passing a list, dict, set, or entity instance as default= shares a
single object across every instance of the aggregate. Wrap the value
in a callable instead:
# Wrong — every Customer shares the same list
tags: List(default=[])
# Right — each Customer gets a fresh list
tags: List(default=list)
When a default needs to reference other field values (e.g. total =
subtotal * (1 + tax_rate)), override the aggregate's
defaults() hook rather than
computing the value in default=.
Constraining values
Most field types accept constraints as arguments. The common ones:
from protean.fields import String, Integer, Float
@domain.aggregate
class Listing:
title: String(max_length=200, min_length=3) # length bounds
priority: Integer(min_value=1, max_value=5) # numeric bounds
discount: Float(min_value=0, max_value=1)
status: String(choices=["DRAFT", "PUBLISHED", "SOLD"]) # enumerated
choices also accepts an Enum class, which is the preferred form when
the set of values is reused across the domain. See the
Arguments reference for the full
list of options.
Enforcing uniqueness
Add unique=True to require that no two aggregates share the same value
for a field. Uniqueness is enforced by the underlying persistence store
when the aggregate is saved:
@domain.aggregate
class User:
email: String(required=True, unique=True)
name: String(max_length=100)
Adding custom validation
For single-field format checks that go beyond length or numeric bounds —
email addresses, phone numbers, SKUs — pass a callable to validators:
from protean.fields import String
from protean.exceptions import ValidationError
class EmailDomainValidator:
def __init__(self, allowed_domain: str):
self.allowed_domain = allowed_domain
def __call__(self, value: str) -> None:
if not value.endswith(f"@{self.allowed_domain}"):
raise ValidationError(
f"Email does not belong to {self.allowed_domain}"
)
@domain.aggregate
class Employee:
email: String(validators=[EmailDomainValidator("mydomain.com")])
Validators run on every assignment and raise ValidationError if the
value is rejected.
Single field vs. cross-field rules
Use validators when the rule involves one field in isolation.
When a rule spans multiple fields — "shipping date must be after
order date", "balance cannot go negative for USD" — express it as
an invariant on the aggregate or
value object instead.
Expressing relationships
Associations connect aggregates to the entities they own and let entities reference their parent aggregate. Use the association field that matches the cardinality you need:
HasOne— the aggregate owns exactly one child entity.HasMany— the aggregate owns zero or more child entities.Reference— the inverse link from a child entity back to its aggregate (Protean adds this automatically when you defineHasOneorHasMany).
from protean.fields import HasMany
@domain.aggregate
class Post:
title: String(max_length=200, required=True)
comments = HasMany("Comment")
@domain.entity(part_of=Post)
class Comment:
content: String(max_length=500)
Aggregates never reference each other directly — cross-aggregate links
are modeled as Identifier fields. See
Expressing Relationships for the full treatment,
including bidirectional navigation, via, and shadow fields.
Embedding value objects
When a group of fields always travels together and represents a single
concept (money, an address, a coordinate), promote them to a
value object and embed it with ValueObject:
from protean.fields import ValueObject
@domain.value_object
class Money:
currency: String(max_length=3, required=True)
amount: Float(min_value=0, required=True)
@domain.aggregate
class Account:
owner: String(max_length=100, required=True)
balance = ValueObject(Money)
You can hand in a Money(...) instance or the flattened attributes
(balance_currency, balance_amount) — Protean reconstitutes the VO in
either case.
Mapping to a different storage name
Use referenced_as when the persisted column or document key needs to
differ from the Python attribute name — typically when matching an
existing database schema:
@domain.aggregate
class Person:
name: String(required=True, referenced_as="full_name")
email: String()
The attribute on the aggregate stays name, but the persisted field is
full_name. This is a persistence-layer concern only; domain code never
sees the referenced_as name.
Introspecting fields
To see what's declared on an element — during debugging, or inside a
custom behaviour — use the helpers in protean.utils.reflection:
In [1]: from protean.utils.reflection import declared_fields
In [2]: declared_fields(Post)
Out[2]:
{'title': String(max_length=200, required=True),
'comments': HasMany('Comment'),
'id': Auto(identifier=True)}
attributes() additionally exposes shadow fields such as the foreign
key columns that HasMany and Reference create behind the scenes.
Common errors
| Exception | When it occurs |
|---|---|
ValidationError |
A required field is missing, a length/numeric bound is violated, or a value isn't in the declared choices. Contains a messages dict keyed by field name. |
ValidationError |
A custom validators callable raises it. The message from the validator is preserved. |
ValidationError |
A unique field collides with an existing record at persistence time. |
IncorrectUsageError |
A value object field is marked unique=True or identifier=True — value objects have no concept of identity. |
| Silent mis-declaration | from __future__ import annotations is active and the annotation style is used — the FieldSpec is treated as a string. Use assignment style instead. See Defining Fields. |
See also
Reference:
- Fields Overview — All field types and options.
- Common Arguments —
required,default,choices,unique,validators,referenced_as, and more. - Simple Fields, Container Fields, Association Fields — Per-type specs.
Related guides:
- Expressing Relationships —
HasOne,HasMany,Reference,via, and shadow fields. - Value Objects — Immutable, attribute-identified types.
- Validations and Invariants — Single-field vs. cross-field rules.
- Status Transitions — Enforced lifecycle states with the
Statusfield.
Explanation:
- Field system internals — How Protean resolves field declarations and integrates with Pydantic.