Projections
CQRS ES
Why not just query the aggregate directly? You can — but aggregates are designed for consistency, not for read performance. Loading a full aggregate graph just to display a dashboard row is wasteful. Projections solve this by maintaining a separate, flattened view that is shaped for the query, not for the write model. They're the "read side" of CQRS.
Projections are populated in response to domain events by projectors.
Defining a Projection
Projections are defined with the Domain.projection decorator.
@domain.projection
class ProductInventory:
"""Projection for product inventory data optimized for querying."""
product_id: Identifier(identifier=True, required=True)
name: String(max_length=100, required=True)
description: Text(required=True)
price: Float(required=True)
stock_quantity: Integer(default=0)
last_updated: DateTime()
Configuration Options
Projections accept several decorator options that control storage, querying, and schema behavior:
| Option | Default | Description |
|---|---|---|
provider |
"default" |
Database provider name |
cache |
None |
Cache provider — takes precedence over provider when set |
schema_name |
snake_case(cls) |
Table or collection name in the backing store |
order_by |
() |
Default field ordering for queries (e.g. ("-placed_at",)) |
limit |
100 |
Default maximum number of results returned by queries |
abstract |
False |
When True, the projection cannot be instantiated — use as a base class |
database_model |
None |
Custom database model class for advanced schema control |
Default limit=100 caps query results
All projection queries are capped at limit results by default. If your
projection has more than 100 records and you query without an explicit
.limit(), only the first 100 are returned. Override this on the decorator
or use .limit(n) in queries:
# Override the default limit on the projection
@domain.projection(limit=500)
class LargeReport:
...
# Or override per-query
view = domain.view_for(LargeReport)
results = view.query.limit(1000).all()
Storage Options
Projections can be stored in either a database or a cache, but not both simultaneously:
# Database storage (default)
@domain.projection(provider="postgres")
class ProductInventory:
...
# Cache storage
@domain.projection(cache="redis")
class ProductInventory:
...
When both cache and provider are specified, the cache parameter takes
precedence. See
element decorators
for the full list of configuration options.
Supported Field Types
Projections support basic field types (String, Integer, Float,
Identifier, DateTime, Boolean, etc.) and ValueObject fields.
References and Associations (HasOne, HasMany) are not supported.
ValueObject fields preserve domain semantics while being stored as flattened shadow fields for efficient querying:
@domain.projection
class OrderSummary:
order_id = Identifier(identifier=True)
customer_name = String(max_length=100)
total_amount = Float()
shipping_address = ValueObject(Address) # Stored as shipping_address_street, etc.
You can query on individual shadow fields:
results = domain.view_for(OrderSummary).query.filter(
shipping_address_city="Springfield"
).all()
Querying Projections
Use domain.view_for() to get a read-only interface for any projection:
view = domain.view_for(ProductInventory)
# Single lookup by identifier
item = view.get("abc-123")
# Fluent filtering via ReadOnlyQuerySet
results = view.query.filter(
stock_quantity__lt=10
).order_by("name").all()
for item in results:
print(f"{item.name}: {item.stock_quantity} remaining")
# Convenience single-item lookup by criteria
item = view.find_by(product_id="abc-123")
# Total count and existence checks
total = view.count()
found = view.exists("abc-123")
ReadView API
The ReadView returned by domain.view_for() exposes these methods:
| Method | Signature | Returns | Description |
|---|---|---|---|
get |
get(identifier) |
Projection instance | Look up a single record by its identifier. Raises ObjectNotFoundError if not found. |
find_by |
find_by(**kwargs) |
Projection instance | Look up a single record matching the given field criteria. |
count |
count() |
int |
Return the total number of projection records. Accepts no arguments. |
exists |
exists(identifier) |
bool |
Check whether a record with the given identifier exists. |
query |
(property) | ReadOnlyQuerySet |
Fluent query builder — supports filter(), exclude(), order_by(), limit(), offset(), and all(). |
count() accepts no arguments
view.count() returns the total count of all records. To count records
matching specific criteria, use the query builder:
view.query.filter(status="active").all().total.
The query property returns a ReadOnlyQuerySet that supports all read
operations — filter(), exclude(), order_by(), limit(), offset(),
and all() — but blocks mutations (update, delete) to enforce CQRS
read/write separation.
Pagination
The ResultSet returned by .all() includes pagination properties:
page = view.query.order_by("name").limit(20).offset(40).all()
page.items # The actual result items
page.total # Total matching records across all pages
page.has_next # True if more pages exist
page.has_prev # True if previous pages exist
page.page # Current page number
page.page_size # Items per page
page.total_pages # Total number of pages
ReadView does not expose add(), _dao, or any mutation methods — it is
safe to pass to API endpoints and query handlers without risking accidental
writes.
For write operations (used inside projectors), continue using
domain.repository_for():
repo = domain.repository_for(ProductInventory)
repo.add(inventory_record)
Note
domain.view_for() is specifically for projections. To query aggregates,
use repositories with custom
query methods.
Cache-backed projections
When a projection is stored in a cache (Redis, in-memory), view.get(),
view.count(), and view.exists() work as expected. However, view.query
and view.find_by() raise NotSupportedError because cache backends are
key-value stores and do not support field-based filtering.
Three levels of projection access
Protean provides three levels of projection access, each suited to different use cases:
| Level | Entry point | Returns | Use when |
|---|---|---|---|
| ReadView | domain.view_for(Proj) |
ReadView |
Default for endpoints and query handlers — read-only by design |
| Raw | domain.connection_for(Proj) |
DB/cache connection | Escape hatch — technology-specific queries (SQL, ES DSL, Redis) |
| Repository | domain.repository_for(Proj) |
BaseRepository |
Inside projectors — when you need to write |
Raw connection access
When you need to run technology-specific queries that cannot be expressed
through QuerySet — such as SQL aggregations, Elasticsearch DSL, or Redis
SCAN commands — use domain.connection_for():
conn = domain.connection_for(OrderSummary)
# conn is the raw SQLAlchemy session, Elasticsearch client,
# Redis client, etc., depending on the projection's backing store
The method automatically routes to the correct provider or cache based on the projection's configuration. For database-backed projections it returns the database provider's connection; for cache-backed projections it returns the cache client.
See also
Concept overview: Projections — Read-optimized views in CQRS.
Related guides:
- Projectors — How to define and configure projectors that maintain projections.
- Query Handlers — How to dispatch structured read intents via
domain.dispatch().
Patterns:
- Projection Granularity — How many projections to create and what each should contain.
- Projection Rebuilds as Deployment — Treating projection rebuilds as standard deployment operations.