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. 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
- Update dependencies:
uv add protean@"^0.15.0" - Search for
Listused for VO embedding, wrap the VO class in aValueObjectdescriptor:List(content_type=ValueObject(...)) - 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.