Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ One dependency is optional:

## A minimal example

Define a router that registers a service and the module that handles it:
Define a router that registers a service and the module that handles it,
and register the services (typically from your application's `start/2`).

```elixir
defmodule MyApp.Router do
Expand All @@ -51,14 +52,13 @@ defmodule MyApp.Greeter do
{:reply, Message.ok(message, "Hello, #{name}!")}
end
end

:ok = MyApp.Router.register_services()
```

Register the services (typically from your application's `start/2`) and dispatch a
message to the service by name:
Dispatch a message to the service by name:

```elixir
:ok = MyApp.Router.register_services()

:greet
|> X3m.System.Message.new(raw_request: %{"name" => "Ada"})
|> X3m.System.Dispatcher.dispatch()
Expand All @@ -78,6 +78,30 @@ flowchart LR
No aggregates or event store are involved here — any module registered through a
router can be a dispatch target.

## Dispatch accross the cluster

If you want to try dispatch accross the cluster, run 2 iex sessions:

```bash
iex --sname x3m_1@localhost -S mix
```

and from the other terminal

```bash
iex --sname x3m_2@localhost -S mix
```

Define your router and message handler (Greeter here) and register your services in first iex session (like in example from above),
and then from the second one, connect to the first node:

```elixir
Node.connect :"x3m_1@localhost"
#=> true
```

... and then dispatch message the same way you did in previous example. Result will be the same.

## Guides

- [Getting started](guides/getting-started.md) — install, optional deps, your first dispatch.
Expand All @@ -86,6 +110,12 @@ router can be a dispatch target.
- [Distribution](guides/distribution.md) — service discovery across nodes, choosing the node, and forwarding.
- [Scheduling](guides/scheduling.md) — persistable, future-dated message delivery with `Scheduler`.

## Example

The [Banking example](examples/bank/) is a complete poncho project demonstrating the
full CQRS/ES flow — HTTP API, command aggregates, event store, listener-driven read
model, and cross-node dispatch. See its README for setup and curl walkthrough.

## License

Released under the MIT License. See the [LICENSE](https://github.com/x3m-ex/system/blob/master/LICENSE) file.
9 changes: 9 additions & 0 deletions examples/bank/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
_volumes/pg_data/*
!_volumes/pg_data/.gitkeep
_volumes/es_data/*
!_volumes/es_data/.gitkeep
_build/
deps/
*.beam
*.ez
.elixir_ls/
248 changes: 248 additions & 0 deletions examples/bank/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
# Banking Example

A reference application demonstrating [x3m_system](../../README.md)'s full CQRS/ES flow.

Poncho project with five independent apps communicating via `X3m.System.Dispatcher`:

| App | Purpose |
|-----|---------|
| `banking` | Shared library (Identity, logger config, mix bless) |
| `banking_event_store` | Extreme TCP wrapper for EventStore |
| `banking_core` | Account aggregate (open, deposit, withdraw, close) |
| `banking_listeners` | Event listener → PG read model + query services |
| `banking_api` | Bandit + Plug HTTP API |

## Architecture

Each app runs as a separate Erlang node. The Dispatcher crosses node boundaries
transparently — the API node doesn't know (or care) which node hosts a given service.

```mermaid
graph TB
subgraph node_api["node: banking_api@localhost"]
Router["Plug.Router"]
SI["ServiceInvoker"]
D["Dispatcher"]
end

subgraph node_core["node: banking_core@localhost"]
CR["Core.Router"]
MH["MessageHandler"]
Agg["Account Aggregate"]
end

subgraph node_listeners["node: banking_listeners@localhost"]
LR["Listeners.Router"]
Listener["Extreme.Listener"]
Den["Denormalizer"]
QS["Query Services"]
end

subgraph infra["infrastructure (Docker)"]
ES[(EventStore)]
PG[(PostgreSQL)]
end

Router --> SI
SI --> D
D -.->|"cross-node call"| CR
D -.->|"cross-node call"| LR
CR -->|authorize + invoke| MH
MH --> Agg
Agg -->|events| ES
ES -->|subscription| Listener
Listener --> Den
Den -->|upsert| PG
LR -->|authorize + invoke| QS
QS -->|read| PG
```

## Command flow (write)

A command enters through HTTP, crosses node boundaries via the Dispatcher,
and reaches the aggregate which emits events persisted to EventStore.

```mermaid
sequenceDiagram
participant HTTP as curl / client

box rgb(40,60,90) banking_api@localhost
participant API as Api.Router
participant D as Dispatcher
end

box rgb(60,40,90) banking_core@localhost
participant CR as Core.Router
participant MH as MessageHandler
participant Agg as Account Aggregate
end

participant ES as EventStore

HTTP->>API: POST /accounts (JSON)
API->>D: Dispatcher.dispatch(message)
D->>CR: find :open_account service (cross-node)
CR->>CR: authorize/1
CR->>MH: invoke open_account
MH->>Agg: handle_msg (validate → execute)
Agg->>Agg: emit Opened event
Agg-->>MH: {:block, msg, state}
MH->>ES: persist events
ES-->>MH: {:ok, version}
MH->>Agg: apply_event (update state)
MH-->>D: {:created, id, version}
D-->>API: response
API-->>HTTP: 201 {"id": "..."}
```

## Query flow (read)

Queries dispatch to listener services on a different node that read from
the PostgreSQL projection — completely separate from the aggregate path.

```mermaid
sequenceDiagram
participant HTTP as curl / client

box rgb(40,60,90) banking_api@localhost
participant API as Api.Router
participant D as Dispatcher
end

box rgb(40,90,60) banking_listeners@localhost
participant LR as Listeners.Router
participant QS as GetAccount
end

participant PG as PostgreSQL

HTTP->>API: GET /accounts/:id
API->>D: Dispatcher.dispatch(message)
D->>LR: find :get_account service (cross-node)
LR->>LR: authorize/1
LR->>QS: invoke get_account
QS->>PG: Repo.get(Account, id)
PG-->>QS: row
QS-->>D: {:ok, data}
D-->>API: response
API-->>HTTP: 200 {"id": "...", "balance": 70, ...}
```

## Prerequisites

- Elixir >= 1.17
- Docker & Docker Compose

## Setup

Start infrastructure:

docker compose up -d

Install dependencies and compile each app:

for app in core listeners api; do
(cd apps/$app && mix deps.get)
done

## Running

Start each app in a separate terminal. Order doesn't matter.

**Terminal 1 — Core (aggregates):**

cd apps/core && ./run.sh

**Terminal 2 — Listeners (read model):**

cd apps/listeners && ./run.sh

**Terminal 3 — API (HTTP):**

cd apps/api && ./run.sh

The apps auto-connect via the shared `apps/.iex.exs` (Erlang distribution with `--sname`).

Browse persisted events in the EventStore web UI at
[http://localhost:2113/web/index.html#/streams/$ce-accounts](http://localhost:2113/web/index.html#/streams/$ce-accounts).

## Usage (curl)

Open an account and capture the returned id:

export ACC=$(curl -s -X POST http://localhost:4001/accounts \
-H "Content-Type: application/json" \
-d "{\"owner_id\": \"user-1\", \"invoked_by\": {\"user_id\": \"user-1\"}}" \
| grep -o '"id":"[^"]*"' | cut -d'"' -f4)

# → 201 {"id": "<uuid>"}

### Deposit

curl -sw "\n%{http_code}" -X PUT "http://localhost:4001/accounts/$ACC/deposit" \
-H "Content-Type: application/json" \
-d '{"amount": 100, "invoked_by": {"user_id": "user-1"}}'

# → 204

### Withdraw

curl -sw "\n%{http_code}" -X PUT "http://localhost:4001/accounts/$ACC/withdraw" \
-H "Content-Type: application/json" \
-d '{"amount": 30, "invoked_by": {"user_id": "user-1"}}'

# → 204

### Get account

curl -sw "\n%{http_code}" "http://localhost:4001/accounts/$ACC"

# → 200 {"id": "<uuid>", "owner_id": "user-1", "balance": 70, "status": "open"}

### List accounts (admin only)

curl -sw "\n%{http_code}" "http://localhost:4001/accounts?admin=true"

# → 200 [{"id": "<uuid>", "owner_id": "user-1", "balance": 70, "status": "open"}, ...]

### Close account

curl -sw "\n%{http_code}" -X PUT "http://localhost:4001/accounts/$ACC/close" \
-H "Content-Type: application/json" \
-d '{"invoked_by": {"admin?": true}}'

# → 204
# If balance was > 0, a Withdrawn event is emitted first, then Closed.

### Error responses

Withdraw more than the balance:

curl -sw "\n%{http_code}" -X PUT "http://localhost:4001/accounts/$ACC/withdraw" \
-H "Content-Type: application/json" \
-d '{"amount": 9999, "invoked_by": {"user_id": "user-1"}}'

# → 422 {"errors": {"amount": ["insufficient funds"]}}

Wrong user:

curl -sw "\n%{http_code}" -X PUT "http://localhost:4001/accounts/$ACC/deposit" \
-H "Content-Type: application/json" \
-d '{"amount": 10, "invoked_by": {"user_id": "other-user"}}'

# → 403 {"error": "forbidden"}

Close an already-closed account:

curl -sw "\n%{http_code}" -X PUT "http://localhost:4001/accounts/$ACC/close" \
-H "Content-Type: application/json" \
-d '{"invoked_by": {"user_id": "user-1"}}'

# → 409 {"error": "account is closed"}

Missing required field:

curl -sw "\n%{http_code}" -X POST http://localhost:4001/accounts \
-H "Content-Type: application/json" -d '{}'

# → 422 {"errors": {"owner_id": ["can't be blank"]}}
Empty file.
Empty file.
34 changes: 34 additions & 0 deletions examples/bank/apps/.iex.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
defmodule BankingClusterBoot do
@moduledoc false

def connect_to_peers do
{:ok, hostname} = :inet.gethostname()
self_node = Node.self()

peers =
[:banking_core_1, :banking_listeners_1, :banking_api_1]
|> Enum.map(&:"#{&1}@#{hostname}")
|> Enum.reject(&(&1 == self_node))

_wait_for_peers(peers, _attempts = 30)
end

defp _wait_for_peers(_peers, 0) do
IO.puts("[cluster] timed out waiting for peers; Node.list/0 = #{inspect(Node.list())}")
end

defp _wait_for_peers(peers, attempts) do
connected =
peers
|> Enum.filter(&(Node.connect(&1) == true))

if connected != [] do
IO.puts("[cluster] connected to #{inspect(connected)}; Node.list/0 = #{inspect(Node.list())}")
else
Process.sleep(500)
_wait_for_peers(peers, attempts - 1)
end
end
end

spawn(fn -> BankingClusterBoot.connect_to_peers() end)
4 changes: 4 additions & 0 deletions examples/bank/apps/api/.formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[
import_deps: [:plug, :x3m_system],
inputs: ["{config,lib,test}/**/*.{ex,exs}"]
]
7 changes: 7 additions & 0 deletions examples/bank/apps/api/config/config.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Config

import_config "../../banking/config/config.exs"

config :banking_api, port: 4001

import_config "#{Mix.env()}.exs"
1 change: 1 addition & 0 deletions examples/bank/apps/api/config/dev.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import Config
1 change: 1 addition & 0 deletions examples/bank/apps/api/config/test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import Config
Loading
Loading