Validation Layering
The Problem
A developer adds email validation to a User aggregate:
@domain.aggregate
class User:
user_id = Auto(identifier=True)
email = String(required=True, max_length=254)
role = String(required=True, choices=["admin", "member", "guest"])
@invariant.post
def email_must_be_valid(self):
if self.email and "@" not in self.email:
raise ValidationError({"email": ["Invalid email format"]})
@invariant.post
def admin_must_have_company_email(self):
if self.role == "admin" and not self.email.endswith("@company.com"):
raise ValidationError(
{"email": ["Admins must use a company email address"]}
)
Two validations, both on the aggregate's invariants, but they serve different purposes. The email format check is a universal truth about email addresses -- it applies regardless of context. The admin-company-email rule is a business policy that could change, applies only to admins, and depends on the aggregate's role field.
Mixing these creates problems:
-
Wrong error granularity. A user entering
"bad-email"gets a format error at the aggregate level, after the command is already processed. The error could have been caught earlier, at the field or value object level, with a clearer message. -
Duplicated validation. If
Emailis used in multiple aggregates (User,Contact,Newsletter), the format check is duplicated in each. If the format rule changes (e.g., accepting+in local parts), every aggregate must be updated. -
Authorization mixed with domain rules. "Only admins can cancel orders" is an authorization check, not a domain invariant. Putting it in the aggregate mixes infrastructure concerns with domain logic.
-
Validation at the wrong time. A command with clearly invalid data (missing required fields, wrong types) travels through the system, gets deserialized, processed by a handler, applied to an aggregate -- all before the aggregate's invariant catches the error. The error surface is broad and the feedback is delayed.
The root cause: all validation is treated the same, regardless of its nature, urgency, or scope.
The Pattern
Organize validation into layers, each with a specific purpose, scope, and position in the processing pipeline. Each layer catches a different category of invalid state.
Layer 1: Field Constraints (Type safety, format, range)
↓
Layer 2: Value Object Invariants (Domain concept rules)
↓
Layer 3: Aggregate Invariants (Business rules, cross-field consistency)
↓
Layer 4: Handler/Service Guards (Contextual rules, authorization, state checks)
Each layer catches errors earlier and more specifically than the next. Errors that slip through one layer are caught by the next.
Layer 1: Field Constraints
What it validates: Data types, formats, ranges, required-ness, and choices at the individual field level.
Where it lives: Field declarations on aggregates, entities, value objects, commands, and events.
When it runs: At object construction, when a value is assigned to a field.
@domain.aggregate
class Product:
product_id = Auto(identifier=True)
name = String(required=True, max_length=200)
sku = String(required=True, max_length=20)
price = Float(required=True, min_value=0.0)
weight_kg = Float(min_value=0.0)
stock_count = Integer(min_value=0, default=0)
status = String(choices=["draft", "active", "discontinued"], default="draft")
What this catches:
name=Nonewhenrequired=True→ Validation errorname="x" * 201whenmax_length=200→ Validation errorprice=-5.0whenmin_value=0.0→ Validation errorstatus="invalid"whenchoices=[...]→ Validation errorstock_count="abc"when type isInteger→ Type conversion error
What this should NOT catch:
- Business rules ("price must be higher than cost") -- that's Layer 3
- Cross-field validation ("if status is active, stock must be > 0") -- Layer 3
- Format patterns specific to domain concepts ("SKU must match XX-NNNN") -- Layer 2
Field constraints are the first defense. They're declarative, visible in the field definition, and enforced automatically by Protean.
Layer 2: Value Object Invariants
What it validates: Rules intrinsic to a domain concept -- format patterns, internal consistency, and constraints that define what makes a concept valid.
Where it lives: Invariants on value objects.
When it runs: At value object construction (which triggers during aggregate construction or field assignment).
@domain.value_object
class Email:
address = String(required=True, max_length=254)
@invariant.post
def must_have_valid_format(self):
if not self.address or "@" not in self.address:
raise ValidationError(
{"address": ["Must be a valid email address"]}
)
local, _, domain_part = self.address.partition("@")
if not local or not domain_part or "." not in domain_part:
raise ValidationError(
{"address": ["Must be a valid email address"]}
)
@domain.value_object
class Money:
amount = Float(required=True)
currency = String(max_length=3, required=True)
@invariant.post
def amount_must_be_non_negative(self):
if self.amount < 0:
raise ValidationError(
{"amount": ["Amount cannot be negative"]}
)
@invariant.post
def currency_must_be_valid(self):
if self.currency not in {"USD", "EUR", "GBP", "JPY", "CAD"}:
raise ValidationError(
{"currency": [f"Invalid currency: {self.currency}"]}
)
@domain.value_object
class DateRange:
start_date = Date(required=True)
end_date = Date(required=True)
@invariant.post
def end_must_be_after_start(self):
if self.end_date <= self.start_date:
raise ValidationError(
{"end_date": ["End date must be after start date"]}
)
What this catches:
Email(address="not-an-email")→ Invalid formatMoney(amount=-5, currency="USD")→ Negative amountMoney(amount=10, currency="XYZ")→ Invalid currencyDateRange(start_date=today, end_date=yesterday)→ End before start
What this should NOT catch:
- Business rules about how the value is used in context ("Money amount must not exceed the account's credit limit") -- that's Layer 3
- Whether the current user is allowed to set this value -- that's Layer 4
Value object invariants are context-free. An Email is either valid or
not, regardless of which aggregate it's in or who is setting it.
Layer 3: Aggregate Invariants
What it validates: Business rules that depend on the aggregate's state -- cross-field consistency, state machine rules, and domain-specific constraints.
Where it lives: Invariants on aggregates and entities.
When it runs: After aggregate methods are called (post-invariants) or before specific operations (pre-invariants).
@domain.aggregate
class Order:
order_id = Auto(identifier=True)
customer_id = Identifier(required=True)
items = HasMany(OrderItem)
status = String(default="draft")
total = ValueObject(Money)
discount = ValueObject(Money)
@invariant.post
def must_have_items_when_placed(self):
"""An order cannot be placed without items."""
if self.status != "draft" and not self.items:
raise ValidationError(
{"items": ["Order must have at least one item"]}
)
@invariant.post
def discount_cannot_exceed_total(self):
"""Discount cannot be larger than the order total."""
if (self.discount and self.total and
self.discount.amount > self.total.amount):
raise ValidationError(
{"discount": ["Discount cannot exceed order total"]}
)
@invariant.post
def total_must_match_items(self):
"""Order total must match sum of item totals."""
if self.items and self.total:
expected = sum(
item.unit_price * item.quantity for item in self.items
)
if abs(self.total.amount - expected) > 0.01:
raise ValidationError(
{"total": [
f"Total {self.total.amount} does not match "
f"items total {expected}"
]}
)
What this catches:
- Placing an order with no items (cross-field: status + items)
- Discount exceeding total (cross-field: discount + total)
- Total mismatch with item sum (cross-collection: total + items)
What this should NOT catch:
- Whether the email is well-formed (that's Layer 2, in the
EmailVO) - Whether the price is non-negative (that's Layer 1, on the field)
- Whether the user is authorized to place this order (that's Layer 4)
Aggregate invariants are the heart of domain validation. They express the business rules -- the things that make an order valid, an account consistent, or a reservation enforceable.
Layer 4: Handler and Service Guards
What it validates: Contextual rules that depend on the current operation, the caller's identity, external state, or cross-aggregate conditions.
Where it lives: Command handlers, application services, event handlers, and domain services.
When it runs: Before the aggregate method is called, in the handler or service.
@domain.command_handler(part_of=Order)
class OrderCommandHandler(BaseCommandHandler):
@handle(CancelOrder)
def cancel_order(self, command: CancelOrder):
repo = current_domain.repository_for(Order)
order = repo.get(command.order_id)
# Layer 4: Authorization check
if command.cancelled_by_role != "admin" and command.cancelled_by != order.customer_id:
raise ValidationError(
{"authorization": ["Only the customer or an admin can cancel"]}
)
# Layer 4: Time-based business rule
if order.placed_at and (datetime.now(timezone.utc) - order.placed_at).hours > 24:
raise ValidationError(
{"timing": ["Orders cannot be cancelled after 24 hours"]}
)
# Delegate to aggregate method (which has its own Layer 3 invariants)
order.cancel(command.reason)
repo.add(order)
@domain.domain_service(part_of=[Account, CreditPolicy])
class TransferValidationService:
"""Cross-aggregate validation that checks multiple aggregates."""
@classmethod
def validate_transfer(cls, source: Account, policy: CreditPolicy, amount: float):
# Layer 4: Cross-aggregate business rule
if amount > policy.max_single_transfer:
raise ValidationError(
{"amount": [
f"Transfer exceeds policy limit of {policy.max_single_transfer}"
]}
)
# Layer 4: External state check
if source.is_frozen:
raise ValidationError(
{"account": ["Source account is frozen"]}
)
What this catches:
- Authorization failures ("you don't have permission")
- Contextual time-based rules ("too late to cancel")
- Cross-aggregate constraints ("exceeds policy limit")
- External system state ("account is frozen in the external system")
What this should NOT catch:
- Field format validation (Layer 1)
- Value object consistency (Layer 2)
- Single-aggregate business rules (Layer 3)
The Layers in Action
Consider a complete flow for placing an order:
# --- Layer 1: Field Constraints ---
# Triggered at command construction
command = PlaceOrder(
order_id="ord-123",
customer_id="cust-456",
items=[{"product_id": "prod-1", "quantity": 2, "unit_price": 29.99}],
total=59.98,
currency="USD",
)
# If order_id is missing → ValidationError (required field)
# If total is negative → ValidationError (min_value constraint)
# If currency is not a string → Type error
# --- Layer 4: Handler Guards ---
@handle(PlaceOrder)
def place_order(self, command: PlaceOrder):
# Check customer credit status (cross-aggregate)
credit = current_domain.repository_for(CustomerCredit).get(command.customer_id)
if credit.is_suspended:
raise ValidationError({"credit": ["Customer credit is suspended"]})
# --- Layer 2: Value Object Invariants ---
# Triggered when constructing the Money value object
total = Money(amount=command.total, currency=command.currency)
# If currency is "XYZ" → ValidationError (invalid currency in Money VO)
# --- Layer 3: Aggregate Invariants ---
order = Order(
order_id=command.order_id,
customer_id=command.customer_id,
items=command.items,
total=total,
)
order.place()
# @invariant.post checks: must have items, total must match items
# If no items → ValidationError
# If total doesn't match → ValidationError
current_domain.repository_for(Order).add(order)
Each layer catches different kinds of errors at progressively deeper levels of the processing pipeline.
Where Each Validation Type Belongs
| Validation Type | Layer | Location | Example |
|---|---|---|---|
| Required fields | 1 | Field declaration | name = String(required=True) |
| Type constraints | 1 | Field declaration | age = Integer(min_value=0) |
| Format patterns | 2 | Value object invariant | Email format check |
| Internal consistency | 2 | Value object invariant | DateRange end > start |
| Currency validity | 2 | Value object invariant | Money valid currency |
| Cross-field rules | 3 | Aggregate invariant | Discount < total |
| State machine rules | 3 | Aggregate method | "Only draft orders can be placed" |
| Collection rules | 3 | Aggregate invariant | "Must have at least one item" |
| Authorization | 4 | Handler/service | "Only admins can cancel" |
| Cross-aggregate rules | 4 | Domain service | "Transfer within policy limits" |
| Time-based rules | 4 | Handler | "Within cancellation window" |
| External state | 4 | Handler/service | "Account not frozen" |
Anti-Patterns
Business Rules in Field Validators
# Anti-pattern: business rule encoded as a field constraint
class Order:
discount_percent = Float(max_value=50.0) # "Max 50% discount" is a business rule
This embeds a business rule in the field definition. When the business changes the maximum discount to 60%, you're modifying the field schema instead of the business rule layer. Use an invariant instead:
class Order:
discount_percent = Float(min_value=0.0, max_value=100.0) # Physical range
@invariant.post
def discount_within_policy(self):
if self.discount_percent > 50.0:
raise ValidationError(
{"discount_percent": ["Discount cannot exceed 50%"]}
)
Duplicating Validation Across Layers
# Anti-pattern: email validated in both VO and aggregate
@domain.value_object
class Email:
address = String(required=True)
@invariant.post
def must_be_valid(self):
if "@" not in self.address:
raise ValidationError({"address": ["Invalid email"]})
@domain.aggregate
class User:
email = ValueObject(Email, required=True)
@invariant.post
def email_must_be_valid(self):
# REDUNDANT: Email VO already validates this
if self.email and "@" not in self.email.address:
raise ValidationError({"email": ["Invalid email"]})
The Email value object already validates format. The aggregate invariant
duplicates the check. Each layer should validate what the previous layers
don't cover.
Authorization in Aggregate Invariants
# Anti-pattern: authorization check in aggregate
class Order:
@invariant.post
def only_admin_can_set_high_discount(self):
# The aggregate shouldn't know about user roles
if self.discount_percent > 20 and current_user().role != "admin":
raise ValidationError(...)
The aggregate shouldn't access the current user or know about roles. That's the handler's responsibility (Layer 4). The aggregate should validate the domain rule: "discount cannot exceed 50%." The handler validates the authorization rule: "only admins can set discounts above 20%."
All Validation in the Handler
# Anti-pattern: all validation in the handler, aggregate is a data bag
@handle(PlaceOrder)
def place_order(self, command: PlaceOrder):
# Handler does ALL validation
if not command.items:
raise ValidationError({"items": ["Required"]})
if command.total < 0:
raise ValidationError({"total": ["Must be positive"]})
if "@" not in command.customer_email:
raise ValidationError({"email": ["Invalid"]})
if command.discount > command.total:
raise ValidationError({"discount": ["Exceeds total"]})
# Aggregate is just a data container
order = Order(**command.to_dict())
repo.add(order)
Every validation is in the handler. The aggregate has no invariants, no
protection. Any code that creates an Order (another handler, a test, an
import script) can create invalid instances. Distribute validation across
layers so each layer protects its own concerns.
Command Validation
Commands validate their own fields at Layer 1, ensuring that the data structure is correct before it reaches the handler:
@domain.command(part_of=Order)
class PlaceOrder(BaseCommand):
order_id = Identifier(identifier=True)
customer_id = Identifier(required=True)
items = List(required=True)
total = Float(required=True, min_value=0.0)
currency = String(required=True, max_length=3)
If total is negative or customer_id is missing, the error is caught at
command construction -- before the handler even runs. This is the earliest
possible feedback.
Commands should validate data structure and presence, not business rules.
"Is customer_id provided?" is a command concern. "Does this customer exist?"
is a handler concern.
Summary
| Layer | What | Where | When | Example |
|---|---|---|---|---|
| 1 - Field | Type, format, range, required | Field declarations | At construction/assignment | String(max_length=200) |
| 2 - Value Object | Concept-level rules | VO invariants | At VO construction | Email format, Money non-negative |
| 3 - Aggregate | Business rules | Aggregate invariants | After methods/mutations | Discount < total, must have items |
| 4 - Handler | Context, auth, cross-aggregate | Handlers/services | Before aggregate method call | Authorization, time-based rules |
The principle: each validation layer has a specific scope. Field constraints catch data errors. Value objects enforce concept rules. Aggregate invariants guard business rules. Handlers check contextual and cross-aggregate conditions. Each layer trusts the layers below it and adds what they don't cover.