Defining Relationships
A relationship is an edge between two entity instances. Relationships model the connections between entities — who follows whom, which users are friends, which tickets belong to which shows.
The engine manages relationship edges in a dedicated store. You do not need to build a separate join table projection to track simple membership — though you can use projections to query relationship data efficiently.
Syntax
relationships:
artist_followers:
from: user
to: artist
cardinality: many_to_many
unique: true
emit_events:
created: ARTIST_FOLLOWED
removed: ARTIST_UNFOLLOWED
friend_requests:
from: user
to: user
cardinality: many_to_many
unique: true
emit_events:
created: FRIEND_REQUEST_SENT
removed: FRIEND_REQUEST_CANCELLED
show_attendees:
from: user
to: show
cardinality: many_to_many
unique: true
emit_events:
created: CONCERT_ATTENDEDFields
| Field | Required | Description |
|---|---|---|
from | Yes | The source entity type. |
to | Yes | The target entity type. |
cardinality | Yes | many_to_many or one_to_many. |
unique | No | If true, duplicate edges are no-ops. Default: false. |
emit_events | No | Event types to emit when edges are created or removed. |
Cardinality
many_to_many
Any number of from entities can be connected to any number of to entities. A user can follow many artists. An artist can be followed by many users.
one_to_many
One from entity can be connected to many to entities, but each to entity can only have one from entity. Useful for ownership relationships — a user owns many orders, but each order belongs to one user.
unique: true
When unique: true, the engine deduplicates edges at the storage level. If a relationship_create operation produces an edge that already exists (same from_id + to_id), it is treated as a no-op — no error, no duplicate edge.
This is important for idempotency. If a FOLLOW_ARTIST intent is submitted twice (e.g. due to a retry), the second relationship_create is silently ignored at the engine level.
Note:
unique: trueis a no-op deduplication, not a semantic validation. If you want to surface an error when a user tries to follow the same artist twice, you should still add an explicit preflight rule that rejects the duplicate attempt.unique: trueonly prevents the data corruption — it does not give the caller a meaningful error code.
# Preflight check pairs naturally with unique: true
actions:
FOLLOW_ARTIST:
state: user
entity_id_expr: intent.user_id
input:
user_id: { type: string, required: true }
artist_id: { type: string, required: true }
preflight:
rules:
- name: not_already_following
when: { expr: "LOOKUP_IS_FOLLOWING == true" }
then:
- op: reject
code: ALREADY_FOLLOWING
message: "Already following this artist"
core:
rules:
- name: create_follow
when: {}
then:
- op: relationship_create
relationship: artist_followers
from_id: intent.user_id
to_id: intent.artist_idemit_events
When an edge is created or removed, the engine can automatically emit the configured event types. This decouples the edge management from the event emission — you don’t need a separate op: emit in side_effects.
relationships:
artist_followers:
from: user
to: artist
cardinality: many_to_many
unique: true
emit_events:
created: ARTIST_FOLLOWED
removed: ARTIST_UNFOLLOWEDThe emitted event must be declared in events: with a payload that makes sense for the relationship context. The engine populates the event with the relationship metadata (from_id, to_id).
If you need custom payload fields on the emitted event (e.g. followed_at), emit the event explicitly in core or side_effects instead of using emit_events on the relationship.
Operations
relationship_create
Creates an edge between two entity instances. Allowed in core rules.
core:
rules:
- name: create_follow
when: {}
then:
- op: relationship_create
relationship: artist_followers
from_id: intent.user_id
to_id: intent.artist_idrelationship_remove
Removes an edge. Allowed in core rules.
core:
rules:
- name: remove_follow
when: {}
then:
- op: relationship_remove
relationship: artist_followers
from_id: intent.user_id
to_id: intent.artist_idBoth operations are allowed in core — this is the one class of cross-stream operation permitted in core. You are modifying a shared relationship store, but the operation is managed by the engine and does not require loading a second entity’s state.
Querying relationship data
To query which entities are connected via a relationship, project the relationship events into a projection table and query it via a join.
# projections/follow.projections.causet
projections:
user_following:
source_events: [ARTIST_FOLLOWED, ARTIST_UNFOLLOWED]
target:
table: user_following
primary_key: [user_id, artist_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 }Then join in a query:
queries:
shows_for_followed_artists:
from: artist_show_directory
joins:
user_following:
on:
artist_show_directory.artist_id: user_following.artist_id
fields:
- user_following.user_id
fields:
- artist_show_directory.*
input:
user_id: { type: string, required: true }
where:
user_following.user_id: { eq: input.user_id }
order_by:
date: asc
limit: 50Next steps
- Examples — concert-app relationship patterns
- Best Practices — common mistakes
- Projections — materializing relationship events for queries