├── .tool-versions ├── test └── test_helper.exs ├── lib ├── coins │ ├── repo.ex │ ├── application.ex │ ├── schemas │ │ └── account.ex │ ├── router.ex │ ├── supervisor.ex │ ├── commands.ex │ ├── events.ex │ ├── send_coins_process.ex │ ├── account_projector.ex │ └── account.ex ├── proof.ex └── coins.ex ├── .formatter.exs ├── priv └── repo │ └── migrations │ ├── 20180203064248_create_accounts.exs │ └── 20180128184736_create_projection_versions.exs ├── .gitignore ├── config └── config.exs ├── mix.exs ├── README.md └── mix.lock /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.6.0 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(skip: :skip) 2 | -------------------------------------------------------------------------------- /lib/coins/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Coins.Repo do 2 | use Ecto.Repo, otp_app: :coins 3 | end 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/coins/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Coins.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, args) do 7 | Coins.Supervisor.start_link(args) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/coins/schemas/account.ex: -------------------------------------------------------------------------------- 1 | defmodule Coins.Schemas.Account do 2 | use Ecto.Schema 3 | 4 | @primary_key false 5 | 6 | schema "accounts" do 7 | field(:account_id, :string) 8 | field(:balance, :integer) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180203064248_create_accounts.exs: -------------------------------------------------------------------------------- 1 | defmodule Bank.Repo.Migrations.CreateAccounts do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:accounts, primary_key: false) do 6 | add(:account_id, :string, primary_key: true) 7 | add(:balance, :integer) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/coins/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Coins.Router do 2 | use Commanded.Commands.Router 3 | 4 | alias Coins.Account 5 | alias Coins.Commands, as: C 6 | 7 | dispatch( 8 | [ 9 | C.MineCoin, 10 | C.SendCoins, 11 | C.ReceiveCoins 12 | ], 13 | to: Account, 14 | identity: :account_id 15 | ) 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180128184736_create_projection_versions.exs: -------------------------------------------------------------------------------- 1 | defmodule CreateProjectionVersions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:projection_versions, primary_key: false) do 6 | add :projection_name, :text, primary_key: true 7 | add :last_seen_event_number, :bigint 8 | 9 | timestamps() 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/coins/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Coins.Supervisor do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | 6 | def start_link(arg) do 7 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 8 | end 9 | 10 | def init(_arg) do 11 | children = [ 12 | {Coins.Repo, []}, 13 | {Coins.AccountProjector, []}, 14 | {Coins.SendCoinsProcess, []} 15 | ] 16 | 17 | Supervisor.init(children, strategy: :one_for_one) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/coins/commands.ex: -------------------------------------------------------------------------------- 1 | defmodule Coins.Commands do 2 | defmodule MineCoin do 3 | defstruct [ 4 | :account_id, 5 | :nonce 6 | ] 7 | end 8 | 9 | defmodule SendCoins do 10 | defstruct [ 11 | :account_id, 12 | :to, 13 | :amount, 14 | :transfer_id 15 | ] 16 | end 17 | 18 | defmodule ReceiveCoins do 19 | defstruct [ 20 | :account_id, 21 | :amount, 22 | :transfer_id 23 | ] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/coins/events.ex: -------------------------------------------------------------------------------- 1 | defmodule Coins.Events do 2 | defmodule CoinMined do 3 | defstruct [ 4 | :account_id, 5 | :nonce 6 | ] 7 | end 8 | 9 | defmodule CoinsSent do 10 | defstruct [ 11 | :account_id, 12 | :to, 13 | :amount, 14 | :transfer_id 15 | ] 16 | end 17 | 18 | defmodule CoinsReceived do 19 | defstruct [ 20 | :account_id, 21 | :amount, 22 | :transfer_id 23 | ] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/proof.ex: -------------------------------------------------------------------------------- 1 | defmodule Proof do 2 | # Just to help find the valid nonces 3 | def nonces(string, amount, difficulty \\ 1) do 4 | 1 5 | |> Stream.iterate(&(&1 + 1)) 6 | |> Stream.filter(&proof(string, &1, difficulty)) 7 | |> Enum.take(amount) 8 | end 9 | 10 | def proof(string, nonce, difficulty \\ 1) do 11 | String.starts_with?( 12 | hash(hash(string) <> hash(to_string(nonce))), 13 | header(difficulty) 14 | ) 15 | end 16 | 17 | defp header(difficulty) do 18 | [<<0>>] 19 | |> Stream.cycle() 20 | |> Enum.take(difficulty) 21 | |> Enum.join("") 22 | end 23 | 24 | defp hash(x), do: :crypto.hash(:sha256, x) 25 | end 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | bank-*.tar 24 | 25 | -------------------------------------------------------------------------------- /lib/coins/send_coins_process.ex: -------------------------------------------------------------------------------- 1 | defmodule Coins.SendCoinsProcess do 2 | use Commanded.ProcessManagers.ProcessManager, 3 | name: "SendCoinsProcess", 4 | router: Coins.Router 5 | 6 | alias Coins.Commands, as: C 7 | alias Coins.Events, as: E 8 | 9 | defstruct [] 10 | 11 | def interested?(%E.CoinsSent{transfer_id: id}) do 12 | {:start, id} 13 | end 14 | 15 | def interested?(%E.CoinsReceived{transfer_id: id}) do 16 | {:stop, id} 17 | end 18 | 19 | def handle(_, %E.CoinsSent{} = evt) do 20 | %C.ReceiveCoins{ 21 | transfer_id: evt.transfer_id, 22 | account_id: evt.to, 23 | amount: evt.amount 24 | } 25 | end 26 | 27 | def apply(state, _event), do: state 28 | end 29 | -------------------------------------------------------------------------------- /lib/coins.ex: -------------------------------------------------------------------------------- 1 | defmodule Coins do 2 | @moduledoc """ 3 | Documentation for Coins. 4 | """ 5 | 6 | alias Coins.{ 7 | Commands, 8 | Transfer, 9 | Repo, 10 | Router, 11 | Schemas 12 | } 13 | 14 | def mine_coin(account_id, nonce) do 15 | %Commands.MineCoin{ 16 | account_id: account_id, 17 | nonce: nonce 18 | } 19 | |> Router.dispatch() 20 | end 21 | 22 | def send_coins(from, to, amount) do 23 | %Commands.SendCoins{ 24 | account_id: from, 25 | to: to, 26 | amount: amount, 27 | transfer_id: UUID.uuid4() 28 | } 29 | |> Router.dispatch() 30 | end 31 | 32 | def richest do 33 | import Ecto.Query 34 | 35 | Schemas.Account 36 | |> order_by(desc: :balance) 37 | |> limit(1) 38 | |> Repo.one() 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/coins/account_projector.ex: -------------------------------------------------------------------------------- 1 | defmodule Coins.AccountProjector do 2 | use Commanded.Projections.Ecto, name: "AccountProjector" 3 | 4 | alias Coins.Events, as: E 5 | alias Coins.Schemas, as: S 6 | 7 | project %E.CoinMined{} = evt do 8 | increase_balance(multi, evt.account_id, 1) 9 | end 10 | 11 | project %E.CoinsSent{} = evt do 12 | increase_balance(multi, evt.account_id, -evt.amount) 13 | end 14 | 15 | project %E.CoinsReceived{} = evt do 16 | increase_balance(multi, evt.account_id, evt.amount) 17 | end 18 | 19 | defp increase_balance(multi, account_id, amount) do 20 | Ecto.Multi.insert( 21 | multi, 22 | :increase_balance, 23 | %S.Account{account_id: account_id, balance: amount}, 24 | conflict_target: :account_id, 25 | on_conflict: [inc: [balance: amount]] 26 | ) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, level: String.to_atom(System.get_env("LOGLVL") || "error") 4 | 5 | config :coins, ecto_repos: [Coins.Repo] 6 | 7 | config :coins, Coins.Repo, 8 | adapter: Ecto.Adapters.Postgres, 9 | username: "postgres", 10 | password: "postgres", 11 | database: "coins_readstore_dev", 12 | hostname: "localhost", 13 | port: 5432, 14 | pool_size: 10 15 | 16 | config :commanded, event_store_adapter: Commanded.EventStore.Adapters.EventStore 17 | 18 | # Configure the event store database 19 | config :eventstore, EventStore.Storage, 20 | serializer: EventStore.TermSerializer, 21 | username: "postgres", 22 | password: "postgres", 23 | database: "coins_eventstore_dev", 24 | hostname: "localhost", 25 | port: 5432, 26 | pool_size: 10 27 | 28 | config :commanded_ecto_projections, repo: Coins.Repo 29 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Coins.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :coins, 7 | version: "0.1.0", 8 | elixir: "~> 1.6", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | aliases: aliases() 12 | ] 13 | end 14 | 15 | # Run "mix help compile.app" to learn about applications. 16 | def application do 17 | [ 18 | mod: {Coins.Application, []}, 19 | extra_applications: [:logger, :eventstore] 20 | ] 21 | end 22 | 23 | # Run "mix help deps" to learn about dependencies. 24 | defp deps do 25 | [ 26 | # Domain 27 | {:commanded, github: "commanded/commanded", override: true}, 28 | {:commanded_eventstore_adapter, "~> 0.3"}, 29 | {:commanded_ecto_projections, "~> 0.6"}, 30 | {:ecto, "~> 2.1"}, 31 | {:postgrex, ">= 0.0.0"}, 32 | {:uuid, "~> 1.1"}, 33 | {:eventstore, ">= 0.13.0"} 34 | ] 35 | end 36 | 37 | defp aliases do 38 | [ 39 | setup_es: ["event_store.create", "event_store.init"], 40 | setup_ecto: ["ecto.create", "ecto.migrate"], 41 | setup_db: ["setup_es", "setup_ecto"], 42 | drop_db: ["ecto.drop", "event_store.drop"], 43 | reset_db: ["drop_db", "setup_db"], 44 | test: ["reset_db", "test --trace"] 45 | ] 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Coins 2 | 3 | Coins is an example app using *CQRS* and *Event Sourcing*. 4 | 5 | It uses: 6 | * [Commanded](https://github.com/commanded/commanded): Framework to create CQRS/ES appliactions 7 | * [Commanded Ecto Projections](https://github.com/commanded/commanded-ecto-projections): Projections on an Ecto DB 8 | * [Commanded EventStore Adapter](https://github.com/commanded/commanded-eventstore-adapter): EventStore adapter for commanded using postgres 9 | 10 | ## Using 11 | 12 | Setup the databases with `mix setup_db` and then run `iex -S mix` 13 | 14 | ```elixir 15 | iex> Coins.mine_coin("me", 1) 16 | {:error, :invalid_nonce} 17 | 18 | iex> Coins.mine_coin("me", 190) 19 | :ok 20 | 21 | iex> Coins.mine_coin("me", 190) 22 | {:error, :used_nonce} 23 | 24 | iex> Coins.mine_coin("me", 443) 25 | :ok 26 | 27 | iex> Coins.richest |> Map.take([:account_id, :balance]) 28 | %{account_id: "me", balance: 1} 29 | 30 | iex> [488, 1442, 1597] |> Enum.map(&(Coin.mine_coin("you" &1))) 31 | [:ok, :ok, :ok] 32 | 33 | iex> Coins.richest |> Map.take([:account_id, :balance]) 34 | %{account_id: "you", balance: 3} 35 | 36 | iex> Coins.send_coins("you", "me", 99999) 37 | {:error, :not_enough_coins} 38 | 39 | iex> Coins.send_coins("you", "me", 3) 40 | :ok 41 | 42 | iex> Coins.richest |> Map.take([:account_id, :balance]) 43 | %{account_id: "me", balance: 5} 44 | ``` 45 | -------------------------------------------------------------------------------- /lib/coins/account.ex: -------------------------------------------------------------------------------- 1 | defmodule Coins.Account do 2 | alias __MODULE__ 3 | 4 | alias Coins.Commands.{ 5 | MineCoin, 6 | ReceiveCoins, 7 | SendCoins 8 | } 9 | 10 | alias Coins.Events.{ 11 | CoinMined, 12 | CoinsSent, 13 | CoinsReceived 14 | } 15 | 16 | defstruct balance: 0, last_nonce: 0 17 | 18 | def execute(%{last_nonce: ln}, %MineCoin{nonce: n}) 19 | when n < ln, 20 | do: {:error, :used_nonce} 21 | 22 | def execute(_, %MineCoin{} = cmd) do 23 | if Proof.proof(cmd.account_id, cmd.nonce) do 24 | %CoinMined{ 25 | account_id: cmd.account_id, 26 | nonce: cmd.nonce 27 | } 28 | else 29 | {:error, :invalid_nonce} 30 | end 31 | end 32 | 33 | def execute(%{balance: b}, %SendCoins{amount: a}) when a > b, do: {:error, :not_enough_coins} 34 | 35 | def execute(_, %SendCoins{} = cmd) do 36 | %CoinsSent{ 37 | account_id: cmd.account_id, 38 | to: cmd.to, 39 | amount: cmd.amount, 40 | transfer_id: cmd.transfer_id 41 | } 42 | end 43 | 44 | def execute(_, %ReceiveCoins{} = cmd) do 45 | %CoinsReceived{ 46 | account_id: cmd.account_id, 47 | amount: cmd.amount, 48 | transfer_id: cmd.transfer_id 49 | } 50 | end 51 | 52 | def apply(state, %CoinMined{} = evt) do 53 | %Account{state | last_nonce: evt.nonce} 54 | |> increase_balance(1) 55 | end 56 | 57 | def apply(state, %CoinsSent{} = evt) do 58 | increase_balance(state, -evt.amount) 59 | end 60 | 61 | def apply(state, %CoinsReceived{} = evt) do 62 | increase_balance(state, evt.amount) 63 | end 64 | 65 | defp increase_balance(state, amount) do 66 | %Account{state | balance: state.balance + amount} 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "commanded": {:git, "https://github.com/commanded/commanded.git", "720001ef696b89b16536d26354024991494bc2aa", []}, 3 | "commanded_ecto_projections": {:hex, :commanded_ecto_projections, "0.6.0", "004b7ed525ab8a04a71bf3dfb3baf070cdfaae6ab945b3d002ceae0fb978dd4d", [:mix], [{:commanded, ">= 0.12.0", [hex: :commanded, repo: "hexpm", optional: false]}, {:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "commanded_eventstore_adapter": {:hex, :commanded_eventstore_adapter, "0.3.0", "bd8ea1ae6d62edc74a97047ff05f46b9d460246261b2d890612dcfed2280b1f8", [:mix], [{:commanded, ">= 0.15.0", [hex: :commanded, repo: "hexpm", optional: false]}, {:eventstore, ">= 0.13.0", [hex: :eventstore, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, 6 | "db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, 7 | "decimal": {:hex, :decimal, "1.4.1", "ad9e501edf7322f122f7fc151cce7c2a0c9ada96f2b0155b8a09a795c2029770", [:mix], [], "hexpm"}, 8 | "ecto": {:hex, :ecto, "2.2.8", "a4463c0928b970f2cee722cd29aaac154e866a15882c5737e0038bbfcf03ec2c", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, 9 | "eventstore": {:hex, :eventstore, "0.13.2", "45e5492d1965a4ec6744eb29d29f2871be27cd8fe32de8747dd85d8c8e311629", [:mix], [{:fsm, "~> 0.3", [hex: :fsm, repo: "hexpm", optional: false]}, {:poison, "~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: false]}, {:swarm, "~> 3.0", [hex: :swarm, repo: "hexpm", optional: true]}, {:uuid, "~> 1.1", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "fsm": {:hex, :fsm, "0.3.0", "d00e0a3c68f8cf8feb24ce3a732164638ec652c48ce416b66d4e375b6ee415eb", [:mix], [], "hexpm"}, 11 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 12 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, 13 | "postgrex": {:hex, :postgrex, "0.13.4", "f58e319c5451bfda86ba6a45ce6dca311193d0a9861323d0d16e8d02e25adc41", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}, 14 | "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm"}, 15 | } 16 | --------------------------------------------------------------------------------