Skip to content
Back to skills

Projection

ASecurity

Define a Protean projection (read model) - a denormalized, query-optimized data structure used on the read side of CQRS. Projections are closer to the database than domain aggregates and can be stored in either a database (provider) or cache (e.g., Redis). They support basic field types (String, Integer, Float, Identifier, DateTime, etc.) and ValueObject fields (stored as flattened shadow fields) - but not References or Associations. Every projection must have at least one identifier field. P...

  • 46 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 20, 2026
designpythongodatabase

Security analysis

A100/100

Pro scans all 11 files and shows the line behind each finding

Scanned October 1, 2026

npx -y skills add proteanhq/protean --skill projection --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Projection?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Projection
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/proteanhq-projection/badge)](https://www.skillsdirectory.com/skills/proteanhq-projection)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: projection
description: Define a Protean projection (read model) - a denormalized, query-optimized data structure used on the read side of CQRS. Projections are closer to the database than domain aggregates and can be stored in either a database (provider) or cache (e.g., Redis). They support basic field types (String, Integer, Float, Identifier, DateTime, etc.) and ValueObject fields (stored as flattened shadow fields) - but not References or Associations. Every projection must have at least one identifier field. Projections are populated by projectors in response to domain events. Use when you need to define a read model, create a query-optimized view, build a denormalized data structure, define a CQRS read side schema, or when the user asks to "create a projection", "define a read model", "add a query view", "build a denormalized view", "create a CQRS projection", or "define a read-optimized model".
license: Apache-2.0
compatibility: Requires Python 3.11+, protean framework
metadata:
  author: proteanhq
  version: "0.1"
  category: element
  diagnostic_codes:
    - PROJECTION_WITHOUT_PROJECTOR
    - UNSOURCED_PROJECTION_FIELD
    - USAGE_NOT_A_PROJECTION
---

# Projection

## How projections differ from aggregates

| Aspect | Aggregate | Projection |
|--------|-----------|------------|
| **Purpose** | Enforce business rules (write side) | Optimized for querying (read side) |
| **Field types** | All types (References, Associations, ValueObjects) | Basic types plus ValueObjects (flattened); no References or Associations |
| **Data shape** | Normalized, bounded by consistency boundary | Denormalized, flattened for query efficiency |
| **Populated by** | Commands and domain logic | Projectors consuming domain events |
| **Storage** | Database via repository | Database (provider) or cache |
| **Decorator** | `@domain.aggregate` | `@domain.projection` |
| **Identity** | Auto-generated `id` or custom | Must declare at least one `identifier=True` field |

## Basic structure

```python
from protean import Domain
from protean.fields import DateTime, Float, Identifier, Integer, String, Text

domain = Domain()

@domain.projection
class ProductInventory:
    product_id: Identifier(identifier=True, required=True)
    name: String(max_length=100, required=True)
    description: Text()
    price: Float(required=True)
    stock_quantity: Integer(default=0)
    last_updated: DateTime()
```

## Key rules

1. **At least one identifier field** - Every non-abstract projection must have a field with `identifier=True`; abstract projections are exempt from this check
2. **No Reference or Association fields** - `Reference` and Associations (`HasOne`/`HasMany`) are rejected. Basic field types and `ValueObject` fields are allowed; a `ValueObject` is stored as flattened shadow fields (e.g. `address_street`, `address_city`)
3. **Use @domain.projection decorator** - Register with domain: `@domain.projection` or `domain.register(MyProjection)`
4. **Projections are denormalized** - Flatten nested/related data into basic fields
5. **Default provider is "default"** - Uses the default database provider unless overridden
6. **Cache overrides provider** - When both `cache` and `provider` are specified, `cache` takes precedence
7. **Default query limit is 100** - Can be overridden; set to `None` or negative for unlimited
8. **Schema name auto-derived** - Defaults to underscore-cased class name (e.g., `ProductInventory` -> `product_inventory`)
9. **Identifier values are immutable** - Once set, the identifier field cannot be changed
10. **Identifier values auto-generate when omitted, for `Identifier`/`Auto` fields** - An `identifier=True` field declared as `Identifier` or `Auto` generates a value on creation if you do not supply one (a UUID by default), the same as aggregates. `Auto(identifier=True, increment=True)` is the exception: it stays `None` at construction and the DAO assigns its integer sequence when the projection is persisted. An `identifier=True` field declared with another field type (e.g. `String`) gets no default and stays required

## Projection options

| Option | Default | Purpose |
|--------|---------|---------|
| `provider` | `"default"` | Database provider for storing projection data |
| `cache` | `None` | Cache provider (e.g., `"redis"`); overrides `provider` when set |
| `schema_name` | underscore class name | Custom table/schema name in the database |
| `order_by` | `()` | Default ordering for query results |
| `limit` | `100` | Default query result limit; `None` for unlimited |
| `abstract` | `False` | If `True`, projection is an abstract base class |

To control the database mapping, register your own model with
`@domain.database_model(part_of=...)`. There is a `database_model` option on
`@domain.projection`, but nothing reads it, so a model passed there is ignored
and the auto-generated one stays in place. See
[Custom database models](references/configuration-options.md).

## Supported field types

Projections support these basic field types:

| Type | Import | Example |
|------|--------|---------|
| `Identifier` | `from protean.fields import Identifier` | `user_id: Identifier(identifier=True)` |
| `Auto` | `from protean.fields import Auto` | `id: Auto(identifier=True)` |
| `String` | `from protean.fields import String` | `name: String(max_length=100)` |
| `Text` | `from protean.fields import Text` | `description: Text()` |
| `Integer` | `from protean.fields import Integer` | `count: Integer(default=0)` |
| `Float` | `from protean.fields import Float` | `price: Float(required=True)` |
| `Boolean` | `from protean.fields import Boolean` | `active: Boolean(default=True)` |
| `DateTime` | `from protean.fields import DateTime` | `created_at: DateTime()` |
| `Date` | `from protean.fields import Date` | `birth_date: Date()` |

Projections also accept `ValueObject` fields (`from protean.fields import ValueObject`, e.g. `shipping_address = ValueObject(Address)`). A value object is stored as flattened shadow fields (`shipping_address_street`, `shipping_address_city`, ...) and each attribute is queryable on its own.

## Quick example: Database-backed projection

```python
@domain.projection(provider="postgres", schema_name="product_inventory")
class ProductInventory:
    product_id: Identifier(identifier=True, required=True)
    name: String(max_length=100, required=True)
    stock_quantity: Integer(default=0)
```

## Quick example: Cache-backed projection

```python
@domain.projection(cache="redis")
class ActiveSession:
    session_id: Identifier(identifier=True, required=True)
    user_id: Identifier(required=True)
    email: String(required=True)
```

## Quick example: Custom query limit and ordering

```python
@domain.projection(order_by=("last_name",), limit=50)
class UserDirectory:
    user_id: Identifier(identifier=True, required=True)
    first_name: String(max_length=50)
    last_name: String(max_length=50)
    email: String(required=True)
```

## Quick example: Projection with defaults method

```python
from enum import Enum

class BuildingStatus(Enum):
    WIP = "WIP"
    DONE = "DONE"

@domain.projection
class Building:
    building_id: Identifier(identifier=True)
    name: String(max_length=50)
    floors: Integer()
    status: String(choices=BuildingStatus)

    def defaults(self):
        if not self.status:
            if self.floors == 4:
                self.status = BuildingStatus.DONE.value
            else:
                self.status = BuildingStatus.WIP.value
```

## Querying read models

Projections are queried through the same repository/DAO interface as aggregates:

```python
repo = domain.repository_for(ProductInventory)

# Flat COUNT without loading rows
low_stock = repo._dao.query.filter(stock_quantity__lt=10).count()

# isnull lookup — records where a field is / isn't set
never_updated = repo._dao.query.filter(last_updated__isnull=True).all().items

# Fetch items without the separate total-count round-trip
page = repo._dao.query.filter(stock_quantity__lt=10).all(with_total=False).items
```

See the `repository` skill for the full querying surface (filtering, ordering,
pagination, Q objects).

`view_for()` and `connection_for()` operate on projections only; passing a
non-projection element (an aggregate, say) raises `USAGE_NOT_A_PROJECTION`.

## Common mistakes

### Missing identifier field

```python
@domain.projection
class UserView:
    name: String()  # Wrong! No identifier field
    email: String()
```

Instead: Add at least one identifier field

```python
@domain.projection
class UserView:
    user_id: Identifier(identifier=True)  # Correct!
    name: String()
    email: String()
```

### Using Reference or Association fields

```python
@domain.projection
class OrderView:
    order_id: Identifier(identifier=True)
    customer = Reference(Customer)      # Wrong!
    items = HasMany(OrderItem)           # Wrong!
```

`ValueObject` fields are allowed (they flatten to shadow fields); `Reference`
and Associations (`HasOne`/`HasMany`) are not. Flatten related data into basic
fields instead:

```python
@domain.projection
class OrderView:
    order_id: Identifier(identifier=True)
    customer_name: String()
    customer_email: String()
    item_count: Integer()
    shipping_city: String()
    shipping_zip: String()
```

### No provider and no cache

```python
domain.register(MyProjection, provider=None, cache=None)  # Wrong!
```

Instead: Always have at least a provider or a cache

### No projector to populate it

A projection with no projector is never populated, so queries against it always return empty. `check` reports this as `PROJECTION_WITHOUT_PROJECTOR`. Add a projector for the projection, or set `externally_populated=True` if a subscriber fills it instead.

### Field the projector never writes

A projection field no projector handler ever writes renders as a dead column. `check` reports this as `UNSOURCED_PROJECTION_FIELD`. Write every field from the projector handler for the event that carries it, or drop the field.

### Confusing projection with aggregate

```python
# Wrong! Projections are read models, not write models
@domain.projection
class Order:
    order_id: Identifier(identifier=True)
    items = HasMany(OrderItem)  # Fails! No associations

    def place(self):
        # Business logic doesn't belong in projections
        ...
```

Instead: Use `@domain.aggregate` for write models with business logic

## Detailed references

### Core Concepts
- [Basic Projection](references/basic-projection.md) - Defining a simple projection with field types
- [Configuration Options](references/configuration-options.md) - Provider, cache, schema_name, limit, and order_by
- [Persistence & Querying](references/persistence-querying.md) - Persisting and querying projection data
- [Field Type Restrictions](references/field-type-restrictions.md) - Why only basic types are allowed
- [Anti-patterns](references/anti-patterns.md) - Common mistakes and how to avoid them

### Complete Examples
- [Simple Projection](assets/projection_simple.py) - Basic projection with identifier and fields
- [Projection with Options](assets/projection_with_options.py) - Custom provider, schema, limit, ordering
- [Projection with Defaults](assets/projection_with_defaults.py) - Projection using defaults() method
- [Projection Persistence](assets/projection_persistence.py) - Persisting and querying projections
- [Field Validation](assets/projection_field_validation.py) - Demonstrating field type restrictions

### Related Skills
- `projector` - Projectors populate projections from domain events
- `aggregate` - Aggregates are the write-side counterpart to projections
- `event` - Events are the bridge between aggregates and projections
- `patterns/cqrs` - Projections implement the query side of CQRS

## Verify your work

- [Verify with check](../../references/verify-with-check.md): run `check` and resolve what it reports

Files in this skill

  • SKILL.md9.4 KB
  • assets/projection_field_validation.py5.1 KB
  • assets/projection_persistence.py2.4 KB
  • assets/projection_simple.py2.8 KB
  • assets/projection_with_defaults.py3.5 KB
  • assets/projection_with_options.py3.1 KB
  • references/anti-patterns.md4.2 KB
  • references/basic-projection.md3.2 KB
  • references/configuration-options.md3.5 KB
  • references/field-type-restrictions.md3.5 KB
  • references/persistence-querying.md2.6 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.