DEV Community

Cover image for How I Built a Live Football Platform That Doesn't Fall Apart Under Load
Ahmed Ali
Ahmed Ali

Posted on • Originally published at Medium

How I Built a Live Football Platform That Doesn't Fall Apart Under Load

A walkthrough of the architecture decisions behind Flacron Gamezone a production full-stack app built with Next.js, Express, PostgreSQL, and Redis.

When a client approached me to build a live football match discovery platform, the requirements sounded straightforward on the surface: show live scores, let users subscribe, handle authentication. But the moment you start thinking about how those pieces connect in production, straightforward gets complicated fast.

This is the story of how I designed the backend for Flacron Gamezone — what decisions I made, why I made them, and what broke along the way.


Table of Contents


The Problem With "Just Building It"

The easiest version of this app is a single Express file: one route handler that queries the database, formats the data, and sends a response. I've seen this pattern in tutorials everywhere. It works for demos. It falls apart in production.

The problems are predictable: you can't test business logic without hitting the database, a change in one feature quietly breaks another, and the moment a second developer joins the codebase, nobody knows where anything lives.

I wanted to build something I could actually be proud to show an employer or a client. That meant committing to a proper layered architecture from day one, even on a project this size.


The Architecture: Four Distinct Layers

The entire Express backend is organized into four layers. Each layer has one job and talks only to the layer directly below it.

Route → Controller → Service → Repository

Here's what each one actually does.

Routes are just maps. They declare that POST /api/v1/subscriptions exists, attach the auth middleware, and hand off to the controller. No logic lives here.

Controllers handle the HTTP boundary. They extract data from req.body or req.params, call the appropriate service method, and send back a formatted response. They don't know anything about databases. They don't contain business rules. If a request comes in malformed, the controller catches it and returns a 400. That's the full extent of its responsibility.

Services are where the application logic lives. A SubscriptionService knows that before creating a subscription, it needs to verify the user doesn't already have one, call Stripe to create a customer, and only then persist the record. It coordinates between multiple repositories if needed. It throws typed errors that the controller can catch and translate into HTTP responses.

Repositories are the only layer that touches the database. A UserRepository has methods like findById, findByEmail, create. It uses Prisma under the hood. Nothing outside this layer writes a SQL query or touches a Prisma client directly.

The result is that when the client asked me to change how subscriptions were priced mid-project, I changed two methods in SubscriptionService and one in SubscriptionRepository. The routes, controllers, and every other service were completely untouched.


Why This Matters to a Client

If you're a client reading this: layered architecture means your project doesn't become unmaintainable the moment the original developer moves on. Any competent backend developer can read this codebase and understand where to make a change within minutes, not hours.

If you're an employer reading this: this is the pattern used in production systems at scale. The reason it's taught in enterprise codebases isn't bureaucracy — it's because the alternative is a system where nobody can safely change anything without breaking something else.


The Bug That Taught Me Something Real

Midway through development, Prisma started throwing connection timeout errors. Not consistently — just intermittently, and always on the first query after a cold start.

I spent an embarrassing amount of time checking my connection pool configuration, my .env file, my database credentials. Everything looked correct.

The actual problem: on Windows, localhost resolves to the IPv6 address ::1 before it tries IPv4 127.0.0.1. PostgreSQL, by default, listens on IPv4. Prisma was trying to connect to the IPv6 address, the connection was being refused, and the error message gave me no indication that address resolution was even involved.

The fix was a single character change in the database URL — replacing localhost with 127.0.0.1.

Before
DATABASE_URL="postgresql://user:pass@localhost:5432/flacron"
After
DATABASE_URL="postgresql://user:pass@127.0.0.1:5432/flacron"

I'm documenting this here because I found almost nothing about it when I was searching. If you're on Windows and Prisma is timing out on cold start, try this before you spend three hours reading connection pool documentation.


The Full Stack at a Glance

  • Frontend: Next.js 15 with App Router, deployed on Vercel
  • Backend: Express.js with the four-layer architecture described above
  • Database: PostgreSQL with Prisma ORM
  • Cache: Redis for live match data (cache-aside pattern, short TTL)
  • Payments: Stripe subscriptions with webhook verification
  • Auth: JWT access/refresh token pattern with role-based guards

Each piece was chosen deliberately. Redis sits in front of the live match queries specifically because that data is read far more often than it's written, and hitting the database on every poll request doesn't scale. Stripe handles payments because rolling your own payment processing is never the right call. PostgreSQL over MongoDB because this data is relational — users, subscriptions, and matches have hard dependencies between them that a document database makes awkward to enforce.


What I'd Do Differently

One thing I underestimated was how much time Stripe webhook handling would take to get right. Verifying the webhook signature, handling idempotency, making the handler resilient to duplicate events — each of those is its own small problem. I'd budget more time for that in future projects.

I'd also set up structured logging with Pino earlier. Console logs are fine locally. The first time you're debugging a production issue and you have no searchable, structured log trail, you feel it immediately.


The Live Project

Flacron Gamezone is live at flacrongamezone.com. The source code for the local development version is on GitHub.

If you're building something and want a developer who thinks about architecture before writing the first line of code, reach me at syedahmedali.com.


Ahmed Ali is a Full-Stack Developer based in Pakistan, building production-ready web apps and AI-powered SaaS products. Next.js · Node.js · PostgreSQL · Redis · TypeScript.

Top comments (0)