Skip to content

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). If True, the field is not allowed to be blank. Default is False.
  • identifier: If True, the field is an identifier for the entity. These fields are unique and required by default.
  • 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 of same category.
  • choices: A set of allowed choices for the field value, supplied as an Enum or list.

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.validatorsMinLengthValidator, 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:

  1. On construction: Protean validates all fields when the object is created. After field validation passes, any @invariant.post methods run.
  2. On attribute assignment: Every self.field = value goes through __setattr__, which triggers field validation. If the value doesn't match the field's type or constraints, a ValidationError is 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.