Skip to content

Backend Overview

The backend is a Go monorepo at ftl-backend/. It compiles three separate binaries from cmd/. All shared logic lives under internal/.

Module layout

ftl-backend/
├── cmd/
│   ├── api-server/main.go   — HTTP REST server
│   ├── ws-server/main.go    — WebSocket server
│   └── flusher/main.go      — Redis→Postgres drain worker
├── internal/                — all packages (never import from cmd/)
├── internal/redis/lua/      — Lua scripts loaded by the Redis client
└── migrations/              — sql-migrate up/down files

internal/ packages

Package Purpose
achievements Badge definitions, earned/locked state per user
activity Social-proof feed (Redis-backed recent trade/goal events)
adminsettings Admin-writable runtime config (scoring weights, cooldown secs); persisted to admin_settings table
amm AMM pricing helpers (arithmetic series cost, fill price)
auth JWT issue/validate (RS256), Google JWKS verifier, JTI blocklist, reg ticket HMAC signer, cookie helpers
bot Synthetic bot engine: per-persona trading against live instruments for liquidity
config Env-var loading via config.Load(); returns typed *Config struct
db pgxpool wrapper with retry logic; single entry point db.NewPool()
email Azure Communication Services sender + NoopSender fallback; transactional email templates
featureflag Boolean feature flags persisted to feature_flags table; coming_soon gate
flusher Periodic Redis→Postgres sync (dirty-set drain), trade outbox retrier, position outbox retrier
handler All HTTP handlers + Deps struct; handler.New(Deps).Register() wires routes
instrument Instrument CRUD + Redis hydration; AMM price computation via net_shares_sold
leaderboard PnL, ROI, district, friends leaderboard reads and writes; Redis sorted sets
lease Single-instance Redis lease primitive (used by backstop sweeper and StopWatch)
margin ADR-0006 washout evaluator: replays price ticks for offline users, closes positions at breach price
match Fixture CRUD, squad queries, Redis event-list reads
middleware Fiber middlewares: JWTAuth, JWTAuthOptional, AdminOnly, RateLimit, SecurityHeaders, ComingSoonGate
notify In-app notification create/list/read; plugged into positions and margin via notify hook
otp 6-digit OTP issue, verify (3 attempts), cooldown (60s), verified-flag storage in Redis
portfolio Wallet balance + unrealized PnL summary; reads from Redis hash then Postgres fallback
positions CFD position open/close/modify/list; StopWatch SL/TP enforcement goroutine
redis Redis client constructor (ftlredis.NewClient) and Lua script loader
referral Per-invite links, referral code validation, first-trade payout (PayoutOnFirstTrade)
replay Admin gameplay-bot: loads bundled fixtures from internal/replay/fixtures/, replays as live ticks
spinner Weekly tiebreaker spin; SpinFor uses ON CONFLICT DO NOTHING for idempotency
sportmonks Sportmonks API client, live scheduler (2s poll), squad syncer, post-match processor
trade Legacy BUY/SELL trades via trade_execute.lua; sell cooldown; system sell at FT
user User registration, login, district validation, admin promotion
wallet Wallet read/write; Redis hash as hot cache, Postgres as durable store
ws WebSocket hub (connection map, subscribe/unsubscribe by instrument idx), Redis pub/sub subscriber
xp XP/level award and read

Dependency injection pattern

cmd/api-server/main.go constructs every service directly, in dependency order:

xpSvc := xp.NewService(pool)
walletSvc := wallet.NewService(pool, redisClient.RDB())
// ... build every service ...
h := handler.New(handler.Deps{
    Pool:          pool,
    UserSvc:       userSvc,
    WalletSvc:     walletSvc,
    // ...
})
h.Register(api, !cfg.IsProduction())

The handler.Deps struct in internal/handler/routes.go is the single place where all injectable services are named. There is no global state or service locator.

How to add a new service

  1. Create internal/<domain>/service.go with type Service struct and func NewService(...) *Service.
  2. Add a field to handler.Deps in internal/handler/routes.go.
  3. Add the matching field to handler.Handlers and copy it in handler.New().
  4. Wire the constructed service into the handler.Deps{} literal in cmd/api-server/main.go.
  5. Register any routes in handler.Handlers.Register() or in your own Register(r fiber.Router) method called from there.

Note

Construction order matters. Services that depend on others (e.g. referral needs wallet) must be constructed after their dependencies. The order in main.go is the canonical reference.