ProjectionsExamples

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: 1

Index 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.notes

REVIEW_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.source

Friend 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

ExampleProjections demonstrated
Concert AppFull projection catalog with design decisions
First Projection tutorialConcert app projections end-to-end
First Query tutorialJoin query over concert app tables
Complete Concert AppFull assemble, compile, deploy, run
Rebuilding ProjectionsFix a bug and replay
User NotificationsAction → projection → query flow

Next steps