Simple Fields
Applies to: DDD · CQRS · Event Sourcing
String
A string field, for small- to large-sized strings. For large amounts of text, use Text.
from protean import Domain
from protean.fields import String
domain = Domain()
@domain.aggregate
class Person:
name: String(required=True, min_length=2, max_length=50, sanitize=True)
Optional Arguments
max_length: The maximum length (in characters) of the field. Defaults to 255.min_length: The minimum length (in characters) of the field. Defaults toNone(no minimum).sanitize: Opt in to HTML sanitization (bleach.clean()) of the stored value. Default isFalse: the stored value is the raw input.
Sanitization is opt-in, and off by default
Sanitization is a display-layer concern: escaping on write bakes one
encoding (HTML) into stored data and corrupts the value anywhere it is
rendered in another context (a plain-text email, a JSON API, a CSV export).
The real control is encoding output where it is rendered. So sanitize is
False by default, and you should pass sanitize=True only for a value
that is rendered as HTML without output encoding. To set the default for a
whole domain, use the [field_defaults] sanitize
config key; precedence is field kwarg > domain default > framework default.
Length bounds apply to the sanitized value
When sanitize=True, sanitization changes the length of a value:
HTML-escaping grows it (& → &) and stripping HTML comments/attributes
shrinks it. So min_length/max_length are checked against both the raw
input and the stored (sanitized) form. Because the stored form is
bounded, a value that is accepted always round-trips through serialization
and event-sourced replay. An input is rejected on write if either form is
out of bounds (so a raw length that looks fine can still be rejected once
sanitized, and vice versa); widen the bound or pass sanitize=False if you
need the raw form. Pass it explicitly rather than leaving sanitize unset:
an unset field follows the domain's [field_defaults] sanitize default,
which a domain can set to true. A choices field is never sanitized, even with
sanitize=True: its value must match a declared choice exactly. See
ADR-0026.
Text
A large text field, to hold large amounts of text. Text fields do not have size constraints.
from protean import Domain
from protean.fields import String, Text
domain = Domain()
@domain.aggregate
class Book:
title: String(max_length=255)
content: Text(required=True)
Optional Arguments
sanitize: Opt in to HTML sanitization (bleach.clean()) of the stored value. Default isFalse; see String for when to opt in and the precedence rules.
Integer
An integer.
from protean import Domain
from protean.fields import Integer, String
domain = Domain()
@domain.aggregate
class Person:
name: String(max_length=255)
age: Integer(required=True)
Optional Arguments
max_value: The maximum numeric value of the field.min_value: The minimum numeric value of the field.
Float
A floating-point number represented in Python by a float instance.
from protean import Domain
from protean.fields import Float, String
domain = Domain()
@domain.aggregate
class Account:
name: String(max_length=255)
balance: Float(default=0.0)
Optional Arguments
max_value: The maximum numeric value of the field.min_value: The minimum numeric value of the field.
Decimal
An exact decimal number, represented in Python by a decimal.Decimal instance.
Prefer this over Float for money and other values where binary floating-point
rounding is unacceptable. With precision/scale it is fixed-precision;
without them it is arbitrary-precision where the backend supports it.
from protean.fields import Decimal
@domain.aggregate
class Product:
price = Decimal(precision=19, scale=4, min_value=0)
On SQL providers the field maps to NUMERIC(precision, scale); values are
string-encoded in JSON and event payloads, so they never round-trip through a
binary float and lose precision.
Optional Arguments
precision: Total number of digits. Maps toNUMERICprecision and Pydanticmax_digits.scale: Number of digits after the decimal point. Maps toNUMERICscale and Pydanticdecimal_places.max_value: The maximum numeric value of the field.min_value: The minimum numeric value of the field.
Date
A date, represented in Python by a datetime.date instance.
from datetime import datetime
from protean import Domain
from protean.fields import Date, String
domain = Domain()
@domain.aggregate
class Post:
title: String(max_length=255)
published_on: Date(default=lambda: datetime.today().date())
In [1]: p = Post(title="It")
In [2]: p.to_dict()
Out[2]:
{'title': 'It',
'published_on': '2024-05-09',
'id': '88a21815-7d9b-4138-9cac-5a06889d4318',
'_version': -1}
Protean converts a valid date string into a date object and rejects a string that is not a real date.
In [1]: post = Post(title='Foo', published_on="2020-01-01")
In [2]: post.to_dict()
Out[2]:
{'title': 'Foo',
'published_on': '2020-01-01',
'id': 'ffcb3b26-71f0-45d0-8ca0-b71a9603f792',
'_version': -1}
In [3]: Post(title='Foo', published_on="2019-02-29")
...
ValidationError: {'published_on': ['Input should be a valid date or datetime, day value is outside expected range']}
DateTime
A date and time, represented in Python by a datetime.datetime instance.
from datetime import UTC, datetime
from protean import Domain
from protean.fields import DateTime, String
domain = Domain()
@domain.aggregate
class Post:
title: String(max_length=255)
created_at: DateTime(default=lambda: datetime.now(UTC))
In [1]: p = Post(title="It")
In [2]: p.to_dict()
Out[2]:
{'title': 'It',
'created_at': '2024-05-09T17:12:11.373300+00:00',
'id': '3a96e434-06ab-4244-80a8-76edbd621a27',
'_version': -1}
Auto-populated timestamps
DateTime and Date support two Django-parity flags that let the persistence
layer stamp the field on save:
auto_now_add=True: Set to the current UTC time on the create save, then never touched again. Use it forcreated_at.auto_now=True: Set to the current UTC time on every save (create and update). Use it forupdated_at.
from protean.fields import DateTime, String
@domain.aggregate
class Article:
title: String(max_length=100)
created_at: DateTime(auto_now_add=True)
updated_at: DateTime(auto_now=True)
The two flags are mutually exclusive, are only valid on DateTime/Date
fields, and cannot be combined with required=True (the value is filled at
save time, so the field must be optional). Unlike a construction-time
default=utc_now, an auto_now* field is None until the first save. The
value is stamped on the repository.add() → save path, not at construction and
not on a bulk query.update() (matching Django's Model.save() vs
QuerySet.update() behavior).
See Track Audit and Lifecycle Fields for
the full recipe (abstract base + timestamps + created_by/updated_by).
Boolean
A True/False field.
from protean import Domain
from protean.fields import Boolean, String
domain = Domain()
@domain.aggregate
class User:
name: String(required=True)
subscribed: Boolean(default=False)
In [1]: u = User(name="John Doe")
In [2]: u.to_dict()
Out[2]:
{'name': 'John Doe',
'subscribed': False,
'id': '69190dd4-12a6-4666-a799-9409ddab39cd',
'_version': -1}
Auto
Automatically-generated unique identifiers.
Auto field values are auto-generated unless explicitly supplied. This is the
primary difference between Auto and Identifier fields. Since they are
always auto-generated, Auto fields cannot be marked required=True.
Optional Arguments
increment: Auto-increment field value. Defaults toFalse. If set, the value is expected to be generated by the database at the time of persistence.
Note
It is necessary for the underlying persistence store to support this
increment feature. You have to set up the database schema accordingly.
Cross-check with the specific adapter's documentation and your database
to confirm if the database supports this functionality.
-
identity_strategy: The strategy to use to generate an identity value. If not provided, the strategy defined at the domain level is used. -
identity_function: A function that is used to generate the identity value. If not provided, the function defined at the domain level is used. -
identity_type: The type of the identity value. If not provided, the type defined at the domain level is used.
The identity params are useful when constructing an entity whose identity schema differs from the default.
By default, all entities and aggregates create an Auto field named id
that represents their unique identifier.
from protean import Domain
from protean.fields import String
domain = Domain()
@domain.aggregate
class Person:
name: String(required=True, min_length=2, max_length=50, sanitize=True)
In [1]: list(declared_fields(Person))
Out[1]: ['name', 'id']
In [2]: p = Person(name='John Doe')
In [3]: p.to_dict()
Out[3]:
{'name': 'John Doe',
'id': '7d32e929-e5c5-4856-a6e7-1ebf12e6259e',
'_version': -1}
Identity values are UUIDs by default. You can customize this behavior with
identity_strategy and identity_type config attributes.
The Identity section covers identities in Protean.
Identifier
An Identifier. It always stores its value as a string. The identity_type
configuration attribute does not change that, and Identifier takes no
identity_type argument.
from protean import Domain
from protean.fields import Boolean, Identifier, String
from protean.utils import IdentityType
domain = Domain()
# Customize the identity type
domain.config["identity_type"] = IdentityType.INTEGER.value
@domain.aggregate
class User:
user_id: Identifier(identifier=True)
name: String(required=True)
subscribed: Boolean(default=False)
In [1]: user = User(user_id=1, name="John Doe")
In [2]: user.to_dict()
Out[2]: {'user_id': '1', 'name': 'John Doe', 'subscribed': False, '_version': -1}
Refer to Identity section for more on identities in Protean.
Status
A status field for modeling aggregate lifecycle states with enforced transitions.
Requires an Enum class as the first argument. Valid values are the Enum members'
value attributes.
from enum import Enum
from protean.fields import Status
class OrderStatus(Enum):
DRAFT = "DRAFT"
PLACED = "PLACED"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"
CANCELLED = "CANCELLED"
@domain.aggregate
class Order:
status = Status(OrderStatus, default="DRAFT")
Without transitions, Status behaves like String(choices=Enum). It
constrains values but does not enforce transition rules.
Enforcing transitions
Pass a transitions dict mapping each state to its allowed next states:
@domain.aggregate
class Order:
status = Status(
OrderStatus,
default="DRAFT",
transitions={
OrderStatus.DRAFT: [OrderStatus.PLACED, OrderStatus.CANCELLED],
OrderStatus.PLACED: [OrderStatus.CONFIRMED, OrderStatus.CANCELLED],
OrderStatus.CONFIRMED: [OrderStatus.SHIPPED],
OrderStatus.SHIPPED: [OrderStatus.DELIVERED],
# DELIVERED and CANCELLED are terminal: absent from keys
},
)
States not appearing as keys in the transitions dict are terminal states, no outgoing transitions are allowed from them.
Same-value assignments are also validated against the map. To make a state idempotent (self-transition allowed), include it in its own target list:
# fragment
OrderStatus.CANCELLED: [OrderStatus.CANCELLED], # cancel() is idempotent
In [1]: order = Order()
In [2]: order.status = "PLACED" # DRAFT → PLACED: allowed
In [3]: order.status = "SHIPPED"
ValidationError: {'status': ["Invalid status transition from 'PLACED' to 'SHIPPED'. Allowed transitions: CONFIRMED, CANCELLED"]}
Programmatic checking
Use can_transition_to() to check whether a transition is valid without raising:
with domain.domain_context():
order = Order()
order.status = "PLACED"
order.can_transition_to("status", OrderStatus.SHIPPED) # False
order.can_transition_to("status", OrderStatus.CONFIRMED) # True
Optional Arguments
transitions: A dict mapping each status to a list of allowed target statuses. When provided, the framework prevents illegal transitions. Accepts both Enum members and raw strings as keys/values. A state must list itself as a target to allow idempotent self-transitions.
Refer to the Status Transitions
guide for detailed usage patterns including atomic_change and event-sourced
aggregates.