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. Rename List to ValueObjectList for VO embedding

from protean.fields import List now returns a generic list factory. If you use it to embed Value Object lists, switch to ValueObjectList:

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

# After
from protean.fields import ValueObjectList
items = ValueObjectList(content_type=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 imports used for VO embedding — change to ValueObjectList
  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.

Observabilityprotean 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.