This guide builds a wallet: a backend that holds a balance for each user, takes deposits, moves money between users, and stays in sync with your payment processor.
We build it twice at once. At each step you write the pure-code version and the FRAGMENT version side by side, and watch one of them pick up cleanup — a lock, a retry guard, a dedupe, a revisit to code you already wrote — that the other never needs.
Keep one question in mind the whole way: where does each rule live? When a rule lives in a function some path has to call, you can always add a second path that forgets it. When it lives in the schema, there's one.
Before you start
You need a FRAGMENT workspace, an API client (Dashboard → API clients), and an SDK installed. See Install the SDK. The examples are Python; the shape is the same in every SDK. In the snippets below, fragment is the SDK client you set up there.
Step 1 — Hold a balance and take a deposit
The rule. Every user has a balance, a deposit moves money in, and the books stay balanced.
Pure code. You make a balances table and a deposit that updates it and records a ledger row. To keep the writes honest you put them behind a BalanceManager — the one place a balance changes, and it refuses to go below zero.
# balances.py — the one place balances change
class BalanceManager:
def credit(self, user_id, cents):
bal = session.get(Balance, user_id) or Balance(user_id=user_id, amount=0)
bal.amount += cents
session.add(bal)
def debit(self, user_id, cents):
bal = session.get(Balance, user_id)
if bal.amount - cents < 0: # the "never go negative" rule
raise InsufficientFunds()
bal.amount -= cents
# wallet.py
def deposit(user_id, amount):
with session.begin():
balances.credit(user_id, to_cents(amount))
session.add(LedgerRow(user_id=user_id, amount=to_cents(amount)))So far, so clean. The rule "balances never go negative" lives in BalanceManager, and the deal is simple: every balance change goes through it.
On Fragment. Everything you build lives in one schema: the JSON document that describes a ledger's accounts and the entries allowed against it. (The ledger is the live instance of that schema — it holds the real balances.) Every step after this one adds to this single document and never goes back to rewrite what an earlier step put there. Start with the accounts: the cash you hold, and what you owe each user.
{
"key": "wallet",
"chartOfAccounts": {
"assets": {
"banks": { "user-cash": {} }
},
"liabilities": {
"users": {
"template": "user",
"available": {}
}
}
}
}Marking users with "template": "user" makes it a per-user node. You address one user's balance as liabilities/users:{{user_id}}/available, and Fragment creates it the first time you post to it, so you never insert a row per user. A deposit is one Ledger Entry with two lines: cash arrives in the bank, the user's balance rises by the same amount.
{
"type": "deposit",
"description": "Fund {{user_id}} for {{amount}}",
"lines": [
{ "key": "funds_arrive_in_bank",
"account": { "path": "assets/banks/user-cash" },
"amount": "{{amount}}" },
{ "key": "increase_user_balance",
"account": { "path": "liabilities/users:{{user_id}}/available" },
"amount": "{{amount}}" }
]
}The entry has to balance or the schema rejects it. Both amounts are positive because one account is an asset and the other a liability: what you hold and what you owe rise together, which keeps the books balanced. Posting it is one call, and amounts are integers in the smallest unit passed as strings, so $2.50 is "250".
async def deposit(user_id, amount):
await fragment.add_ledger_entry(
type="deposit",
parameters={"user_id": user_id, "amount": amount},
)You read a balance back by querying that account path; it reflects every committed entry. Both sides start even: one small class and one entry type. The next three steps are where they diverge.
Step 2 — Move money between users
The rule. A transfer moves money from one user to another and never takes the sender below zero, even when two land at once.
Pure code. A transfer touches two balances. BalanceManager works one account at a time, and running two operations through it kept fighting the session, so transfer reaches around it into raw SQL. The first cost: the deal from Step 1 — every balance change goes through BalanceManager — is already broken, so the never-go-negative check doesn't run here. The second: the read-check-write races.
def transfer(from_id, to_id, cents):
row = session.execute(
text("SELECT amount FROM balances WHERE user_id = :u"), {"u": from_id}
).first()
if row.amount < cents: # re-implements the floor check, badly
raise InsufficientFunds()
# two transfers can both pass the check here before either writes
session.execute(text("UPDATE balances SET amount = amount - :c WHERE user_id = :u"),
{"c": cents, "u": from_id})
session.execute(text("UPDATE balances SET amount = amount + :c WHERE user_id = :u"),
{"c": cents, "u": to_id})To close the race you go back for row locks (SELECT ... FOR UPDATE), which means revisiting deposit so the two don't deadlock against each other. One new feature, and you've edited code from the step before.
On Fragment. Add a transfer entry to the same schema. Attach the floor as a condition on the sender's account.
{
"type": "transfer",
"description": "{{from_id}} sends {{amount}} to {{to_id}}",
"lines": [
{ "key": "debit_sender",
"account": { "path": "liabilities/users:{{from_id}}/available" },
"amount": "-{{amount}}" },
{ "key": "credit_recipient",
"account": { "path": "liabilities/users:{{to_id}}/available" },
"amount": "{{amount}}" }
],
"conditions": [
{ "account": { "path": "liabilities/users:{{from_id}}/available" },
"postcondition": { "totalBalance": { "gte": "0" } } }
]
}Both lines move user balances, so this time the amounts are opposite signs that net to zero. totalBalance is the sender's balance, and postcondition means the rule is checked against the balance the entry would leave behind. Fragment evaluates it in the same transaction that moves the money, so the check and the write can't be split, and of two transfers racing to drain one account at most one commits. The overdraft rule lives on the entry type, so no second path can grow up beside it and skip the check the way the pure-code transfer just did. You added one entry type and changed nothing from Step 1.
Step 3 — Survive retries
The rule. A network retry of the same deposit or transfer doesn't post it twice.
Pure code. A dropped response makes the client retry, so you add a seen_tx table and check it in deposit. For the check to be honest, the lookup, the write, and the marker all have to commit together — so you revisit deposit's transaction boundaries again.
# wallet.py — guard added inside deposit (and then copied into transfer)
def deposit(user_id, amount, tx_id):
if session.get(SeenTx, tx_id): # check-then-insert, no unique index
return
with session.begin():
balances.credit(user_id, to_cents(amount))
session.add(LedgerRow(user_id=user_id, amount=to_cents(amount)))
session.add(SeenTx(tx_id=tx_id))
# routes.py — the gateway retries with its own key, so a second guard appears
@idempotent(key=lambda req: req.headers["Idempotency-Key"])
def deposit_route(req):
wallet.deposit(req.user_id, req.amount, req.tx_id)Then you remember transfer can be retried too, and go add the same guard there. Now idempotency lives in three places — the route decorator, the seen_tx check, and whatever you copied into transfer — keyed on different ids, disagreeing about what "the same request" means.
On Fragment. Pass an idempotency key. There's nothing to add to the schema; the key was always part of the call.
await fragment.add_ledger_entry(
ik=idempotency_key, # same key on a retry -> same result
type="deposit",
parameters={"user_id": user_id, "amount": amount},
)Send the same ik twice and the second call returns the first result instead of posting again. The pure-code version touched deposit, transfer, and the route to get here; the Fragment version used an argument that was there the whole time.
Step 4 — Reconcile with your processor
The rule. Each charge from Stripe counts exactly once.
Pure code. A webhook posts a ledger row when the charge arrives. Webhooks get missed, so you add a nightly cron to backfill — and now the webhook and the cron both post the same charge. You bolt on a dedupe that matches amount and date, then find it drops two real charges for the same amount on the same day, so you go back and keep widening the key.
@app.post("/webhooks/stripe")
def on_charge(charge): # the webhook posts the charge
session.add(LedgerRow(user_id=charge.metadata["user_id"],
amount=charge.amount, external_ref=charge.id))
def reconcile_nightly(): # the cron posts it again
for charge in stripe.charges.list(created={"gte": yesterday()}):
dupe = session.execute(text(
"SELECT 1 FROM ledger WHERE amount = :a AND DATE(created) = CURRENT_DATE"),
{"a": charge.amount}).first()
if not dupe: # drops two same-amount charges as one
session.add(LedgerRow(user_id=charge.metadata["user_id"],
amount=charge.amount, external_ref=charge.id))On Fragment. Link the user-cash account you defined in Step 1 to the processor. Now, when money arrives through Stripe, the charge posts the deposit itself instead of you calling deposit() for it.
{
"assets": {
"banks": { "user-cash": { "linkedAccount": "stripe" } }
}
}async def reconcile_charge(user_id, charge_id):
await fragment.reconcile_tx(
type="deposit",
parameters={"user_id": user_id},
external_ref=charge_id,
)reconcile_tx posts the same deposit entry you wrote in Step 1, keyed to the Stripe charge and with the amount taken from the charge. For money that comes through the processor, reconciliation is the deposit, not a second copy of it. A linked account ties each Ledger Line to one external transaction, so the charge lands once whether the webhook or a later backfill delivers it. You linked one account and reused the Step 1 entry; there was no new way to record the money to get wrong.
What you end up with
Two builds that started even in Step 1. Look at how they ended.
The pure-code wallet finished most steps heavier than it started, and usually by editing code from an earlier step: the transfer reached around the balance abstraction and sent you back for locks; retries sent you back into both deposit and transfer and added a second idempotency scheme; reconciliation grew a cron and a dedupe you keep retuning. By the end no rule has a single owner — a balance changes in two places, idempotency lives in three, a charge can post from two — and amount means dollars in one function and cents in the next.
The Fragment wallet added at most one declaration per step — nothing at all in Step 3 — and never went back to an earlier one:
- The books balance: the deposit and transfer entry types.
- No double-posting: the idempotency key.
- No overdraft, even under load: the condition on the transfer.
- Each charge once: the linked account.
The overdraft check, the row locks, the seen_tx table, the second idempotency guard, the deduplicating cron: none of them got written, because the rule each one was patching already has a home in the schema.
You can still build anything a wallet needs — payouts, holds, fees, refunds — by adding entry types to the same schema. What changed is that every rule has one place to live, so the wallet stays small enough to read months later, by you or by the assistant making the next change.
Where to go next
- Design your Ledger — accounts, entries, and the accounting equation.
- Post Ledger Entries — parameters, idempotency, conditions, and tags.
- Configure consistency — how conditions stay correct under concurrency.
- Read balances — querying balances and lines.
- Reconcile payments — linking accounts to external systems.