Projections

View Source

Projections in AshCommanded define how events affect the state of your resources. They are the read model update mechanism in the CQRS pattern. In Commanded, projections transform domain events into a read-optimized format.

Defining Projections

Projections are defined in the commanded DSL extension for Ash resources:

defmodule ECommerce.Customer do
  use Ash.Resource,
    extensions: [AshCommanded.Commanded.Dsl]

  attributes do
    uuid_primary_key :id
    attribute :email, :string
    attribute :name, :string
    attribute :status, :string
  end

  commanded do
    events do
      event :customer_registered do
        fields([:id, :email, :name])
      end

      event :customer_status_updated do
        fields([:id, :status])
      end
    end

    projections do
      projection :customer_registered do
        action(:create)
        changes(%{
          status: "pending"
        })
      end

      projection :customer_status_updated do
        action(:update_by_id)
        changes(&Map.take(&1, [:status]))
      end
    end
  end
end

Projection Options

Each projection can have the following options:

  • action: The Ash action to use when handling the event (:create, :update, etc.)
  • changes: Static map or function to determine the changes to apply
  • projector_name: Override the generated projector module name

Generated Projector Modules

AshCommanded generates a projector module for each resource with projections. This projector is a Commanded event handler that subscribes to events and updates the read model. It leverages Commanded's projection support to handle the events efficiently.

defmodule ECommerce.Projectors.CustomerProjector do
  @moduledoc """
  Projector for Customer-related events
  """

  use Commanded.Projections.Ecto, 
    name: "ECommerce.Projectors.CustomerProjector"
  # See Commanded's Ecto projections: https://hexdocs.pm/commanded_ecto_projections/

  # Each projection gets a project/3 function
  project(%ECommerce.Events.CustomerRegistered{} = event, _metadata, fn _context ->
    Ash.Changeset.new(ECommerce.Customer, event)
    |> Ash.Changeset.for_action(:create, %{
      id: event.id,
      email: event.email, 
      name: event.name,
      status: "pending"
    })
    |> Ash.create()
  end)
  
  project(%ECommerce.Events.CustomerStatusUpdated{} = event, _metadata, fn _context ->
    Ash.Changeset.new(ECommerce.Customer, event)
    |> Ash.Changeset.for_action(:update_by_id, %{
      status: event.status
    })
    |> Ash.update()
  end)
  
  # Helper functions for applying different action types
  defp apply_action_fn(:create), do: &Ash.create/1
  defp apply_action_fn(:update), do: &Ash.update/1
  defp apply_action_fn(:destroy), do: &Ash.destroy/1
end

Registering Projectors with Commanded

The generated projectors need to be registered with your Commanded application to start processing events. Add them to your application's supervisor tree:

defmodule ECommerce.Application do
  use Application

  def start(_type, _args) do
    children = [
      # ...other children
      
      # Start your projectors
      ECommerce.Projectors.CustomerProjector
    ]

    opts = [strategy: :one_for_one, name: ECommerce.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

For more complex projection needs, you can customize the project/3 function directly in your own projector module:

defmodule ECommerce.CustomProjector do
  use Commanded.Projections.Ecto, name: "ECommerce.CustomProjector"

  project(%ECommerce.Events.CustomerRegistered{} = event, metadata, fn _context ->
    # Access event and metadata
    customer_id = event.id
    timestamp = metadata.created_at
    
    # Custom projection logic
    Ash.Changeset.new(ECommerce.Customer)
    |> Ash.Changeset.for_action(:create, %{
      id: customer_id,
      email: event.email,
      name: event.name,
      status: "pending",
      registered_at: timestamp
    })
    |> Ash.create()
  end)
end

Each resource typically gets one projector module containing handlers for all projections. The projector:

  1. Automatically registers with Commanded to receive events
  2. Processes events in real-time as they occur
  3. Updates the read models (resources) using Ash actions
  4. Maintains consistency between write and read models

Customizing Projector Generation

You can customize the projector generation with several options:

projection :user_registered do
  # Specify the action to perform (create, update, destroy)
  action(:create)
  
  # Changes to apply
  changes(%{status: "active"})
  
  # Custom projector module name
  projector_name(:CustomUserProjector)
  
  # Disable projector generation for this projection
  autogenerate?(false)
end

For a resource with many projectors, you can also set the projector namespace:

defmodule ECommerce.Customer do
  use Ash.Resource,
    extensions: [AshCommanded.Commanded.Dsl]
    
  # Set custom namespace for all projectors
  @projector_namespace ECommerce.CustomProjectors
  
  # Resource definition...
end

Change Types

The changes option supports two formats:

  1. Static map - simple key-value changes:

    changes(%{
    status: "active"
    })
  2. Function - dynamic changes based on the event:

    changes(fn event ->
    %{
     status: event.status,
     last_updated: DateTime.utc_now()
    }
    end)

Or more concisely with capture syntax:

changes(&Map.take(&1, [:status]))