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
| Module | Role |
|---|---|
Counterpoint.Event | Domain event: serialisable struct stored in the log |
Counterpoint.Command | Reads events, validates, appends new ones |
Counterpoint.CommandWithEffect | Like Command but with external deps injected |
Counterpoint.OnDemandProjection | Folds events into state at query time |
Counterpoint.Projection | Simpler fold without limit/reverse support |
Counterpoint.Query | Composable 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