[{"data":1,"prerenderedAt":551},["ShallowReactive",2],{"work-\u002Fwork\u002Fbandabets":3,"work-siblings-\u002Fwork\u002Fbandabets":190},{"id":4,"title":5,"body":6,"cover":157,"coverFit":158,"description":159,"extension":160,"featured":161,"liveUrl":162,"markets":163,"meta":170,"metric":171,"metricLabel":172,"navigation":161,"order":149,"path":173,"period":174,"role":175,"screenshots":176,"seo":179,"stack":180,"stem":187,"tagline":188,"__hash__":189},"work\u002Fwork\u002Fbandabets.md","BandaBets",{"type":7,"value":8,"toc":147},"minimark",[9,14,18,21,25,28,35,41,48,52,55,89,92,95,98,102,119,123,140,144],[10,11,13],"h2",{"id":12},"context","Context",[15,16,17],"p",{},"BandaBets runs in six countries — Kenya, Nigeria, Uganda, Zambia, Malawi and the DRC — off a single\nfrontend platform. Each market brings its own currency, its own payment providers, its own\nregulator and, in several cases, its own language.",[15,19,20],{},"The codebase had grown the way frontends do: one Nuxt application that started small, accumulated a\nsportsbook, a casino, a payments flow, an account area and six markets' worth of localisation, and\nnever got reorganised along the way.",[10,22,24],{"id":23},"the-problem","The problem",[15,26,27],{},"Two problems, and they compounded each other.",[15,29,30,34],{},[31,32,33],"strong",{},"The deployment unit."," Everything shipped together, so everything carried the risk of everything\nelse. A one-line copy change meant rebuilding and redeploying the whole platform. That produces a\npredictable spiral: releases get batched because they're risky, batches get bigger, bigger batches\nare riskier, and shipping slows until the frontend is the bottleneck for work that was finished days\nago. Two engineers touching different features regularly collided in the same files.",[15,36,37,40],{},[31,38,39],{},"The markets."," Multi-market platforms usually fail in one of two directions. Fork per market and\nyou get six codebases drifting apart, where every bug fix is six pull requests and the sixth one\ngets forgotten. Hardcode one market and bolt the others on, and every new country becomes an\narchaeology expedition through assumptions nobody wrote down — that the currency has two decimal\nplaces, that phone numbers start with a particular prefix, that the copy fits the button.",[15,42,43,44,47],{},"The goal was one deployable platform where ",[31,45,46],{},"a market is a configuration, not a branch",".",[10,49,51],{"id":50},"architecture","Architecture",[15,53,54],{},"The split followed the product's natural seams rather than a technical taxonomy:",[56,57,58,65,71,77,83],"ul",{},[59,60,61,64],"li",{},[31,62,63],{},"Sportsbook"," — fixtures, odds, bet slip, live updates",[59,66,67,70],{},[31,68,69],{},"Casino"," — game catalogue, provider integrations, session handling",[59,72,73,76],{},[31,74,75],{},"Wallet & payments"," — deposits, withdrawals, transaction history",[59,78,79,82],{},[31,80,81],{},"Account"," — auth, profile, verification, responsible-gaming controls",[59,84,85,88],{},[31,86,87],{},"Shared core"," — design system, i18n, session context, API client",[15,90,91],{},"Each domain became an independently buildable and deployable Nuxt layer with its own routes and\ncomponents. The shared core was extracted into a versioned internal package, so common UI and the\nAPI client had exactly one definition rather than several slightly diverged copies.",[15,93,94],{},"Layers were the right primitive here specifically because they compose at build time — the user\nstill gets one coherent application and one navigation, not six apps stitched together with full\npage reloads.",[15,96,97],{},"Market differences live in configuration: currency and locale per deployment, payment providers\nresolved per market, and locale bundles loaded on demand so a user in Nairobi never downloads French\ncopy. Layouts were built to survive translation — Swahili strings routinely run longer than the\nEnglish they replace, and a fixed-width button is a rendering bug waiting for a translator.",[10,99,101],{"id":100},"what-i-built","What I built",[56,103,104,107,110,113,116],{},[59,105,106],{},"The module boundaries, and the migration path that moved existing code across them without\nfreezing feature work — the split happened incrementally alongside ongoing delivery.",[59,108,109],{},"The shared design-system and i18n package, covering English, Swahili and French.",[59,111,112],{},"Per-module CI pipelines, so a change to the casino builds and deploys the casino.",[59,114,115],{},"Backend services supporting the split — API contracts scoped per domain instead of one endpoint\nreturning everything any screen might need.",[59,117,118],{},"Rendering strategy per route: static generation for marketing and catalogue pages, server-side\nrendering for anything session- or odds-dependent.",[10,120,122],{"id":121},"impact","Impact",[56,124,125,131,134,137],{},[59,126,127,130],{},[31,128,129],{},"Shipping time down 25%."," Mostly structural rather than clever: smaller deployment units mean a\nsmaller blast radius, which means less batching and less pre-release ceremony.",[59,132,133],{},"Multiple engineers work in parallel without merge collisions in shared files.",[59,135,136],{},"Acquisition routes prerender to static HTML — a real difference on the mid-range Android devices\nthat make up most of the traffic.",[59,138,139],{},"Adding a market became configuration and a provider integration rather than a fork.",[10,141,143],{"id":142},"what-id-revisit","What I'd revisit",[15,145,146],{},"The shared core is doing a lot of work, and shared packages have gravity — every convenience added\nto one makes it harder to change. It needs an explicit rule about what earns a place in it, enforced\nin review rather than left to judgement, or it slowly becomes the monolith it replaced.",{"title":148,"searchDepth":149,"depth":149,"links":150},"",2,[151,152,153,154,155,156],{"id":12,"depth":149,"text":13},{"id":23,"depth":149,"text":24},{"id":50,"depth":149,"text":51},{"id":100,"depth":149,"text":101},{"id":121,"depth":149,"text":122},{"id":142,"depth":149,"text":143},"\u002Fimages\u002Fwork\u002Fbandabets.png","contain","Re-architected a monolithic Nuxt frontend into modular, independently deployable services across six African markets — shipping time cut 25%.","md",true,"https:\u002F\u002Fke.bandabets.com\u002F",[164,165,166,167,168,169],"Kenya","Nigeria","Uganda","Zambia","Malawi","DRC",{},"25%","faster shipping","\u002Fwork\u002Fbandabets","2024 — 2026","Frontend architecture · backend services",[177],{"src":157,"caption":178},"Mobile web — the surface most of the traffic actually arrives on",{"title":5,"description":159},[181,182,183,184,185,186],"Nuxt","Vue 3","TypeScript","Node.js","Docker","Kubernetes","work\u002Fbandabets","One frontend platform, six countries, shipped by a team that stopped colliding","IDMpyKv0xhgcscuywNWGYf_DxgS8PvMzMTuqPV3YqyU",[191,349,452],{"id":192,"title":193,"body":194,"cover":325,"coverFit":326,"description":327,"extension":160,"featured":161,"liveUrl":328,"markets":329,"meta":330,"metric":331,"metricLabel":332,"navigation":161,"order":333,"path":334,"period":174,"role":335,"screenshots":336,"seo":339,"stack":340,"stem":346,"tagline":347,"__hash__":348},"work\u002Fwork\u002Fbetzetu.md","BetZetu",{"type":7,"value":195,"toc":317},[196,198,206,209,212,214,217,237,239,249,255,261,267,270,272,289,291,312,314],[10,197,13],{"id":12},[15,199,200,201,205],{},"Betting platforms are an attractive target. The money moves fast, the account lifecycle is short,\nand the abuse patterns — bonus farming, collusion between accounts, deposit-and-withdraw laundering\ncycles — only become visible when you look at behaviour ",[202,203,204],"em",{},"across"," events rather than at one\ntransaction in isolation.",[15,207,208],{},"The platform already had fraud rules. They ran as scheduled batch jobs against the transactional\ndatabase, which meant two problems: the checks were competing with live traffic for database\ncapacity, and by the time a pattern surfaced, the money was usually gone.",[15,210,211],{},"Detection needed to move off the database and onto the event stream.",[10,213,24],{"id":23},[15,215,216],{},"Three constraints shaped everything:",[56,218,219,225,231],{},[59,220,221,224],{},[31,222,223],{},"Volume."," Peak traffic produces well over 50,000 relevant events a minute — bet placements,\nsettlements, deposits, withdrawals, logins, profile edits.",[59,226,227,230],{},[31,228,229],{},"Latency."," A decision that arrives after the withdrawal has cleared is a report, not a control.\nTo actually block a transaction, the verdict has to come back inside the request window.",[59,232,233,236],{},[31,234,235],{},"Statefulness."," The interesting signals aren't per-event. They're \"this account has made 14\ndeposits from 5 different instruments in 20 minutes\" — which means holding windowed state across\na stream, per account, continuously.",[10,238,51],{"id":50},[240,241,246],"pre",{"className":242,"code":244,"language":245},[243],"language-text","services ──▶ Kafka ──▶ Flink ──▶ decision API ──▶ platform\n             (log)     (state)        │\n                                      └──▶ flagged ──▶ manual review\n","text",[247,248,244],"code",{"__ignoreMap":148},[15,250,251,254],{},[31,252,253],{},"Kafka"," as the ingest log. Every service publishes domain events to partitioned topics keyed by\naccount ID, which gives two things for free: ordering guarantees per account, and horizontal scale\nby adding partitions and consumers. Because the log is durable and replayable, a change to the\ndetection logic can be validated against real historical traffic before it goes anywhere near\nproduction.",[15,256,257,260],{},[31,258,259],{},"Flink"," for the stateful layer. Sliding windows per account track deposit velocity, stake\npatterns, win-rate anomalies and device\u002FIP overlap between accounts. Flink's checkpointing is what\nmakes this survivable — a task manager restart resumes from the last checkpoint instead of losing\nthe window and starting cold.",[15,262,263,266],{},[31,264,265],{},"A decision service"," consuming the scored output and returning an allow\u002Fblock verdict on the\nsynchronous path, with Redis holding hot account state so the common case never touches Postgres.",[15,268,269],{},"Anything scored above the threshold diverts to a review queue rather than being auto-blocked\noutright — a false positive that freezes a legitimate customer's withdrawal is its own kind of\nexpensive.",[10,271,101],{"id":100},[56,273,274,277,280,283,286],{},[59,275,276],{},"Event schemas and the Kafka topic topology — partitioning strategy, retention, and consumer group\nlayout for independent scaling of each consumer.",[59,278,279],{},"Flink jobs for the windowed aggregations, including watermark and late-arrival handling, since\nmobile clients on unreliable connections deliver events out of order routinely.",[59,281,282],{},"The decision service on the synchronous path, with a Redis cache for hot account state.",[59,284,285],{},"Consumer resilience — dead-letter handling for malformed payloads, and an offset strategy that\ndoesn't replay a poison message into a crash loop on every restart.",[59,287,288],{},"Kubernetes deployment across multiple clusters, with the throughput and lag dashboards needed to\ntell \"the pipeline is healthy\" from \"the pipeline is quietly falling behind\".",[10,290,122],{"id":121},[56,292,293,300,306,309],{},[59,294,295,296,299],{},"Sustains ",[31,297,298],{},"50,000+ events per minute"," through the pipeline.",[59,301,302,305],{},[31,303,304],{},"Sub-200ms p99"," end-to-end, fast enough to sit on the synchronous path.",[59,307,308],{},"Fraud checks moved entirely off the transactional database, removing a recurring source of\ncontention during peak hours.",[59,310,311],{},"Detection logic can be replayed against historical traffic before release, so a rule change is\nsomething you can measure rather than hope about.",[10,313,143],{"id":142},[15,315,316],{},"The windowing thresholds were tuned empirically and encode assumptions about traffic that shift by\nmarket. The next step is making them adaptive per market rather than globally configured — Nairobi\nat 9pm and Lagos at 9pm are not the same distribution, and treating them as one costs you either\nrecall or false positives.",{"title":148,"searchDepth":149,"depth":149,"links":318},[319,320,321,322,323,324],{"id":12,"depth":149,"text":13},{"id":23,"depth":149,"text":24},{"id":50,"depth":149,"text":51},{"id":100,"depth":149,"text":101},{"id":121,"depth":149,"text":122},{"id":142,"depth":149,"text":143},"\u002Fimages\u002Fwork\u002Fbetzetu.png","cover","A Kafka and Flink pipeline that scores every bet, deposit and withdrawal as it happens — 50K+ events per minute, decisions back in under 200ms at p99.","https:\u002F\u002Fwww.betzetu.com\u002F",[],{},"50K+","events \u002F minute",1,"\u002Fwork\u002Fbetzetu","Backend engineering · streaming pipeline",[337],{"src":325,"caption":338},"Sportsbook home — live markets, betslip and the highlights feed",{"title":193,"description":327},[341,342,343,344,345,186],"Go","Apache Kafka","Apache Flink","PostgreSQL","Redis","work\u002Fbetzetu","Real-time fraud detection on a live betting platform","b_p68N_YoS8Cld0tgJLrG1AA96zC2lObrsRHVGdO9mg",{"id":4,"title":5,"body":350,"cover":157,"coverFit":158,"description":159,"extension":160,"featured":161,"liveUrl":162,"markets":446,"meta":447,"metric":171,"metricLabel":172,"navigation":161,"order":149,"path":173,"period":174,"role":175,"screenshots":448,"seo":450,"stack":451,"stem":187,"tagline":188,"__hash__":189},{"type":7,"value":351,"toc":438},[352,354,356,358,360,362,366,370,374,376,378,400,402,404,406,408,420,422,434,436],[10,353,13],{"id":12},[15,355,17],{},[15,357,20],{},[10,359,24],{"id":23},[15,361,27],{},[15,363,364,34],{},[31,365,33],{},[15,367,368,40],{},[31,369,39],{},[15,371,43,372,47],{},[31,373,46],{},[10,375,51],{"id":50},[15,377,54],{},[56,379,380,384,388,392,396],{},[59,381,382,64],{},[31,383,63],{},[59,385,386,70],{},[31,387,69],{},[59,389,390,76],{},[31,391,75],{},[59,393,394,82],{},[31,395,81],{},[59,397,398,88],{},[31,399,87],{},[15,401,91],{},[15,403,94],{},[15,405,97],{},[10,407,101],{"id":100},[56,409,410,412,414,416,418],{},[59,411,106],{},[59,413,109],{},[59,415,112],{},[59,417,115],{},[59,419,118],{},[10,421,122],{"id":121},[56,423,424,428,430,432],{},[59,425,426,130],{},[31,427,129],{},[59,429,133],{},[59,431,136],{},[59,433,139],{},[10,435,143],{"id":142},[15,437,146],{},{"title":148,"searchDepth":149,"depth":149,"links":439},[440,441,442,443,444,445],{"id":12,"depth":149,"text":13},{"id":23,"depth":149,"text":24},{"id":50,"depth":149,"text":51},{"id":100,"depth":149,"text":101},{"id":121,"depth":149,"text":122},{"id":142,"depth":149,"text":143},[164,165,166,167,168,169],{},[449],{"src":157,"caption":178},{"title":5,"description":159},[181,182,183,184,185,186],{"id":453,"title":454,"body":455,"cover":531,"coverFit":326,"description":532,"extension":160,"featured":161,"liveUrl":533,"markets":534,"meta":535,"metric":536,"metricLabel":537,"navigation":161,"order":538,"path":539,"period":174,"role":540,"screenshots":541,"seo":544,"stack":545,"stem":548,"tagline":549,"__hash__":550},"work\u002Fwork\u002Fnetbet.md","NetBet",{"type":7,"value":456,"toc":524},[457,459,462,465,467,470,477,480,482,488,494,500,506,508,519,521],[10,458,13],{"id":12},[15,460,461],{},"NetBet is the Uganda deployment: a full sportsbook and casino serving a single market. I worked on\nthe backend — the services behind accounts, betting, settlement and the wallet, plus the payment\nintegrations that connect it to how people in Uganda actually move money.",[15,463,464],{},"A single-market platform sounds simpler than a multi-country one, and in one sense it is. What it\nremoves in configuration complexity it hands back in specificity: one currency, one regulator, one\nset of payment rails, and no ability to hide a bad assumption behind a per-market override.",[10,466,24],{"id":23},[15,468,469],{},"The interesting constraints were on the money path.",[15,471,472,473,476],{},"Most deposits and withdrawals here run through ",[31,474,475],{},"mobile money",", not cards. That changes the failure\nmodel. A card gateway either authorises or declines, usually inside a second. A mobile-money\ntransaction is asynchronous: the platform initiates, the user confirms on their handset, and a\ncallback arrives at some point afterwards — or doesn't, or arrives twice, or arrives after the user\nhas already given up and retried.",[15,478,479],{},"Any wallet built on the assumption that a payment resolves synchronously will eventually\ndouble-credit somebody or lose a deposit. Those are the two failure modes that actually matter,\nbecause one costs money and the other costs trust.",[10,481,101],{"id":100},[15,483,484,487],{},[31,485,486],{},"Payments."," Provider integrations for deposits and withdrawals against local mobile-money rails,\nbuilt around the assumption that callbacks are unreliable by default. Every transaction carries an\nidempotency key, so a provider retrying a webhook credits an account once and not three times.\nCallback signatures are verified rather than trusted. Pending transactions reconcile against the\nprovider rather than sitting in limbo waiting for a callback that may never arrive.",[15,489,490,493],{},[31,491,492],{},"The ledger."," Amounts stored in minor units against an explicit currency, never as floats.\nUGX has its own conventions, and a ledger that is vague about what a number means is a ledger that\nwill eventually be wrong in a way nobody can reconstruct.",[15,495,496,499],{},[31,497,498],{},"Services."," Go and Spring Boot services for accounts, betting, settlement and wallet operations,\ncommunicating over gRPC internally and REST at the edge — the same service patterns as the rest of\nthe platform stack, so operational knowledge transfers between markets instead of being relearned.",[15,501,502,505],{},[31,503,504],{},"Streaming."," Domain events published to Kafka, feeding the same downstream consumers used for\nmonitoring and fraud detection elsewhere on the stack.",[10,507,122],{"id":121},[56,509,510,513,516],{},[59,511,512],{},"A live sportsbook and casino running in Uganda on the shared platform stack.",[59,514,515],{},"Payment flows that survive the realities of mobile money — retried callbacks, delayed\nconfirmations and user retries — without double-crediting or silently dropping deposits.",[59,517,518],{},"Backend patterns consistent with the other markets, so a fix made once applies broadly rather than\nbeing reimplemented per country.",[10,520,143],{"id":142},[15,522,523],{},"Provider integrations were built one at a time as each rail came online, which left more shape in\ncommon between them than the code currently admits. A single provider abstraction with\nper-provider adapters would make the next integration substantially cheaper — the pattern was\nvisible by the second one, and that was the moment to consolidate.",{"title":148,"searchDepth":149,"depth":149,"links":525},[526,527,528,529,530],{"id":12,"depth":149,"text":13},{"id":23,"depth":149,"text":24},{"id":100,"depth":149,"text":101},{"id":121,"depth":149,"text":122},{"id":142,"depth":149,"text":143},"\u002Fimages\u002Fwork\u002Fnetbet.png","Backend services for a live sportsbook in Uganda — mobile-money payments built to survive unreliable callbacks, and a UGX-denominated ledger.","https:\u002F\u002Fnetbetug.com",[166],{},"UG","Uganda · live platform",3,"\u002Fwork\u002Fnetbet","Backend engineering",[542],{"src":531,"caption":543},"Sportsbook home — markets, betslip and the live feed",{"title":454,"description":532},[341,546,547,344,342,345],"Java","Spring Boot","work\u002Fnetbet","Backend engineering for the Uganda market","4O6uSaed32B8awgaAlXSgNwuo-atFXHfrWr-icfc42I",1785591010548]