Skip to content

Migrating to 0.15

From 0.14 to 0.15

Protean 0.15 is a major release. It rebuilds domain element internals on Pydantic v2, restructures the messaging system, and adds a large set of new capabilities. This guide focuses on what you need to change to upgrade.

On this page:


Required changes

These will break your code if not addressed.

1. Install Pydantic v2

Protean 0.15 requires pydantic>=2.10,<3.0.

uv add pydantic@">=2.10,<3.0"
# or
pip install "pydantic>=2.10,<3.0"

2. Wrap VO lists in a ValueObject descriptor

from protean.fields import List now returns a generic list factory. If you use it to embed Value Object lists, wrap the Value Object class in a ValueObject descriptor:

# Before
from protean.fields import List
items = List(content_type=OrderItem)

# After
from protean.fields import List, ValueObject
items = List(content_type=ValueObject(OrderItem))

Generic List(content_type=String) usage is unchanged.

3. Remove extra kwargs from Value Object constructors

Value Objects now use extra="forbid". Passing unknown keyword arguments raises ValidationError:

# Before: silently ignored
address = Address(street="123 Main", city="NYC", extra_field="ignored")

# After: raises ValidationError — remove extra_field
address = Address(street="123 Main", city="NYC")

4. Replace domain.publish() with broker access

domain.publish() has been removed.

# Before
domain.publish("order_events", message_dict)

# After
domain.brokers["default"].publish("order_events", message_dict)

5. Update Message class imports and method names

The Message class moved from protean.utils.mixins to protean.utils.eventing. Three methods were renamed:

0.14 0.15
Message.from_dict(d) Message.deserialize(d)
Message.to_object() Message.to_domain_object()
Message.to_message(obj) Message.from_domain_object(obj)

MessageRecord has been removed, use Message directly.

6. Update message metadata access

Metadata was a flat object in 0.14. It is now grouped hierarchically:

0.14 0.15
msg._metadata.id msg._metadata.headers.id
msg._metadata.type msg._metadata.headers.type
msg._metadata.stream msg._metadata.headers.stream
msg._metadata.timestamp msg._metadata.headers.time
msg._metadata.fqn msg._metadata.domain.fqn
msg._metadata.kind msg._metadata.domain.kind
msg._metadata.payload_hash Removed (use msg._metadata.envelope.checksum)

New groups: headers, envelope, domain, event_store, extensions.

7. Add @apply handlers for every event-sourced event

For is_event_sourced=True aggregates, @apply handlers are now mandatory and run in both the live path and during replay. Business methods must no longer mutate state directly, only raise events.

# Before — direct mutation in business methods
@domain.aggregate(is_event_sourced=True)
class Account:
    balance = Float()

    def withdraw(self, amount):
        self.balance -= amount                 # Direct mutation
        self.raise_(Withdrawn(amount=amount))

    @apply
    def on_withdrawn(self, event):
        self.balance -= event.amount           # Only ran during replay

# After — @apply handles ALL state mutation
@domain.aggregate(is_event_sourced=True)
class Account:
    balance: Float()

    def withdraw(self, amount):
        self.raise_(Withdrawn(amount=amount))  # Only raise the event

    @apply
    def on_withdrawn(self, event):
        self.balance -= event.amount           # Runs in live path AND replay

A missing @apply handler raises NotImplementedError at runtime.

8. Handle from __future__ import annotations

Annotation-style field definitions do not work with PEP 563 deferred evaluation. If you use from __future__ import annotations, either:

  • Remove the import if not needed, or
  • Use assignment style: name = String(max_length=50)
  • Use raw Pydantic style: name: Annotated[str, Field(max_length=50)]

Behavioral changes

These won't necessarily break your code, but may change runtime behavior.

Invariants skip when field validation fails

Pre- and post-invariants now only run if field-level validation passes. If your tests expected both field errors and invariant errors together, you will now see only the field errors. Fix those first, invariants evaluate after.

HasMany add_*/remove_* trigger pre-invariants

add_<field>() and remove_<field>() now evaluate @invariant.pre checks, matching the behavior of direct assignment. You may see new invariant errors where these methods previously bypassed checks.

to_dict() vs model_dump()

to_dict() is the canonical serialization method for domain elements. Avoid using Pydantic's model_dump() directly. It does not handle domain-specific concerns correctly:

Aspect to_dict() model_dump()
Reference fields Skipped May be included
ValueObject fields Nested dict at domain level Flat Pydantic output
DateTime values Strings datetime objects

Always use to_dict() for serializing domain elements.

IDE setup

Annotation-style fields may confuse type checkers. Pyright/Pylance: set "reportInvalidTypeForm": false in pyrightconfig.json. mypy: enable the plugin with plugins = ["protean.ext.mypy_plugin"] in pyproject.toml.

Elasticsearch: explicit field mappings and re-indexing

The Elasticsearch adapter now builds explicit index mappings from aggregate/entity attributes instead of relying on Elasticsearch's dynamic mapping. String fields are mapped as Keyword (exact match) by default.

Re-index required. Existing Elasticsearch indexes created with prior versions use dynamic mappings where string fields are text with an auto-generated .keyword subfield. After upgrading, queries target the field name directly (without .keyword), so old indexes will return incorrect results. Drop and recreate your Elasticsearch indexes after upgrading:

with domain.domain_context():
    provider = domain.providers["elasticsearch"]
    provider._drop_database_artifacts()
    provider._create_database_artifacts()

Or use the CLI: protean db drop && protean db setup.

Custom models for full-text search. If you need full-text search with analyzers on specific fields, define a custom @domain.model using elasticsearch_dsl field types directly:

import elasticsearch_dsl

@domain.model(part_of=Article)
class ArticleModel:
    # Text field with analyzer for full-text search
    title = elasticsearch_dsl.Text(
        analyzer="standard",
        fields={"keyword": elasticsearch_dsl.Keyword()}
    )
    # Fields not listed here are auto-mapped from the aggregate

User-defined fields take precedence; unmapped attributes are filled in automatically.


Upgrade checklist

  1. Update dependencies: uv add protean@"^0.15.0"
  2. Search for List used for VO embedding, wrap the VO class in a ValueObject descriptor: List(content_type=ValueObject(...))
  3. Search for VO constructors with extra kwargs, remove them
  4. Search for domain.publish(), replace with domain.brokers["default"].publish()
  5. Search for Message.from_dict, .to_object, .to_message, rename and update imports to protean.utils.eventing
  6. Search for MessageRecord, replace with Message
  7. Search for flat metadata access (msg._metadata.id, etc.), update to hierarchical structure
  8. For ES aggregates, add @apply handlers, remove direct state mutation from business methods
  9. If using from __future__ import annotations, switch to assignment-style fields
  10. If using Elasticsearch, drop and recreate indexes (mapping format changed)
  11. Run your test suite, fix any new validation or invariant errors

What's new

Protean 0.15 adds significant new capabilities. Brief pointers below, see the documentation for each topic for full details.

CQRS read side, @domain.query, @domain.query_handler, domain.dispatch(), domain.view_for(), domain.connection_for(), and ResultSet pagination (page, total_pages, has_next).

Process managers, @domain.process_manager for stateful, multi-aggregate process coordination with event store persistence.

Event sourcing, @domain.upcaster for schema evolution, temporal queries (repo.get(id, at_version=N) / as_of=datetime), manual snapshots (domain.create_snapshot()), and the protean.testing.given BDD-style test DSL.

Observability, protean observatory monitoring dashboard with Prometheus metrics, SSE streaming, and subscription lag tracking. structlog integration and FastAPI DomainContext middleware.

Message tracing, End-to-end correlation_id and causation_id propagation via domain.process(cmd, correlation_id="..."). Causation chain API and @domain.command_enricher hooks.

Reliability, Dead letter queue with replay, projection rebuilding (domain.rebuild_projection() and protean projection rebuild CLI), priority lanes for two-lane event routing, and Redis-backed command idempotency (domain.process(cmd, idempotency_key="...")).

Scaling, Multi-worker supervisor via protean server --workers N.

Infrastructure, Database lifecycle API (domain.setup_database(), domain.truncate_database(), domain.drop_database()) and protean db CLI commands. DomainFixture pytest plugin. Provider registry via entry points. Adapter conformance testing. Event store inspection CLI (protean events). Python 3.11–3.14 support.