This is the result of my experimental implementation of an event-sourced (ES), CQRS system. I deliberately wanted to experiment with writing my own ES+CQRS system without a library, in order to gain a deeper understanding of how such systems are designed.
The name comes from the numeronym style that is trending these days e.g. i18n, o11y, k8s. Originally I named it CQRS-Clojure
, but then I couldn't resist the pun and shortened it to 4-Clojure
😎
Given that you have Clojure and a JVM installed, you can start up the app via
PORT=8080 clojure --main main
Providing an environment variable with the key PORT
will override the default. Default port is 8080
.
I have provided a means of running the application inside it's own Docker container via the makefile.
make run-docker
To run tests:
clojure -M:test
Build a standalone uber-JAR with the following commands:
clojure -T:build clean
clojure -T:build uber
The compiled .jar
will be created in the target/
directory.
From there you can execute the compiled .jar
with
java -jar target/four-clojure-standalone.jar
This method is preferred as you can be sure that your compiled .jar
is compatible with your version of the JVM on your system. This is not the case when using a container!
If you wish, you can compile an uber-JAR using the Dockerfile provided. Simply run
make docker-build
Which will also compile a .jar
, and then copy it into the target/
directory.
However this approach has some drawbacks, you need to make sure that the .jar
file compiled in the container is compatible with your local JVM. Currently, the Docker container compiles for Java 21. If this is not compatible with your local setup, you need to adjust build.Dockerfile to the correct base image to ensure compatibility.
The domain is a primitive banking system with the following functions:
- A user can create an account
- The user has the ability to specify an ID, or have one automatically generated
- A user can deposit an amount into an account
- A user can withdraw a valid amount from an account
- i.e. a user can only withdraw an amount up to the current balance contained in the account
Invalid events are still recorded in the event store, but given a rejected event-type. Examples that would cause a rejected event include
- A user trying to create an account with an ID that already exists
- A user trying to deposit or withdraw into/from an account that does not exist
- A user trying to deposit or withdraw a negative or zero amount
Note that the units of the amount deposited/withdrawn to/from the account are cents, not dollars.
The system follows an event-sourced, CQRS architecture. This is more succinctly explained with the diagram below:
---
title: Banking CQRS System
---
flowchart TD
subgraph Commands
CreateAccountCommand(`Create Account` command) --> CreateAccountCommandValidation([Validation])
AlterAmountCommands(`Deposit/Withdraw Amount` command) --> AlterAmountCommandsValidation([Validation])
end
subgraph WriteModels [Write Models]
Account2[Another Account] ~~~ Account[Account]
end
EventStore -. `Account Created` event .-> WriteModelFactory[Write Model Factory]
WriteModelFactory -- Creates --> Account
WriteModelFactory -- Creates --> Account2
EventStore -. update state on<br/>`Amount Deposited/Withdrawn`</br>events .-> Account
EventStore -.-> Account2
CreateAccountCommandValidation -- `Account Created` event ---> EventStore[(Event Store)]
AlterAmountCommandsValidation -- `Amount Deposited/Withdrawn` event --> EventStore
AlterAmountCommandsValidation -- `Deposit/Withdrawal Rejected` event --> EventStore
subgraph ReadModels [Read Models]
TotalReserve[Total Reserve]
subgraph AccountsCurrentBalances [Accounts Current Balances]
CurrentBalance[Current Account Balance]
CurrentBalance2[Another Current Account Balance]
end
end
EventStore -. `Account Created` event ..-> ReadModelFactory[Read Model Factory]
ReadModelFactory -- Creates --> CurrentBalance
ReadModelFactory -- Creates --> CurrentBalance2
EventStore -. update state on<br/>`Amount Deposited/Withdrawn`<br/>events .-> TotalReserve
EventStore -. update state on<br/>`Amount Deposited/Withdrawn`<br/>events .-> CurrentBalance
CurrentBalance2 <-.-> EventStore
Create an account
curl --header "Content-Type: application/json" http://localhost:8080/account/ --data '{"name":"my example account name"}'
Deposit into account
curl --header "Content-Type: application/json" http://localhost:8080/account/deposit/3779b736-f0bb-46c4-bbab-be33d80bb4d6 --data '{"amount":100000000,"description":"Found sunken treasure"}'
Withdraw from account
curl --header "Content-Type: application/json" http://localhost:8080/account/withdraw/3779b736-f0bb-46c4-bbab-be33d80bb4d6 --data '{"amount":100000,"description":"Parking fines"}'
Get the status of an account
curl http://localhost:8080/account/3779b736-f0bb-46c4-bbab-be33d80bb4d6
Query the reserve
curl http://localhost:8080/reserve
How should we handle receiving events out-of-order?
- e.g. What if we receive a
amount-deposited
event before the correspondingaccount-created
event? - For now, I have opted to still record them in the event store, but as
*-rejected
events.
- Move validation logic into aggregate
- Add API
(router)
tests - Add API tests using
(start-app)
- Investigate if it's possible to write a
when-let
macro with multiple bindings, preferably short-circuiting.- It can be called
when-lets
- It can be called
- Implement more complex complex domain behaviour
- Might have to learn more about how banking works
- Add specs for data structures used: account read/write-models, reserve, maybe event-store events?
- Add persistent storage implementations (e.g. Postgres database), possible order of attack:
- Event store
- Account read model
- Reserve read model
- Account write model