From a FastAPI Script to a Spring Boot SaaS: Rebuilding My Bitfinex Auto-Lending Platform
How an auto-lending side project went from a "good enough to run" single-person script to a multi-module platform with idempotency, reconciliation, circuit breaking, and full monitoring.
This side project started out of pure laziness.
Funding (lending) rates on Bitfinex move every day, and placing offers by hand is tedious — so I wrote a script to do it automatically. Good enough to run was fine, until it started managing real money and other people started using it. Then "good enough" wasn't.
This is the story of how it went from a pile of Python scripts to its current architecture.
The old version: FastAPI + Vue, working but fragile
The first version was textbook:
- FastAPI backend running a few async tasks to poll the market and place offers
- Vue 3 + Vite frontend drawing some earnings charts
- Docker Compose to glue it together
The problem wasn't the stack — it was that the system had no concept of "what happens when this fails." If the network dropped after an offer was sent, I had no idea whether it went through. If the exchange returned a 5xx now and then, the whole poll loop would stall. Adding a field meant hand-editing the schema. It ran fine with the wind at its back, but lending is about managing money, and headwind is the normal case.
Why I rewrote it in Spring Boot + Next.js
Rather than patch it, I rewrote it. The backend went to Spring Boot 3 / Java 21, for very practical reasons:
- Java/Spring is my day job — familiarity translates directly into reliability
- What I wanted was transactions, scheduling (
@Scheduled), circuit breakers, metrics — the "boring but mature" stuff, all available in one ecosystem - Strong typing + JPA + Flyway means schema changes are versioned and migrated, no more hand-editing the database
The frontend became Next.js 16 / React 19, statically exported (output: export) straight to Cloudflare Pages — the frontend needs no server, a CDN is enough.
Architecture overview
It's a monorepo, roughly layered like this:
Next.js (static export) → Cloudflare Pages
│ JWT (HttpOnly cookie) + XSRF
▼
Cloudflare Tunnel → Spring Boot (Java 21)
├─ Lending engine (scheduled)
├─ Reconciliation (scheduled)
├─ Bitfinex client (circuit breaker + rate limit)
├─ Notifications (Email / Telegram)
└─ Audit (hash chain)
│
PostgreSQL 16 Redis 7
(Flyway) (cache/lock/rate-limit)
The backend faces the world through a Cloudflare Tunnel, so the server needs no public IP and no self-managed reverse-proxy certificates — a big simplification for a personal project.
The lending engine: write the DB first, then place the order
The most important — and most interesting — part is the idempotent order placement.
The lending engine runs on a fixed schedule. Each cycle is roughly: read the market → compute the rate and period to offer → compute available balance → place the order. Simple-sounding, but "place the order" talks to an external exchange and can time out at any moment, or succeed without the response ever reaching me. Naively retrying there means lending the same money twice.
My fix is to invert the order — write the DB first, then place the order:
- Write a
PENDINGorder to the DB first, with a self-generated idempotency UUID - Then send the offer to Bitfinex
- Success → update to
ACTIVEand record the exchange's offer ID - API logic error → mark
ERROR - Transport-level error (timeout, etc.) → leave it
PENDING, don't guess
Step 5 is the key: on a connection error I assume neither success nor failure, and hand the decision to reconciliation.
Reconciliation: collapsing the uncertainty
A second scheduled job, reconciliation, runs on a longer interval. It scans for orders stuck in PENDING and asks the exchange whether each one actually landed:
- Exchange knows about it → update to the matching state
- Exchange doesn't → infer it never sent, and the next engine cycle will reprocess it
This "optimistic placement + after-the-fact reconciliation" combo means the system never double-places under flaky networks, and never strands money in an unknown state. It's the biggest lesson from the old version: in a distributed setting, state must be able to heal itself, not pray that every call succeeds.
Risk control: isolating exchange failures
The external API is the least controllable piece, so I wrap it in two layers:
- Circuit breaker (Resilience4j): trip open after a failure ratio threshold and stop hitting the exchange for a while, so I don't hammer it (and myself) while it's down. There's a subtlety here — only transport-level errors count toward the breaker; API logic errors (e.g. bad params) are my own bugs and shouldn't trip it.
- Rate limiting (Redis token bucket): the exchange has rate limits, so I use Redis as a shared counter for a token bucket with some headroom, to avoid getting blocked for being too aggressive.
The pricing strategy itself anchors to the funding order book's depth and adds a dynamic premium, with a rate sanity check before submission to reject outliers. I'll keep the exact parameters private, but that's the skeleton.
Security: you're managing other people's money
The platform touches users' Bitfinex API keys, and a leak there is a disaster, so:
- Login is Email + password (BCrypt) + TOTP two-factor, issuing a JWT
- Users' exchange keys are AES-256 encrypted before they hit the DB, decrypted only when needed
- The audit log is a hash chain (each record includes the previous record's hash), so tampering is detectable after the fact
Observability and operations
In the old version, when something broke I could only grep logs. Now:
- Structured JSON logs
- Prometheus scrapes metrics (lending-cycle latency, orders placed, circuit-breaker state, rate-limit saturation…), Grafana dashboards, Alertmanager pings Telegram on incidents
- Health checks split into liveness / readiness, with Docker probing accordingly
Deployment runs CI on a self-hosted GitHub Actions runner, secrets are injected from 1Password, and multi-stage Docker builds produce a slim image. For a one-person project, this is what lets me not wake up at 3am to check on it.
Mistakes I made
- Idempotency isn't just slapping on a UUID — it's the ordering of the whole placement flow. Think through "what happens if this step fails" before writing it.
- Classify your errors for the circuit breaker, or you'll trip on your own bugs and make problems harder to find.
- The cost of a rewrite is real. Going from FastAPI to Spring wasn't translating code — it was rethinking every edge case. But what I got back was the confidence to let it run on its own.
Wrap-up
What this side project taught me is the same thing my day job does: writing a program that runs is easy; writing a system that gets back up when it fails is hard. Idempotency, reconciliation, circuit breakers, observability — none of it shows in a demo, but when it's quietly managing your money at 3am, it's everything.
Loading comments…