Associations Guide
View SourceThis guide covers how to handle Ecto associations with Spector event-sourced schemas. The key insight is that event sourcing stores changes to individual records, so associations work differently than in traditional Ecto applications.
Overview
Event sourcing captures changes as immutable events. When dealing with associations, you need to consider:
- Where is the foreign key stored? The schema that stores the foreign key is straightforward to event-source.
- Can the relationship be rebuilt from events? If replaying events can't reconstruct the relationship, you need a different approach.
- Who owns the relationship data? This determines which schema's events capture changes.
belongs_to (Straightforward)
belongs_to associations are the simplest case because the foreign key is stored directly in the schema that declares the association.
Schema Definition
defmodule MyApp.Item do
use Spector.Evented, events: MyApp.Events
use Ecto.Schema
alias Ecto.Changeset
schema "items" do
field :title, :string
belongs_to :workspace, MyApp.Workspace
timestamps()
end
def changeset(changeset, attrs) do
changeset
|> Changeset.cast(attrs, [:title, :workspace_id])
|> Changeset.validate_required([:title, :workspace_id])
|> Changeset.foreign_key_constraint(:workspace_id)
end
endUsage
# Create an item belonging to a workspace
{:ok, item} = Spector.insert(MyApp.Item, %{
title: "New Task",
workspace_id: workspace.id
})
# The workspace_id is stored in the event payload
# Replaying events correctly rebuilds the associationThe foreign key (workspace_id) is cast like any other field. Events capture changes to this field, and replaying events correctly reconstructs which workspace the item belongs to.
Why Not has_one
has_one associations are problematic with event sourcing and are generally not recommended.
The Problem
With has_one, the "owned" side stores the foreign key, not the owner:
defmodule MyApp.User do
has_one :profile, MyApp.Profile # Profile stores user_id
end
defmodule MyApp.Profile do
belongs_to :user, MyApp.User # user_id is here
endWhen you replay events for a User, you only see changes to the User schema. The user_id field lives in Profile, so the User's event history knows nothing about whether a Profile exists or which one it points to.
The Same Problem as has_many
has_one has the same conceptual issues as has_many - the "many" side stores the foreign key. The only difference is cardinality, which doesn't help with event replay.
Recommendations
- Use belongs_to from the other side: Query the Profile by
user_idwhen you need it - Embed the data: If the profile is truly owned by the user, consider using an embedded schema or JSON field
- Accept the limitation: Preload the association separately after rebuilding from events
has_many (Child Stores Parent ID)
has_many associations work well when each child is event-sourced independently.
The Pattern
defmodule MyApp.Item do
use Spector.Evented, events: MyApp.Events
use Ecto.Schema
schema "items" do
field :title, :string
has_many :comments, MyApp.Comment
timestamps()
end
end
defmodule MyApp.Comment do
use Spector.Evented, events: MyApp.Events
use Ecto.Schema
alias Ecto.Changeset
schema "comments" do
field :body, :string
belongs_to :item, MyApp.Item
timestamps()
end
def changeset(changeset, attrs) do
changeset
|> Changeset.cast(attrs, [:body, :item_id])
|> Changeset.validate_required([:body, :item_id])
end
endWhy This Works
- Each Comment is an independent event-sourced entity
- Comments store their own
item_id - Replaying Comment events rebuilds which Item each Comment belongs to
- No special Spector handling needed
Usage
# Create a comment on an item
{:ok, comment} = Spector.insert(MyApp.Comment, %{
body: "Great work!",
item_id: item.id
})
# Query comments for an item
comments = MyApp.Repo.all(
from c in MyApp.Comment,
where: c.item_id == ^item.id
)
# Or preload through the association
item = MyApp.Repo.preload(item, :comments)many_to_many (Virtual Field Pattern)
many_to_many associations require special handling because the relationship data lives in a join table, not in either schema.
The Challenge
Events capture changes to schema fields. A join table is a separate entity, so changes to relationships aren't automatically tracked. The solution is to:
- Store relationship IDs in a virtual field
- Capture changes to this virtual field in events
- Sync to the join table using
prepare_materialization/1
Schema Definition
defmodule MyApp.Item do
@behaviour Spector.Evented
use Spector.Evented, events: MyApp.Events
use Ecto.Schema
alias Ecto.Changeset
schema "items" do
field :title, :string
field :member_ids, {:array, :binary_id}, virtual: true, default: []
many_to_many :members, MyApp.Member, join_through: "item_members"
timestamps()
end
def changeset(changeset, attrs) do
changeset
|> Changeset.cast(attrs, [:title, :member_ids])
|> Changeset.validate_required([:title])
end
@impl true
def prepare_materialization(changeset) do
member_ids = Changeset.get_field(changeset, :member_ids) || []
members = Enum.map(member_ids, &%MyApp.Member{id: &1})
Changeset.put_assoc(changeset, :members, members)
end
endJoin Table Migration (No Schema)
This simple case uses join_through: "item_members" with a table name string. There is no Ecto schema for the join table—Ecto manages it directly. This works when you only need to track which records are related, with no additional metadata.
defmodule MyApp.Repo.Migrations.CreateItemMembers do
use Ecto.Migration
def change do
create table(:item_members, primary_key: false) do
add :item_id, references(:items, type: :binary_id, on_delete: :delete_all), null: false
add :member_id, references(:members, type: :binary_id, on_delete: :delete_all), null: false
end
create unique_index(:item_members, [:item_id, :member_id])
create index(:item_members, [:member_id])
end
endHow It Works
- On insert/update: The
member_idsvirtual field is cast into the changeset - Event creation: The
member_idsarray is stored in the event payload - On replay: Events rebuild the
member_idsvirtual field - Materialization:
prepare_materialization/1converts IDs to association structs - Database sync:
put_assochandles the join table insert/update/delete
Usage
# Create with members
{:ok, item} = Spector.insert(MyApp.Item, %{
title: "Team Task",
member_ids: [user1.id, user2.id]
})
# Update members
{:ok, item} = Spector.update(%{item | member_ids: [user1.id, user2.id, user3.id]})
# The event log captures:
# 1. Insert event with member_ids: [user1.id, user2.id]
# 2. Update event with member_ids: [user1.id, user2.id, user3.id]Handling Updates (Preloading)
For updates, you need to preload existing member IDs into the virtual field:
def get_item_for_update(id) do
item = MyApp.Repo.get!(MyApp.Item, id) |> MyApp.Repo.preload(:members)
%{item | member_ids: Enum.map(item.members, & &1.id)}
end
# Now updates work correctly
item = get_item_for_update(item.id)
{:ok, updated} = Spector.update(%{item | member_ids: new_member_ids})Join Table with Join Schema (Metadata on Association)
When you need to store metadata on the relationship itself (e.g., role, permissions, timestamps), use a join schema instead of a bare table name.
Join Schema Definition
defmodule MyApp.ItemMember do
use Ecto.Schema
alias Ecto.Changeset
@primary_key false
schema "item_members" do
belongs_to :item, MyApp.Item, primary_key: true
belongs_to :member, MyApp.Member, primary_key: true
field :role, :string, default: "viewer"
field :added_at, :utc_datetime
end
def changeset(item_member \\ %__MODULE__{}, attrs) do
item_member
|> Changeset.cast(attrs, [:item_id, :member_id, :role, :added_at])
|> Changeset.validate_required([:item_id, :member_id])
|> Changeset.validate_inclusion(:role, ["viewer", "editor", "admin"])
end
endUpdated Item Schema
Instead of storing just IDs, store maps with the metadata:
defmodule MyApp.Item do
@behaviour Spector.Evented
use Spector.Evented, events: MyApp.Events
use Ecto.Schema
alias Ecto.Changeset
schema "items" do
field :title, :string
# Store member data as maps: %{member_id: uuid, role: "editor"}
field :member_data, {:array, :map}, virtual: true, default: []
many_to_many :members, MyApp.Member, join_through: MyApp.ItemMember
timestamps()
end
def changeset(changeset, attrs) do
changeset
|> Changeset.cast(attrs, [:title, :member_data])
|> Changeset.validate_required([:title])
end
@impl true
def prepare_materialization(changeset) do
member_data = Changeset.get_field(changeset, :member_data) || []
item_id = Changeset.get_field(changeset, :id)
item_members = Enum.map(member_data, fn data ->
%MyApp.ItemMember{
item_id: item_id,
member_id: data["member_id"],
role: data["role"] || "viewer",
added_at: parse_datetime(data["added_at"])
}
end)
Changeset.put_assoc(changeset, :members, item_members)
end
defp parse_datetime(nil), do: nil
defp parse_datetime(str) when is_binary(str), do: DateTime.from_iso8601(str) |> elem(1)
defp parse_datetime(%DateTime{} = dt), do: dt
endUsage
# Create with members and roles
{:ok, item} = Spector.insert(MyApp.Item, %{
title: "Team Project",
member_data: [
%{member_id: alice.id, role: "admin", added_at: DateTime.utc_now()},
%{member_id: bob.id, role: "editor", added_at: DateTime.utc_now()}
]
})
# Update a member's role
item = get_item_for_update(item.id)
updated_data = Enum.map(item.member_data, fn
%{"member_id" => id} = data when id == bob.id ->
Map.put(data, "role", "admin")
data ->
data
end)
{:ok, item} = Spector.update(%{item | member_data: updated_data})Preloading with Metadata
def get_item_for_update(id) do
item = MyApp.Repo.get!(MyApp.Item, id)
|> MyApp.Repo.preload(members: :item_member)
member_data = Enum.map(item.members, fn member ->
# Access the join record through the preloaded association
join = Enum.find(member.item_members, &(&1.item_id == item.id))
%{
"member_id" => member.id,
"role" => join.role,
"added_at" => join.added_at
}
end)
%{item | member_data: member_data}
endThe key difference from the simple case: events capture the full relationship data (including metadata), not just IDs. This lets you replay events and reconstruct not just which members are associated, but how they're associated.
Polymorphic Relations (Advanced)
For relationships between different entity types, store type+id pairs.
Schema Definition
defmodule MyApp.Item do
@behaviour Spector.Evented
use Spector.Evented, events: MyApp.Events, actions: [:add_relation, :remove_relation]
use Ecto.Schema
alias Ecto.Changeset
schema "items" do
field :title, :string
# Store relations as maps: %{type: "item", id: uuid}
field :relations, {:array, :map}, virtual: true, default: []
timestamps()
end
def changeset(changeset, attrs) when changeset.action == :add_relation do
current = Changeset.get_field(changeset, :relations) || []
new_relation = %{
"type" => Spector.get_attr(attrs, :relation_type),
"id" => Spector.get_attr(attrs, :relation_id)
}
Changeset.put_change(changeset, :relations, [new_relation | current])
end
def changeset(changeset, attrs) when changeset.action == :remove_relation do
current = Changeset.get_field(changeset, :relations) || []
remove_id = Spector.get_attr(attrs, :relation_id)
filtered = Enum.reject(current, &(&1["id"] == remove_id))
Changeset.put_change(changeset, :relations, filtered)
end
def changeset(changeset, attrs) do
changeset
|> Changeset.cast(attrs, [:title, :relations])
|> Changeset.validate_required([:title])
end
@impl true
def prepare_materialization(changeset) do
# Sync relations to a join table if needed
relations = Changeset.get_field(changeset, :relations) || []
item_id = Changeset.get_field(changeset, :id)
relation_structs = Enum.map(relations, fn rel ->
%MyApp.ItemRelation{
item_id: item_id,
target_type: rel["type"],
target_id: rel["id"]
}
end)
Changeset.put_assoc(changeset, :item_relations, relation_structs)
end
endJoin Table
defmodule MyApp.ItemRelation do
use Ecto.Schema
@primary_key false
schema "item_relations" do
field :item_id, :binary_id
field :target_type, :string
field :target_id, :binary_id
end
endUsage
# Add a dependency on another item
{:ok, item} = Spector.execute(item, :add_relation, %{
relation_type: "item",
relation_id: other_item.id
})
# Add a dependency on a milestone
{:ok, item} = Spector.execute(item, :add_relation, %{
relation_type: "milestone",
relation_id: milestone.id
})Additional Join Table Patterns
has_many :through
For relationships like Item -> ItemMember -> Member -> MemberRole:
# Option 1: Event-source the middle entity
defmodule MyApp.ItemMember do
use Spector.Evented, events: MyApp.Events
schema "item_members" do
belongs_to :item, MyApp.Item
belongs_to :member, MyApp.Member
field :role, :string
end
end
# Option 2: Virtual field on the source
# Store the full path: [{member_id: x, role: y}, ...]Self-Referential many_to_many
For items that depend on other items:
defmodule MyApp.Item do
schema "items" do
field :dependency_ids, {:array, :binary_id}, virtual: true, default: []
many_to_many :dependencies, MyApp.Item,
join_through: "item_dependencies",
join_keys: [source_id: :id, target_id: :id]
end
endThe join table needs distinct column names:
create table(:item_dependencies, primary_key: false) do
add :source_id, references(:items, type: :binary_id), null: false
add :target_id, references(:items, type: :binary_id), null: false
end
create unique_index(:item_dependencies, [:source_id, :target_id])Temporal/Versioned Joins
For tracking when relationships were active:
defmodule MyApp.ItemMembership do
use Spector.Evented, events: MyApp.Events
schema "item_memberships" do
belongs_to :item, MyApp.Item
belongs_to :member, MyApp.Member
field :joined_at, :utc_datetime
field :left_at, :utc_datetime
end
endEvent-sourcing the join table itself provides a complete audit trail of relationship changes.
Best Practices
Prefer belongs_to: The simplest pattern. Put the foreign key on the side you're event-sourcing.
Event-source children independently: For
has_many, let each child be its own event-sourced entity rather than trying to manage the list from the parent.Use virtual fields for many_to_many: Store IDs in a virtual field, sync to the join table in
prepare_materialization/1.Consider embedded schemas: For truly owned data that doesn't need independent identity, embedded schemas or JSON fields avoid association complexity.
Be explicit about ownership: Decide which side "owns" the relationship and captures changes in its events.
Preload before updates: For virtual field patterns, load existing relationship IDs into the virtual field before making changes.
Document your patterns: Association handling varies by use case. Document the pattern each relationship uses.
See Also
- Building a Basic Chat - Uses belongs_to for conversation membership
- Event Links Guide - For event-to-event relationships within the same record