Basic Validations
DDD CQRS ES
A core DDD principle is that domain objects should be always valid — it should be impossible to construct or mutate an aggregate, entity, or value object into a state that violates its rules. Protean enforces this guarantee starting at the field level: every field declaration carries constraints that are checked automatically on construction and on every subsequent assignment.
Field-level validation is the first layer of a broader validation architecture. Protean organizes validation into four layers, each catching a different category of invalid state:
| Layer | What it validates | Where it lives |
|---|---|---|
| 1 — Field constraints | Type, format, range, required | Field declarations (this guide) |
| 2 — Value object invariants | Concept-level rules (e.g. email format) | @invariant.post on VOs |
| 3 — Aggregate invariants | Business rules, cross-field consistency | @invariant on aggregates |
| 4 — Handler/service guards | Authorization, cross-aggregate checks | Command handlers, domain services |
Each layer trusts the layers below it and adds what they don't cover. This guide focuses on Layer 1 — field constraints, built-in validators, and custom validators. For the complete picture, see the Validation Layering pattern.
Field Restrictions
Field restrictions begin with the type of field chosen to represent an attribute.
def utc_now():
return datetime.now(timezone.utc)
class AccountType(Enum):
SAVINGS = "SAVINGS"
CURRENT = "CURRENT"
@domain.aggregate
class Account:
account_number: Integer(required=True, unique=True)
account_type: String(required=True, max_length=7, choices=AccountType)
balance: Float(default=0.0)
opened_at: DateTime(default=utc_now)
Violating any of these constraints will throw exceptions:
In [3]: account = Account(
...: account_number="A1234",
...: account_type="CHECKING",
...: balance=50)
ERROR: Error during initialization...
...
ValidationError: {
'account_number': ['"A1234" value must be an integer.'],
'account_type': [
"Value `'CHECKING'` is not a valid choice. Must be among ['SAVINGS', 'CURRENT']"
]
}
These validations kick in even on attribute change, not just during initialization, thus keeping the aggregate valid at all times.
In [1]: account = Account(account_number=1234, account_type="SAVINGS", balance=500.0)
In [2]: account.account_type = "CHECKING"
...
ValidationError: {
'account_type': [
"Value `'CHECKING'` is not a valid choice. Must be among ['SAVINGS', 'CURRENT']"
]
}
Every Protean field also has options that help constrain the field value.
For example, we can specify that the field is mandatory with the required
option and stores a unique value with the unique option.
The four options to constrain values are:
required: Indicates if the field is required (must have a value). IfTrue, the field is not allowed to be blank. Default isFalse.identifier: If True, the field is an identifier for the entity. These fields areuniqueandrequiredby default.unique: Indicates if the field values must be unique within the repository. IfTrue, this field's value is validated to be unique among all entities of same category.choices: A set of allowed choices for the field value, supplied as anEnumorlist.
Note
Note that some constraints, like uniqueness, will only be enforced when the element is persisted.
Since Account.account_number was declared required earlier, skipping it
will throw an exception:
n [5]: account = Account(
...: account_type="SAVINGS",
...: balance=50)
ERROR: Error during initialization: {'account_number': ['is required']}
...
ValidationError: {'account_number': ['is required']}
A full list of field types and their options is available in the Fields section.
In-built Validations
Many field classes in Protean come pre-equipped with basic validations, like length and value.
For example, Integer fields have min_value and max_value validators,
while String fields have min_length and max_length validators. These
validators are typically activated by supplying them as a parameter during
field initialization.
@domain.aggregate
class Person:
name: String(required=True, min_length=3, max_length=50)
age: Integer(required=True, min_value=0, max_value=120)
Violating these constraints results in an immediate exception:
In [1]: Person(name="Ho", age=200)
ERROR: Error during initialization:
...
ValidationError: {'name': ['value has less than 3 characters'], 'age': ['value is greater than 120']}
Under the hood, these parameters create built-in validator instances from
protean.fields.validators — MinLengthValidator, MaxLengthValidator,
MinValueValidator, MaxValueValidator, and RegexValidator. You rarely need
to use them directly, but they are available if you need to compose validators
programmatically.
A full list of in-built validators is available in the Fields section under each field.
Custom Validators
You can also add validations at the field level by defining custom validators.
A validator is any callable class that accepts a value and raises ValueError
if the value is invalid:
def __init__(self):
self.error = "Invalid Email Address"
def __call__(self, email):
# Define the regular expression pattern for valid email addresses
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9]+\.[a-zA-Z]{2,}$"
# Match the email with the pattern
if not bool(re.match(pattern, email)):
raise ValueError(f"{self.error} - {email}")
@domain.aggregate
class Person:
name: String(required=True, max_length=50)
email: String(required=True, max_length=254, validators=[EmailValidator()])
Now, an email address assigned to the field is validated with the custom regex pattern:
In [1]: Person(name="John", email="john.doe@gmail.com")
Out[1]: <Person: Person object (id: 659fa079-f93c-4a6d-9b16-19af02ec86ef)>
In [2]: Person(name="Jane", email="jane.doe@.gmail.com")
...
ValueError: Invalid Email Address - jane.doe@.gmail.com
You can attach multiple validators to a single field with the validators
list. Protean runs all of them in order, and the first failure stops
evaluation and raises the error.
For more details on the validators field option and the error_messages
option for customizing error text, see the
Common Arguments reference.
Note
For domain-concept validation (e.g. "an email must have a valid format"), consider using a value object with an invariant (Layer 2) instead of a field-level validator. Value objects are reusable across aggregates and make the concept explicit in your domain model. See Validation Layering for guidance on choosing the right layer.
How It Works
Every aggregate, entity, and value object validates field assignments automatically:
- On construction: Protean validates all fields when the object is created.
After field validation passes, any
@invariant.postmethods run. - On attribute assignment: Every
self.field = valuegoes through__setattr__, which triggers field validation. If the value doesn't match the field's type or constraints, aValidationErroris raised immediately — the assignment never takes effect.
This means an aggregate can never hold an invalid field value, even momentarily. The "always valid" guarantee is enforced at the Python runtime level, not just at persistence time.
See also
Deep dive: The Always-Valid Domain — The complete story of how Protean's four validation layers work together to guarantee your domain objects are never invalid.
Concept overview: Invariants — The foundational concept of keeping domain objects always valid.
Related guides:
- Invariants — Business rules that enforce cross-field consistency (Layers 2-3).
- Aggregate Mutation — How state changes trigger validation.
Patterns: Validation Layering — Choosing the right validation layer for each kind of rule.