Projection Examples
These projections come from the Concert App tutorial. Each example highlights a different read-model pattern.
Artist popularity (atomic increments)
Counter columns updated with increment: — no read-modify-write race when many events hit the same artist.
# projections/artist_popularity.projections.causet
projections:
artist_popularity:
source_events: [ARTIST_FOLLOWED, ARTIST_UNFOLLOWED, USER_CHECKED_IN]
target:
table: artist_popularity
primary_key: [artist_id]
indexes:
- fields: [follower_count]
order: desc
fields:
artist_id: TEXT
follower_count: INTEGER
checkin_count: INTEGER
last_updated: BIGINT
derive:
artist_id: event.artist_id
last_updated: event.ts
mutations:
ARTIST_FOLLOWED:
op: upsert
increment:
follower_count: 1
ARTIST_UNFOLLOWED:
op: upsert
increment:
follower_count: -1
USER_CHECKED_IN:
op: upsert
increment:
checkin_count: 1Index follower_count DESC supports a leaderboard query without a full table scan.
User concert memory (per-event set: blocks)
Composite primary key [user_id, show_id]. Each mutation updates only the fields it owns.
# projections/concert_memory.projections.causet
projections:
user_concert_memory:
source_events: [CONCERT_MEMORY_CREATED, REVIEW_SUBMITTED]
target:
table: user_concert_memory
primary_key: [user_id, show_id]
indexes:
- fields: [user_id]
fields:
user_id: TEXT
show_id: TEXT
artist_id: TEXT
venue_id: TEXT
checked_in: BOOLEAN
has_review: BOOLEAN
rating: INTEGER
notes: TEXT
memory_at: BIGINT
last_updated: BIGINT
derive:
user_id: event.user_id
show_id: event.show_id
artist_id: event.artist_id
venue_id: event.venue_id
last_updated: event.ts
mutations:
CONCERT_MEMORY_CREATED:
op: upsert
set:
checked_in: true
has_review: false
memory_at: event.ts
REVIEW_SUBMITTED:
op: upsert
set:
has_review: true
rating: event.rating
notes: event.notesREVIEW_SUBMITTED does not overwrite checked_in or memory_at — partial row updates per mutation.
Show going vs check-ins (two tables, same composite PK)
Separate projections when two facts about the same (user_id, show_id) pair should be queried independently.
# projections/show_attendance.projections.causet
projections:
show_going:
source_events: [USER_MARKED_GOING]
target:
table: show_going
primary_key: [user_id, show_id]
indexes:
- fields: [show_id]
fields:
user_id: TEXT
show_id: TEXT
marked_at: BIGINT
derive:
user_id: event.user_id
show_id: event.show_id
marked_at: event.ts
mutations:
USER_MARKED_GOING: { op: upsert }
show_checkins:
source_events: [USER_CHECKED_IN]
target:
table: show_checkins
primary_key: [user_id, show_id]
indexes:
- fields: [show_id]
fields:
user_id: TEXT
show_id: TEXT
artist_id: TEXT
checked_in_at: BIGINT
derive:
user_id: event.user_id
show_id: event.show_id
artist_id: event.artist_id
checked_in_at: event.ts
mutations:
USER_CHECKED_IN: { op: upsert }User following (upsert + delete)
Relationship table used as a join source for show-discovery queries.
projections:
user_following:
source_events: [ARTIST_FOLLOWED, ARTIST_UNFOLLOWED]
target:
table: user_following
primary_key: [user_id, artist_id]
indexes:
- fields: [user_id]
fields:
user_id: TEXT
artist_id: TEXT
followed_at: BIGINT
derive:
user_id: event.user_id
artist_id: event.artist_id
followed_at: event.ts
mutations:
ARTIST_FOLLOWED: { op: upsert }
ARTIST_UNFOLLOWED: { op: delete }Artist show directory (upsert with mutation-specific set:)
Canonical show catalog — two source events, one table, different source values per mutation.
projections:
artist_show_directory:
source_events: [SHOW_ANNOUNCED, SHOW_IMPORTED]
target:
table: artist_show_directory
primary_key: [show_id]
indexes:
- fields: [artist_id]
- fields: [date]
fields:
show_id: TEXT
artist_id: TEXT
venue_id: TEXT
title: TEXT
date: TEXT
source: TEXT
created_at: BIGINT
derive:
show_id: event.show_id
artist_id: event.artist_id
venue_id: event.venue_id
title: event.title
date: event.date
created_at: event.ts
mutations:
SHOW_ANNOUNCED:
op: upsert
set:
source: "announced"
SHOW_IMPORTED:
op: upsert
set:
source: event.sourceFriend activity feed (fan-out)
One domain event becomes many feed rows — one per user who follows the artist.
# projections/friend_activity.projections.causet
projections:
friend_activity_feed:
source_events: [SHOW_ANNOUNCED, USER_CHECKED_IN, REVIEW_SUBMITTED]
target:
table: friend_activity_feed
primary_key: [feed_key]
indexes:
- fields: [user_id, occurred_at]
order: desc
fields:
feed_key: TEXT
user_id: TEXT
actor_id: TEXT
event_type: TEXT
show_id: TEXT
artist_id: TEXT
occurred_at: BIGINT
derive:
feed_key: concat(state.user_id, ":", event.event_id)
user_id: state.user_id
actor_id: event.user_id
event_type: event.event_type
show_id: event.show_id
artist_id: event.artist_id
occurred_at: event.ts
fan_out:
from: user_following
join_on: user_following.artist_id = event.artist_id
yield: user_following.user_id as state.user_id
mutations:
SHOW_ANNOUNCED: { op: upsert }
USER_CHECKED_IN: { op: upsert }
REVIEW_SUBMITTED: { op: upsert }For artists with very large followings, fan-out write volume can be significant. See the Concert App design notes on feed size limits.
More examples
| Example | Projections demonstrated |
|---|---|
| Concert App | Full projection catalog with design decisions |
| First Projection tutorial | Concert app projections end-to-end |
| First Query tutorial | Join query over concert app tables |
| Complete Concert App | Full assemble, compile, deploy, run |
| Rebuilding Projections | Fix a bug and replay |
| User Notifications | Action → projection → query flow |
Next steps
- Defining Projections — full schema reference
- Partitioning — shard keys and hot aggregates
- Best Practices — fields, indexes, rebuilds
- Query Examples — named queries against these tables