01 · Architecture · ports and adapters
Pure in the middle.
Every service we build has the same shape. A pure decider at the centre turns state and a command into events. Adapters at the rim own the clock, the network, the database and the ID minting. The core touches none of them, so it can be tested exhaustively and replayed exactly.
The shape
Three rings. Only the outer one is allowed to be impure.
Parse, don't validate
Raw input becomes typed values exactly once. Newtype IDs, value objects, an Idempotency-Key on every mutation. The boundary mints IDs and stamps time, so nothing inside has to.
Decide, then evolve
Two pure functions. Decide maps state and command to events or a typed rejection. Evolve folds an event into state. No IO, no clock, no randomness: property tests can run them a million times.
Append, then project
Accepted events are appended to the ledger. Every read model, from a balance to a graph to a vector index, is a projection that can be dropped and rebuilt. Errors leave as RFC 9457 problem documents.
// Pure decider: (state, command) -> events.
// No IO. No clock. Adapters run the loop at the seams.
pub enum Command {
Open(AccountId),
Post(Posting),
Reverse(EntryId, Reason),
}
pub enum Decision {
Accepted(Vec<Event>),
Rejected { because: RejectReason },
}
pub fn decide(state: &Ledger, cmd: Command) -> Decision {
use Command::*;
match (state.is_open(), cmd) {
(false, Post(_)) => Decision::Rejected { because: RejectReason::Closed },
(true, Post(p)) => Decision::Accepted(vec![Event::Posted(p)]),
(_, Open(id)) => Decision::Accepted(vec![Event::Opened(id)]),
(true, Reverse(e, r)) => Decision::Accepted(vec![Event::Reversed(e, r)]),
_ => Decision::Rejected { because: RejectReason::Illegal },
}
}Simulation · runs in your browser
Send it a request. Watch it refuse the wrong ones.
A ledger account, implemented as a decider. Pick a scenario: each is a short run of requests that passes the boundary, meets the pure core, and either becomes events or a typed rejection. Beside it, the reasoning says what each step proved, and why a table updated in place could not have.
A customer pays R1 250 into an open account. What does the ledger write down?
Method · open → credit R1 250. Open the account, then send one payment in.
- 01 · AdapterWaiting for a request
- 02 · BoundaryParse, mint, stamp
- 03 · DecidePure function
- 04 · AppendLedger write
- 05 · ProjectRead models
- The questionA customer pays R1 250 into an open account. What does the ledger write down?
- The recordOne account, acc_7Q2X, starting empty. The boundary mints each request's id and stamps its time; the core sees only the command and the state folded from the log.
The stack