All work

Payment systems · Distributed backend

Maat

Kill the ledger. Lose nothing. A full payment platform on Kafka, TiDB, Redis, DynamoDB, Envoy and Kubernetes. Thirteen verified runs, from retry storms to six faults in 90 seconds. Not one yen lost or duplicated.

  • Java
  • Kotlin
  • gRPC
  • TiDB
  • Kafka
  • Redis
  • DynamoDB
  • Envoy
  • Kubernetes
  • Terraform
  • Prometheus
The live Maat site
¥0
lost or double-charged across 13 verified load and failure runs
150/s
sustained with no failed requests, on one machine
63 ms
payment p99 at steady load (p50 44 ms)
300
of 1,000 concurrent debits succeed on a ¥300 wallet, exactly

The problem

Payment systems fail in the gaps: a lost response, a retried request, a crash between two writes. The cost of being wrong is someone’s money.

Maat treats these failures as the main design problem. Every money movement is a balanced double-entry posting, every guarantee has a test that fails when the rule is removed, and the system is load-tested and broken on purpose.

After each run, a verifier replays every intent under its original key. Every wallet must add up, the ledger must pass its invariant check, and the ledger, settlement and bank must all agree.

The path of a payment

  1. 01

    Edge

    Envoy is the only way in. It verifies RS256 tokens and HMAC-signed merchant requests, and strips client identity headers.

  2. 02

    Payment service

    Client-chosen idempotency keys are leases with fencing tokens. A write-ahead state survives a lost ledger response.

  3. 03

    Ledger

    Append-only double-entry postings over mutual TLS, with reserve-then-capture holds and one conditional balance update.

  4. 04

    Outbox

    Events are written in the same transaction as the state change, then relayed to Kafka with their trace restored.

  5. 05

    Settlement

    Daily payouts, then three-way reconciliation across the ledger, the settlement worker and the bank.

Results

13 runs: requests failed, correctness verified
  1. Steady0%Pass
  2. Peak92.7%Pass
  3. Hot merchant0%Pass
  4. Retry storm36.7%Pass
  5. Ledger killed38.4%Pass
  6. Payment service killed49.5%Pass
  7. Merchant service killed0%Pass
  8. Kafka frozen0%Pass
  9. DynamoDB frozen60.9%Pass
  10. Redis frozen0%Pass
  11. Bank frozen0%Pass
  12. Database frozen7.0%Pass
  13. Everything40.6%Pass

Requests can fail during a fault. A run passes only if no money was lost or duplicated.

All 13 runs

RunRateFailedp50 / p99 msVerified
Steady149.9/s0%44 / 63Pass
Peak178.7/s92.7%355 / 2,923Pass
Hot merchant149.9/s0%56 / 173Pass
Retry storm99.9/s36.7%4 / 39Pass
Ledger killed99.9/s38.4%252 / 1,727Pass
Payment service killed82.2/s49.5%54 / 2,816Pass
Merchant service killed99.9/s0%47 / 366Pass
Kafka frozen99.8/s0%48 / 80Pass
DynamoDB frozen100.0/s60.9%48 / 2,378Pass
Redis frozen92.3/s0%44 / 1,411Pass
Bank frozen100.0/s0%50 / 191Pass
Database frozen99.9/s7.0%46 / 2,327Pass
Everything92.4/s40.6%248 / 2,634Pass

One i5 laptop in Docker Compose, one instance of each service. PASS means every intent has one outcome, every wallet adds up, the ledger balances and settlement agrees. Failed requests measure availability, which these runs don’t optimise.

Guarantees, and the test behind each

GuaranteeTest
A wallet is never overdrawn1,000 concurrent debits on ¥300: exactly 300 succeed
A retried call applies once64 concurrent posts with one key produce one entry
A hold has one winnerCapture, void, cancel and expiry race for 40 rounds
Opposing transfers don’t deadlock400 transfers in both directions between two wallets
A hot merchant doesn’t serialize writers8 roll-ups fold every delta exactly once
Journal history is append-onlyThe service’s own database connection is refused
A request can’t be altered or replayedChanged body, path, timestamp and nonce are each refused

Key decisions

  • Fail closed or fail open, by risk

    Without the idempotency store, payments stop, because the risk is a double charge. Without Kafka they continue, since events can be published later. The fraud check gets 80 ms, then fails open, because fraud can be reversed.

  • Rules live in the database

    One conditional UPDATE carries the overdraft rule and the row count is the verdict. A unique key gives QR codes single use. The service’s own user has no UPDATE or DELETE on the journal.

  • A rate can’t create money

    Each account is in one currency, and a conversion is one entry that balances in both through a platform position. An entry whose totals agree while its currencies don’t is refused.

  • Keep the trace across the outbox

    Events are published later, on another thread, where the request’s trace no longer exists. The outbox row stores the trace, so one payment is one trace from the edge to settlement.

  • Observability starts with the money

    The dashboard asks in order: is the money correct, are payments fast, where is the backlog. A Watchdog alert always fires, so that silence can be told apart from a broken pager.

What broke

  • A fractional yen

    {"amount": 1.5} created a QR code for 1 yen, because the JSON library truncated decimals by default. A malformed-request test caught it.

  • A request refused as a duplicate of itself

    On a fresh cluster, the DynamoDB client’s own retry collided with the original claim and returned 409 REQUEST_IN_PROGRESS.

  • A rounded ratio

    Multiplying by a rounded ratio turned ¥9,900 at 150 to the dollar into $65.99. Amounts are now computed exactly from both rates and rounded once.

What these numbers don’t prove

  • All numbers come from one machine with single service instances, so they show correctness under failure and not availability. A killed service is down until it restarts.
  • Past roughly 150 to 300 requests a second, most requests time out.
  • Balances are play money and the bank is simulated.
  • The AWS Terraform is validated but has never been applied. The fraud model is trained on simulated payments, so its accuracy says nothing about real fraud.