Happi Hacking
Code BEAM Berlin 2025

Building Fintech Systems

That Stay Fast and Stay Compliant

Compliance Myths

  1. Slows you down
  2. Belongs to lawyers
  3. Means paperwork

What does good engineering done on purpose look like?

Pattern 1: Isolation Boundaries

Diagram

Thinking in Processes

To help you think about process-oriented programming, let’s introduce three ideas:

  • Domains: code & data boundaries
  • Process Archetypes: common behavioral patterns
  • Flows: how data and messages move

Pattern 1: Isolation Boundaries as Domains

Diagram

Domains define what belongs together.

Pattern 1: Isolation Boundaries as Archetypes

Diagram

Different view: roles at runtime.

Pattern 1: Isolation Boundaries as Flows

Diagram

Four kinds of flow: data, message, process, call.

From Boundaries to Movement

Isolation boundaries give structure.

Now let’s look at movement inside those boundaries, how change travels safely through the system.

Pattern 2: Idempotent Change Flow

Diagram

Message example

Msg = #{
  trace_id => T,
  actor    => <<"3f7e8c4-9d2b-4a15-8e6f-1c5b3d4a7f9e">>,
  op       => <<"authorize">>,
  amount   => 14999, ccy => <<"SEK">>, scale => 2,
  meta     =>  #{psp => <<"AcmePay">>,
                 idempotency_key    => T }
}.

Ledger-side dedup idea (conceptual)

Maintain a dedup ETS table: dedup: seen(T) -> true | false.

On post:

if seen(T) -> {ok, already_applied, Prev_result};
   true -> append(E),
           mark_seen(T),
           {ok, new, Result}
end

PSP-side idempotency (conceptual)

  • Outbound: include Idempotency-Key: T (or body field) so PSP won’t double-charge on retries.
  • Inbound: if PSP sends webhooks multiple times → dedup by (psp_event_id || T).

You Build It · You Own It · You Run It

Ownership is compliance.

With Ownership Comes Trust

Give developers the tools and the trust to act responsibly.

  • “Break-the-glass” access for real incidents
  • Clear audit trail of every action
  • Fair compensation and proper on-call terms

The One Rule That Works

Every change must have a reason.

  • One ticket → one branch → one deploy.
  • Branch name = ticket_nr + documentation.
  • Commit message explains what.
  • The ticket explains why.

Never Use Floats (for Money)

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

Where This Maps to Regulations

Regulation What it asks for Engineering pattern
PSD2 / NIS2 Isolation of risk & failover Isolation Boundaries, Circuit Breakers
GDPR Data minimization, traceability, forget-me Typed data, trace_id + PII mapping, "break-the-glass" access
PCI DSS Segregation of duties, controlled data flow Ledger Domain, Gatekeepers, supervised processes
SOC 2 / ISO 27001 Change management & auditability Ticket → branch → deploy, Idempotent Change Flow
AML / KYC Transaction traceability Ledger events with actor + trace context

Lessons learnt (the hard way)

  • Isolation Boundaries → Contain Risk
  • Idempotent Change → Safe Movement
  • YBYOYR → Lasting Culture

One System. Both Fast and Compliant.

Thank You

If we have time:

  • Press → for Mini-Ledger demo
  • Press → again for Process Archetypes

Otherwise:

  • Press → twice to skip to Next Steps

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

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

Process Archetypes

Worker Handles one task. Dies after. You can pool them if needed.

Resource owner Keeps state. A cart, a session, a WebSocket.

Router Receives, decides, forwards. Never stores.

Gatekeeper Limits something. One message at a time, access to a resource.

Observer Watches processes, reacts to conditions.

Worker: Short-lived workers

Spawn, do one job, exit. Use these for things like sending emails, updating logs, writing audit trails.

Worker: Pooled workers

Lives in a pool when not running, does one job, returns to pool. Can keep some state, like configuration.

Router: Broadcaster

Fan out of messages.

Router: Director

Route messages to the right receiver.

Gatekeeper: Flow controllers

Processes that gate, synchronize, or sequence other flows.

They manage contention, enforce ordering, or regulate access.

Gatekeeper: Rate limiters

Provides back pressure.

Gatekeeper: Circuit breakers

When subsystems stall or crash, they keep the damage local.

Resource owner: Entity owner

Processes that wrap state.

A payment intent. A socket. A user.

Can be implemented with a gen_server.

Resource owner: Aggregator

Collector, windowing, rollups.

Observer: Sentinel

Watchdogs, deadlines, cancellations.

Observer: Supervisor

Watches others. Restarts them. Never does real work.

They know who to restart and when to walk away.

Next Steps

Fintech

Happi Hacking QR

Feedback

Feedback QR