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 — things that will break your code
- Behavioral changes — won't break, but may surprise
- Upgrade checklist — quick step-by-step
- What's new — brief pointers to new features
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
- Update dependencies:
uv add protean@"^0.15.0" - Search for
Listimports used for VO embedding — change toValueObjectList - Search for VO constructors with extra kwargs — remove them
- Search for
domain.publish()— replace withdomain.brokers["default"].publish() - Search for
Message.from_dict,.to_object,.to_message— rename and update imports toprotean.utils.eventing - Search for
MessageRecord— replace withMessage - Search for flat metadata access (
msg._metadata.id, etc.) — update to hierarchical structure - For ES aggregates — add
@applyhandlers, remove direct state mutation from business methods - If using
from __future__ import annotations— switch to assignment-style fields - If using Elasticsearch — drop and recreate indexes (mapping format changed)
- 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.