Skip to content
PROTOWORKS

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.

RING 1 · BOUNDARY

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.

RING 2 · CORE

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.

RING 3 · EDGES

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.

core/src/decision.rsrust · 22 lines
// 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 },
    }
}
A small piece of the work. State plus command returns events or a reason. Nothing here can fail for a reason that is not in the type.

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.

The question

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.

step 0 / 3
Request ·Idempotency-Key ·
  1. 01 · AdapterWaiting for a request
  2. 02 · BoundaryParse, mint, stamp
  3. 03 · DecidePure function
  4. 04 · AppendLedger write
  5. 05 · ProjectRead models
The first request is on its way.
Postgres · balanceR0no account yet
ClickHouse · statement0rows in the ledger
Memgraph · graph0counterparty edges
Qdrant · search0indexed entries
SeqTime (UTC)EventAmountRequest
No events yet. A refusal never adds one.
Idempotency-KeyStored response
No requests answered yet.
The reasoningIn progress
  1. The questionA customer pays R1 250 into an open account. What does the ledger write down?
  2. 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 first request is on its way.■ accepted■ refused by the core■ answered by the boundary

The stack

One ledger. Every other store is a view of it.

LedgerClickHouse, append-onlyStatements with content-addressed IDs. The same fact written twice is the same row; replays are idempotent by construction.
Control planePostgreSQLTenancy, configuration, idempotency records and the outbox. Small, relational, transactional.
GraphMemgraph, derivedA projection of the ledger for traversal: ownership, causation, control. Rebuilt from statements, never written directly.
VectorsQdrantDense retrieval for linking and search, fed by the same projection pipeline as everything else.
ServicesRust cores, TypeScript surfacesTyped IDs as newtypes, schema-validated types at the TypeScript boundary, one error-mapping layer with leak-catching middleware.
WireBoring on purposesnake_case fields, ISO-8601 UTC timestamps, cursor pagination, Idempotency-Key on every mutation, RFC 9457 problem+json for every error.
Deliverymoonrepo, GKE, ArgoCDPinned toolchains, affected-only task graphs, GitOps sync. Clusters converge on what is in Git; nobody patches them by hand.
TestsProperties, containers, fixturesProperty-based tests for invariants, Testcontainers for integration, recorded fixtures for every model and network call. Behaviour, not implementation.