← Back to Blog

Building MatchSense: What Event-Driven Systems Taught Me About Failure

MatchSense is a real-time football (soccer) analytics platform: match events (goals, shots, fouls, corners, cards) flow through Kafka into a stats aggregator, then into an ML prediction service, and out through a read API and a live Grafana dashboard. The match data is simulator-generated, not a live sports feed, and the xG and win-probability models are trained on synthetic data. What's real is everything downstream of the event: the pipeline, the failure handling, and the observability. That's also where almost everything I learned actually happened.

Key technologies

Go · Python / FastAPI · Kafka · Redis · OpenTelemetry · Prometheus · Grafana · Loki · Jaeger · Kubernetes / Kustomize · ArgoCD · Kyverno · Trivy · Cosign

github.com/nhatminh06/matchsense

Why this project exists

I wanted a project that would force me to deal with distributed-systems problems I could talk around in a tutorial but couldn't fake in a real pipeline: what happens when a message gets delivered twice, what happens when a downstream service is slow, and how do you actually prove that a single request was handled correctly across five services instead of just hoping it was.

Service architecture

MatchSense is five services, each doing one job:

ServiceLanguageJob
event-apiGoHTTP ingestion for match events, publishes to Kafka
event-processorGoAggregates events into running match stats
ml-predictorPython / FastAPIxG and win-probability predictions
query-apiGoRead API over Redis for stats and predictions
match-simulatorGoGenerates a simulated match for local/demo use

A match event goes match-simulator → event-api → Kafka(match-events) → event-processor → Redis + Kafka(match-stats) → ml-predictor → Redis → query-api. Five services, two Kafka topics, one Redis store as the shared source of truth for current state.

Why Kafka, why Redis

These aren't interchangeable. Kafka is the durable event log: the record of what happened, replayable, ordered per partition. Redis is the current-state cache: fast, ephemeral, and only ever as correct as the last event that updated it. Treating them as competing options is a mistake I see in a lot of write-ups: the actual design question is which one owns the truth for a given piece of data. Kafka owns "what happened." Redis owns "what's true right now."

The duplicate-event problem

Kafka's delivery guarantee is at-least-once, not exactly-once, by default. A producer retry after a network blip, or a consumer rebalance mid-processing, can mean the same event arrives twice. If event-processor just increments a shot counter on every message it sees, a duplicate delivery silently corrupts the stats.

The fix is event identity plus idempotency: every event carries an event_id, either supplied by the caller or generated by event-api. event-processor checks whether it has already applied that ID before mutating state. Resending the same ID is deduplicated instead of double-counted. This sounds obvious written down; it was not obvious the first time I watched a shot counter jump by two for one shot and had to work backward to why.

Retries and dead-letter handling

The harder question is what happens when a consumer can't process a message: a malformed payload, a downstream dependency that's down, a bug in the aggregation logic itself. Retrying forever blocks the partition behind it. Dropping the message silently loses data with no signal that anything went wrong. The pattern I built around is bounded retries with backoff, and a dead-letter path for anything that still fails after those retries are exhausted, so a bad message becomes a visible, inspectable artifact instead of an invisible black hole in the stats.

Tracing a request across five services

Every hop propagates an OpenTelemetry trace context through Kafka message headers, so a single match event can be followed end to end in Jaeger, from the HTTP POST that created it, through both Kafka topics, into the ML prediction, and out through the query API. Before wiring this up, debugging a slow prediction meant checking five services' logs by hand and guessing at timestamps. After, it meant opening one trace. That difference is the entire argument for observability tooling that otherwise looks like overhead on a portfolio project.

GitOps and the security layer

Deployment goes through Kustomize manifests synced by ArgoCD rather than a CI job that runs kubectl apply directly, and Kyverno admission policies enforce signed images, resource limits, and non-privileged containers at the cluster level regardless of what the pipeline intended to deploy. Every image is scanned with Trivy, gets an SBOM, and is signed with Cosign before it's eligible to run. None of this changes what the application does. It changes what I can prove about what's running, which is a different and, I'd argue, more important property for anything meant to run unattended.

What's still simulated

To be direct about the limits: there's no real sports data feed wired in, and the ML models are trained on data the simulator generates, not historical match data. The engineering (the pipeline, the idempotency, the tracing, the delivery pipeline) is real and running. The football analytics themselves are a demonstration, not a fielded product, and I'd be misrepresenting the project if I implied otherwise.

What I learned

  • At-least-once delivery is the default you should assume, not the edge case. Idempotency isn't optional polish, it's the baseline correctness requirement for any consumer.
  • A durable log and a fast cache solve different problems. Deciding which one owns which piece of state is a design decision, not an implementation detail.
  • Tracing pays for itself the first time you need it. The cost is wiring it up before you need it; the alternative cost is debugging blind across five services during an incident.
  • Supply-chain controls belong at the cluster, not just the pipeline. A CI check can be skipped or bypassed; an admission policy at the cluster can't.

Closing

MatchSense started as a way to build something with Kafka in it. It became a much better lesson in what "event-driven" actually costs once you take failure seriously: every message can arrive twice, every consumer can fail, and every trace you don't wire up in advance is a debugging session you'll wish you had later.