Building Fintech Systems

That Stay Fast and Stay Compliant

Code BEAM Berlin 2025

Fast vs Compliant?

People will tell you to choose one.

These people are wrong.

Why This Room

In 30 minutes you'll leave with:

  • a ledger-first layout you can map to your system
  • a money type you can implement on Monday
  • a trace and audit field list that makes audits as easy as queries

Who Am I

  • Klarna's first CTO
  • Author of The BEAM Book
  • Programming since 1980

I have made every mistake in this talk at least twice.

The Fintech Scaling Problem

PSD2 · GDPR · AML · PCI · SOC2

Rules pile up. Each jurisdiction adds more. Stakes: money, licenses, trust.

Also: your sanity.

Two Bad Paths

       Start
         |
  ---------------
  |             |
Speed-first   Compliance-first
🚀              ✅
↓              ↓
Compliance     Slow releases
fire drills    Lost ground

The Goal

Architecture + Culture → Speed and Compliance

Ledger-Centric Design

Diagram

Typed Money

❌ "149.99" (string)
❌ 149.99 (float)
✅ {amount: 14999, currency: "SEK", scale: 2}

Why the BEAM Helps

  • Scaling out of the box
  • Bit syntax for protocols
  • Arbitrary-precision integers
  • Immutable data
  • Isolated processes
  • Supervision trees

Pattern: Isolation Boundaries

Diagram

Pattern: Safe Change at Speed

  • Adapter per PSP, contract tests
  • Queues at edges, idempotent handlers
  • Feature switches per integration
  • Hot code upgrades when needed

Traceability and Simplicity

  • Logs + metrics + traces = one story
  • Single trace_id across transaction
  • Example: GDPR with UUIDs + mapping table

Audit Trail as Query

{
  "trace_id": "7b3bf470...",
  "actor": "user:U123",
  "timestamp": "2025-09-30T14:35:12Z",
  "event": "payment.authorized",
  "amount_before": 0,
  "amount_after": 14999,
  "psp": "AcmePay",
  "region": "SE"
}

Culture: YBYOYR

You Build it · You Own it · You Run it

Failure vs Success

❌ Failure ✅ Success
Floats in money Typed money
No trace IDs Trace IDs everywhere
PSP outages cascade Isolation boundaries
Months of retrofits Clean audits, fast launches

Takeaway

Friday deploys. Monday audits. One system.

Both work. Or neither does.

Live Demo (Optional):
Erlang Mini-Ledger

  • Append-only ledger with a hash chain + Merkle root over balances.
  • Show: immutable history, crash safety, proof-of-state.

Observer

observer:start().

The Code: ledger.erl (Core)

post(Account, Delta, Currency) ->
    Ts = erlang:system_time(millisecond),
    Entry = #{ts => Ts, acct => Account,
              delta => Delta, ccy => Currency},
    Prev = prev_hash(),
    TxHash = sha256(term_to_binary(Entry)),
    BlockHash = sha256(<<Prev/binary, TxHash/binary>>),

    ets:insert(?CHAIN, {Ts, Entry, TxHash, Prev, BlockHash}),
    update_balance(Account, Delta, Currency),
    ets:insert(?META, {?PREV, BlockHash}),
    {ok, BlockHash}.

The Code: Merkle Root

state_root() ->
    Leafs = lists:map(
        fun({A,B,C}) ->
            sha256(<<(term_to_binary(A))/binary,
                     (term_to_binary(B))/binary,
                     C/binary>>)
        end,
        lists:sort(ets:tab2list(?BAL))),
    merkle:root(Leafs).

Compact commitment to all balances. Any change produces a different root.

Step 1 — Start the Ledger

1> c(ledger_store), c(ledger), c(merkle), ledger:start().
ok
  • ETS-backed append-only chain
  • Hash = SHA256(prev_hash || txn)
  • Balances updated from entries
  • ledger_store owns the ETS tables (heir pattern)

Step 2 — Post Transactions

2> ledger:post(user_1, 14999, <<"SEK">>).
{ok,<<...>>}
3> ledger:post(user_1, -2500, <<"SEK">>).
{ok,<<...>>}
4> ledger:balance(user_1).
12499
  • Entries are appended; nothing is overwritten.

Step 3 — Prove State (Merkle Root)

5> ledger:state_root().
<<RootHash:256/bits>>
6> ledger:balances().
[{user_1,12499,<<"SEK">>}]
  • state_root() is the Merkle root over the sorted balance set.
  • Any stakeholder can recompute and match.

Step 4 — Fault Containment (Heir Pattern)

7> whereis(ledger_worker).
<0.123.0>
8> exit(whereis(ledger_worker), kill).
true
9> ledger:balance(user_1).
12499  % Still works! Tables transferred to heir
10> ledger:start_worker().
{ok, <0.125.0>}
11> ledger:balance(user_1).
12499  % Data intact after worker restart
  • Worker dies → tables transfer to ledger_store (heir).

What Are Merkle Proofs?

Problem: Your auditor needs to verify one account balance without accessing your entire database.

Solution: Merkle tree, a cryptographic commitment to all balances.

  • Each balance becomes a leaf hash
  • Pairs combine into parent hashes
  • Final root commits to entire state

Merkle Tree Structure

           Root (6A93...)
              /     \
         H_AB         H_CD
         /  \         /  \
      H_A  H_B     H_C  H_D
       |    |       |    |
    user_1 user_2 user_3 user_4
    12499  4000   8500   2100

To prove user_2 balance:

  • Show leaf H_B (hash of user_2, 4000, SEK)
  • Show path: [H_A (left), H_CD (right)]
  • Anyone can recompute: hash(hash(H_A, H_B), H_CD) = Root

Why This Matters for Fintech

  • Privacy: Prove one balance without revealing others
  • Compact: Log N proof size (8 hashes for 256 accounts)
  • Verifiable: Anyone can check against published root
  • Historical: Generate proofs for any past state

This is what makes blockchains auditable.

You don't need a blockchain: plain Merkle trees in Erlang work fine.

How Verification Works

You publish: Root hash 6A93...

User claims: "I have 4000 SEK"

User provides proof:

  1. Leaf: hash(user_2, 4000, SEK) = 8C75...
  2. Path: [H_A (left sibling), H_CD (right sibling)]

Verifier computes:

  • Step 1: hash(H_A, 8C75...) = H_AB
  • Step 2: hash(H_AB, H_CD) = 6A93...
  • Check: Does this match published root? Yes → Valid

Live Demo: Merkle Proofs

12> c(proof_pp).
{ok, proof_pp}
13> {ok, H1} = ledger:post(user_2, 5000, <<"SEK">>).
{ok, <<H1:256/bits>>}
14> {ok, H2} = ledger:post(user_2, -1000, <<"SEK">>).
{ok, <<H2:256/bits>>}
15> {ok, P} = ledger:proof(user_2).
{ok, #{balance => 4000, ...}}

Understanding the Proof Structure

16> proof_pp:show(P).

=== Proof of Balance ===
Account : user_2
Balance : 4000 "SEK"
Leaf (account hash):
  8C75A23F...4E9FAE12
Path (2 siblings):
  -> left 91AB4C2D...8F331D0A
  -> right 0F88E7B1...3A7CC491
Root :
  6A9312B7...9C4F8E2D

Verify the Proof

17> merkle_proof:verify(user_2, 4000, <<"SEK">>, maps:get(proof, P)).
true

Historical Proof (Rewind to H1)

18> {ok, P1} = ledger:proof_at(user_2, H1).
{ok, #{balance => 5000, at_block => H1, ...}}
19> proof_pp:show(P1).

=== Proof of Balance ===
Account : user_2
Balance : 5000 "SEK"
At block: 3A7F8E12...9C2B4D76
Leaf (account hash):
  2F4C9A81...6D7E3B92
Path (1 siblings):
  -> right 8B3E2C1F...4A9D7F05
Root :
  D4F8A2E6...7B1C9E34

20> merkle_proof:verify(user_2, 5000, <<"SEK">>, maps:get(proof, P1)).
true

From Reliability to Verifiable State

You probably already know:

  • Don't use floats for money
  • Use supervisors for isolation and recovery

Now go further:

  • Ledgers: every change append-only, reconstruct able
  • CRDTs: concurrent updates that converge without locks
  • Patricia-Merkle Trees: cryptographic proof of balances and state

Next Steps

QR Code