Defining fields
Applies to: DDD · CQRS · Event Sourcing
Protean supports three ways to define fields on domain elements. All three are fully supported and can be mixed freely within a single class. Choose the style that reads best for each situation.
Annotation style (recommended)
Fields are declared as type annotations using Protean's field functions:
from protean import Domain
from protean.fields import String, Integer, Float, HasMany
domain = Domain()
@domain.entity(part_of="Order")
class LineItem:
description: String(max_length=200, required=True)
quantity: Integer(min_value=1, default=1)
unit_price: Float(min_value=0)
@domain.aggregate
class Order:
customer_name: String(max_length=100, required=True)
items = HasMany("LineItem")
This is the recommended style. It reads as a declaration — "description IS a String" — which aligns naturally with domain modeling, where you are stating what something is, not assigning it a value.
The annotation style also aligns with how modern Python libraries declare structured data (dataclasses, Pydantic, attrs). Tools that process annotations — documentation generators, schema exporters, and IDE inspectors — can discover fields declared this way.
Assignment style
Fields are assigned as class variables, using the same field functions:
from protean import Domain
from protean.fields import String, Integer, HasOne
domain = Domain()
@domain.entity(part_of="Warehouse")
class InventoryManager:
name = String(max_length=100)
warehouse_ref = String()
@domain.aggregate
class Warehouse:
name = String(max_length=100, required=True)
capacity = Integer(min_value=0)
manager = HasOne("InventoryManager", via="warehouse_ref")
This style is familiar if you have used Django models or earlier versions of Protean. It reads as "name equals a String with max_length 100." The semantics are identical to annotation style — both produce the same runtime behavior, validation, and persistence.
Raw Pydantic style
Protean domain elements are Pydantic models under the hood, so you can use
standard Python type annotations and Pydantic's Field() directly:
from typing import Annotated
from pydantic import Field
from protean import Domain
from protean.fields import String
domain = Domain()
@domain.aggregate
class Metric:
name: String(max_length=100, required=True)
score: float = 0.0
metadata: Annotated[dict, Field(default_factory=dict)]
This is useful as an escape hatch when you need full control over Pydantic's
configuration — for example, custom default_factory callables, complex
Annotated metadata, or plain Python types with no constraints.
Any annotation that is not a Protean FieldSpec is passed through to
Pydantic untouched.
Mixing styles
All three styles can coexist in a single class. Each field is processed according to its form:
from typing import Annotated
from pydantic import Field
from protean import Domain
from protean.fields import String, Float, Boolean
domain = Domain()
@domain.aggregate
class Product:
# Annotation style (recommended)
name: String(max_length=50, required=True)
sku: String(max_length=20, unique=True)
# Assignment style
price = Float(min_value=0)
in_stock = Boolean(default=True)
# Raw Pydantic
metadata: Annotated[dict, Field(default_factory=dict)]
There is no performance or behavioral difference between styles. The choice is purely about readability and preference.
Which style should I use?
| Style | Best for | Example |
|---|---|---|
| Annotation | Most fields — clean, declarative, and modern | name: String(max_length=100) |
| Assignment | Teams familiar with Django-style models | name = String(max_length=100) |
| Raw Pydantic | Advanced cases needing direct Pydantic control | metadata: Annotated[dict, Field(...)] |
Recommendation: Use annotation style as your default. It reads as a declaration, works well with tooling, and is the style used throughout Protean's documentation and examples. Reach for assignment style when it feels more natural for a specific field, and raw Pydantic when you need capabilities that Protean's field functions don't expose directly.
Known limitation: from __future__ import annotations
Annotation-style field definitions are incompatible with PEP 563 deferred
evaluation (from __future__ import annotations). When this import is active,
Python converts all annotations to strings at definition time. This means
String(max_length=50) becomes the string "String(max_length=50)" before
Protean's metaclass can process it, and the FieldSpec is never resolved.
If your project uses from __future__ import annotations, use the
assignment style instead:
from __future__ import annotations
@domain.aggregate
class Product:
# Assignment style works correctly with deferred annotations
name = String(max_length=50, required=True)
price = Float(min_value=0)
# Raw Pydantic style also works
metadata: dict = {}
Assignment style places the FieldSpec in the class namespace (not in
__annotations__), so deferred evaluation does not affect it. Raw Pydantic
style also works because Pydantic handles string annotations natively.
Warning
This limitation only affects annotation style. Assignment and raw Pydantic
styles are fully compatible with from __future__ import annotations.
How it works
Regardless of which style you use, Protean normalizes every field into the
same internal representation before the class is finalized. Protean's field
functions (String, Integer, Float, etc.) each return a FieldSpec
object that carries the base Python type, constraints, and metadata. During
class creation, the metaclass resolves every FieldSpec into standard
Pydantic type annotations. By the time validation runs, all three styles
are identical.
This means:
- Validation works the same way regardless of style.
String(max_length=50)enforces the same length limit whether written as an annotation or assignment. - Serialization (
to_dict(), JSON export) produces the same output. - Persistence stores and retrieves the same data.
- JSON Schema generation includes all constraints from all styles.
For a deeper look at the resolution process and the design reasoning behind supporting multiple styles, see the Field system internals.