Counterpoint

Counterpoint applies the UNIX philosophy to the backend: small, autonomous feature slices that compose through a shared stream of events.

The relational database is the usual culprit for inter-feature coupling. Shared tables, foreign keys, and God-schemas make it hard to change one feature without touching another. The standard event-sourcing response is to define a stream per aggregate and enforce it as the consistency boundary but that trades one constraint for another: now your business rules must fit inside a single stream.

DCB - Dynamic Consistency Boundaries dissolves that constraint. Rather than routing all writes through a fixed stream, commands declare exactly which events they care about and the store guarantees consistency against those events only. Features stay autonomous; their event queries compose freely across the shared log.

Counterpoint is the Elixir layer on top of DCB: events, commands, projections, and automations : each scoped to a feature slice, composing through the event stream.

NB: Aggregates are still a valid way to group commands : you just aren't forced into them. And because the log is the source of truth and streams are defined at query time, your model can evolve freely as you discover it.

lib/my_app/
├── orders/
│ ├── commands/
│ │ └── place_order.ex # validates + appends OrderPlaced
│ ├── events/
│ │ └── order_placed.ex # immutable fact
│ └── views/
│ └── order_summary.ex # folds events → %OrderSummary{}
├── inventory/
│ └── ...

Core building blocks

ModuleRole
Counterpoint.EventDomain event: serialisable struct stored in the log
Counterpoint.CommandReads events, validates, appends new ones
Counterpoint.CommandWithEffectLike Command but with external deps injected
Counterpoint.OnDemandProjectionFolds events into state at query time
Counterpoint.ProjectionSimpler fold without limit/reverse support
Counterpoint.QueryComposable filter by event types and tags

A worked example

1. Define an event

defmodule MyApp.Orders.Events.OrderPlaced do
use Counterpoint.Event
defstruct [:order_id, :total, :occurred_at]
def tags(%__MODULE__{order_id: id}), do: ["order_id:#{id}"]
def to_map(%__MODULE__{order_id: id, total: t, occurred_at: ts}),
do: %{"order_id" => id, "total" => t, "occurred_at" => DateTime.to_iso8601(ts)}
def from_map(%{"order_id" => id, "total" => t, "occurred_at" => ts}),
do: %__MODULE__{order_id: id, total: t, occurred_at: ts}
end

2. Write a command

The command reads state, enforces a rule, and appends an event if all is well. Optimistic concurrency is built in: if a concurrent write lands between your read and append, the runner retries automatically.

defmodule MyApp.Orders.Commands.PlaceOrder do
use Counterpoint.Command
import Counterpoint.ReadAppender
alias Counterpoint.Query
alias MyApp.Orders.Events.OrderPlaced
defstruct [:order_id, :total]
@impl Counterpoint.Command
def run(%__MODULE__{order_id: id, total: total}, ra) do
{existing, ra} =
read_events(ra, Query.new() |> Query.add_item(types: [OrderPlaced], tags: ["order_id:#{id}"]))
if Enum.any?(existing) do
{:error, :already_placed}
else
append_event(ra, %OrderPlaced{order_id: id, total: total, occurred_at: DateTime.utc_now()})
end
end
end

Execute it:

Counterpoint.CommandRunner.run(:my_store, %PlaceOrder{order_id: "ord-1", total: 99})

3. Build a read model

Projections are just a query + a fold. No persistence layer needed for in-memory reads.

defmodule MyApp.Orders.Views.OrderSummary do
use Counterpoint.OnDemandProjection
alias Counterpoint.Query
alias MyApp.Orders.Events.OrderPlaced
defstruct [:order_id, :total]
@impl Counterpoint.OnDemandProjection
def query(order_id),
do: Query.new() |> Query.add_item(types: [OrderPlaced], tags: ["order_id:#{order_id}"])
@impl Counterpoint.OnDemandProjection
def init, do: %__MODULE__{}
@impl Counterpoint.OnDemandProjection
def apply(state, %Counterpoint.Envelope{data: %OrderPlaced{order_id: id, total: t}}),
do: %{state | order_id: id, total: t}
end

Query it:

Counterpoint.OnDemandProjection.run(MyApp.Orders.Views.OrderSummary, :my_store, "ord-1")
# => %OrderSummary{order_id: "ord-1", total: 99}

Wiring it up

Add to your supervision tree:

def start(_type, _args) do
children = [
{Counterpoint.Supervisor,
store: [name: :my_store, namespace: "my_app"],
events: [MyApp.Orders.Events.OrderPlaced]}
]
Supervisor.start_link(children, strategy: :one_for_one)
end

Projections beyond in-memory

On-demand in-memory projections (above) cover most read needs. For continuous read models — updating a Postgres table, a search index, or a cache as events arrive — use automations: background workers that watch the event log and react to new events. See Counterpoint.Automation for details.

Installation

def deps do
[
{:counterpoint, "~> 0.1.0"}
]
end

Requires Elixir 1.18+ and a running FoundationDB cluster for the DCB event store.

Optional extras:

{:oban, "~> 2.18"} # for the Oban queue adapter (distributed automations)
{:plug, "~> 1.16"} # for the HTTP integration helpers