Skip to content

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.

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.