The Diary · Ferdo Vukojević
Kupci — designing the architecture
The engineering behind Kupci: a modular monolith in PHP + Symfony, a Next.js + React frontend geo-routed at the edge, PostgreSQL / OpenSearch / Redis, async queues, a self-feeding scraper + budget-capped LLM enrichment pipeline, Kubernetes autoscaling, Terraform, and CI/CD — built to scale across the Balkans without a rewrite.
Read this story in the flipbook ↗What happens if this works?
Building the product was only half of the question. The other half was: what happens if this actually works?
From the beginning, we didn’t want to architect Kupci as a small website that would eventually need to be rewritten if people started using it. We were building for the Balkans.
Bosnia and Herzegovina was only the starting point. Croatia followed, Serbia was on the horizon, and from the beginning we wanted the architecture to be capable of supporting significantly more businesses, reviews, searches, reservations, integrations and daily visitors than we had on day one.
That gave us two important architectural priorities: availability and latency.
The platform needed to remain available when traffic increased, and the experience needed to remain fast as the amount of data behind it continued to grow.
But there was an important balance to find. We wanted to design for scale without pretending we already had it.
Don't start with microservices
Given my professional background, microservices were an obvious architecture to consider. I had spent years building and operating distributed systems, working with event-driven architectures, Kubernetes, large databases and independently deployable services.
It would have been very easy to reproduce that architecture here. We deliberately didn’t.
At Kupci’s stage, starting with a large collection of independently deployed microservices would have introduced complexity without solving a problem we actually had yet — more deployments, more networking, more observability, more failure modes, more infrastructure and more operational overhead.
We didn’t need distributed complexity. We needed speed of development without sacrificing architectural discipline. So we chose a modular monolith.
Kupci’s backend might be deployed as a single primary application, but internally it is not one enormous collection of tightly coupled business logic. The system is divided into clearly defined modules and bundles representing different areas of the platform.
If Kupci eventually reaches a point where a particular domain needs to scale, deploy or evolve independently, that boundary should already exist. Extracting it into a dedicated service becomes an architectural evolution rather than a rewrite.
We weren’t avoiding microservices. We were postponing their operational cost until the product had a reason to pay it.
Backend and frontend are different products
One separation we did want from the beginning was between frontend and backend. Kupci therefore lives across separate frontend and backend codebases.
The backend owns the domain. It provides the API through which the frontend interacts with Kupci’s data and business capabilities, while also containing the internal Back Office used to operate and administer the platform.
┌─────────────────────┐
│ Kupci Frontend │
│ Next.js + React │
└──────────┬──────────┘
│ API
▼
┌─────────────────────┐
│ Kupci Backend │
│ PHP + Symfony │
│ API / Domain │
│ Back Office │
│ Business Logic │
└──────────┬──────────┘
│
┌───────┼───────────┐
▼ ▼ ▼
PostgreSQL Redis OpenSearchThis gives us an important boundary. The frontend doesn’t need to understand how Kupci stores its information. It asks the platform for what it needs, and the backend decides how that information is produced.
That gives us freedom to change implementation details behind the API without forcing the presentation layer to understand those changes.
PHP + Symfony
For the backend, we chose PHP and Symfony. This wasn’t simply a familiarity decision.
Symfony gave us a mature ecosystem, strong conventions, excellent dependency injection, a powerful component model and an architecture that works particularly well for long-lived business applications.
More importantly, we didn’t want our business logic to become inseparable from the framework. That led us toward hexagonal architecture.
Hexagonal architecture
One of the architectural principles behind Kupci is that the business domain should remain at the center of the application. Frameworks, databases, payment providers, search engines and external APIs are implementation details surrounding that domain.
External World
│
┌──────────┼──────────┐
HTTP Payments Search
│ │ │
▼ ▼ ▼
┌───────────────────────┐
│ Adapters │
└───────────┬───────────┘
▼
┌───────────────────────┐
│ Ports │
└───────────┬───────────┘
▼
┌───────────────────────┐
│ Application / Domain │
└───────────────────────┘The important idea is that our core business rules shouldn’t care whether a payment was processed by Stripe or PayPal. They shouldn’t need to understand whether frequently accessed information came directly from PostgreSQL, Redis or another infrastructure component.
Infrastructure serves the application. The application shouldn’t be designed around the infrastructure.
The right tool per workload
Kupci doesn’t have one universal storage problem. Different workloads require different tools.
PostgreSQL, running through AWS RDS, is our primary transactional datastore — the source of truth for the core relational data behind the platform. Businesses, users, reviews and the relationships between the different parts of Kupci naturally fit a relational model. For data where consistency and relationships matter, PostgreSQL remains the foundation.
Not every request should become an expensive relational query. For high-frequency search and retrieval workloads, we use OpenSearch — search experiences optimized for discovery, kept away from our primary transactional database. As the number of companies and searchable records grows, this separation becomes increasingly important.
Then there is information that shouldn’t need to be calculated repeatedly at all. For those workloads, we use Redis. Frequently requested or expensive-to-produce information can be cached and served without repeatedly travelling through the entire persistence layer.
The fastest database query is still the database query you don’t have to make.
Designing for traffic spikes
Horizontal scalability was important from the beginning. Normal user traffic is relatively predictable. Other workloads aren’t.
Our data ingestion and scraping processes continuously discover and update companies. LLM-powered processes help us enrich and introduce additional company information. Background processing can suddenly create significantly more work than ordinary browsing traffic — and future growth can create spikes we can’t predict today.
We therefore designed the backend to run horizontally. Through Kubernetes Horizontal Pod Autoscaling, additional backend instances can be created as demand increases.
Normal Traffic High Load
┌─────┐ ┌─────┐┌─────┐┌─────┐
│ API │ │ API ││ API ││ API │
└─────┘ └─────┘└─────┘└─────┘
Load decreases ↓
┌─────┐
│ API │
└─────┘Slow work belongs in a queue
A user posting a review shouldn’t wait on the slow, uncertain work their review triggers — moderating its content, processing and re-hosting the images they attached. So that work doesn’t happen in the request.
The API validates, persists, and responds immediately, then hands the slow parts to a message queue (Symfony Messenger, backed by Redis). Workers pick them up separately: one moderates the review, another processes its media. The reader gets an instant response; the heavy lifting settles a moment later.
The request does the fast, certain work. Everything slow or flaky is a message someone else handles.
POST /review ──▶ save + respond (fast)
│
├─▶ [queue] moderate review
└─▶ [queue] process media
│
workers (async)A self-feeding ingestion pipeline
A review platform is worthless without businesses to review. Rather than pay a data vendor per record, we built our own ingestion pipeline — a self-hosted scraper feeding a dedup-and-upsert importer that discovers and updates hundreds to thousands of companies a day.
The parts I’m proud of are the ones that make it safe to run unattended. Every company is matched against what we already have by a cascade of keys — external id, then tax number, phone, website domain, finally a normalized name and city — so we enrich the right record instead of creating duplicates. Curated data is never overwritten; imports only fill blanks. And a content hash per source record makes re-runs idempotent: unchanged records are skipped, so running it twice costs nothing and changes nothing.
It runs on a daily cron and needs no babysitting — matched records are enriched, unchanged ones skipped, curated fields never clobbered.
LLM enrichment that can't run away
Freshly discovered companies are messy — inconsistent names, missing categories, cities written a dozen ways. An LLM cleans them up: it returns structured JSON with a tidied name, the right subcategory, a localized city, and a short local-language description. When the model is confident, the record is auto-approved; when it isn’t, a human reviews it.
The part I insisted on is the guardrail. Every call is logged with its cost, and once cumulative spend crosses a configured budget, enrichment simply stops and alerts — a runaway loop can’t quietly burn money. If the API key is missing entirely, enrichment is skipped gracefully and imports still run. Real cost lands around a few hundredths of a cent per company.
AI in the pipeline should never be able to surprise you on the invoice — spend is metered, capped, and killed at the limit.
Reviewable, repeatable, reproducible
Production infrastructure should not depend on somebody remembering which buttons they clicked in a cloud dashboard. Our infrastructure is therefore managed as code.
We use Terraform to provision and describe infrastructure across our hosting environment, including infrastructure running across AWS and Hetzner. Infrastructure changes become reviewable, repeatable, version controlled and — importantly — reproducible.
The same engineering principles we apply to application code should apply to the infrastructure running it.
Deployment as a routine
Code shouldn’t reach production because somebody copied files onto a server. Kupci uses GitHub Actions for CI/CD.
Changes move through automated pipelines that allow us to validate and deploy the platform consistently. This gives us the foundation for treating deployment as a routine engineering operation rather than a special event.
The exact pipeline architecture deserves its own chapter later in this journal.
Production isn't our testing environment
Kupci also has a fully fledged staging environment. This sounds obvious. It isn’t always.
A real staging environment gives us somewhere to validate application changes, infrastructure changes, integrations, migrations and new features before exposing them to real customers.
The objective is for production deployments to be boring. Boring deployments are good deployments.
Next.js + React
The customer-facing experience is built separately using Next.js and React. Separating the frontend gives us freedom to optimize the customer experience independently from the backend — its responsibility is presentation and interaction, the backend’s is the platform.
One decision I’m genuinely proud of lives at the edge. Kupci runs one codebase but several country storefronts — Croatia, Bosnia and Herzegovina, Serbia — and the routing happens in Next.js middleware before a page ever renders. A visitor is geo-detected (Cloudflare country header), redirected into their storefront, and pinned there by cookie; real users get localized, while crawlers are detected and left on clean, indexable URLs so SEO isn’t collateral damage.
One codebase, many storefronts — the right country resolved at the edge, before the first byte of HTML.
That frontend/backend boundary also means Kupci’s backend is capable of serving more than one consumer over time. Today it’s primarily the web application. Tomorrow it could be mobile applications, integrations, widgets, partners or entirely new products — all speaking to the same API.
Without owning the business logic
Kupci supports both Stripe and PayPal. But the more important architectural decision is that payment-provider-specific behavior should not define Kupci’s core business logic. A subscription is a Kupci concept. Stripe is a provider. PayPal is a provider. Those aren’t the same thing.
The detail I care most about here is correctness under failure. Payment webhooks are retried, duplicated, and delivered out of order by design — so every incoming provider event is recorded by its id and processed exactly once. A replayed webhook is recognized and ignored rather than double-charging a customer or granting a subscription twice.
By maintaining that distinction, payment infrastructure can evolve without requiring the product itself to be redesigned around a particular vendor. Today we support multiple providers; tomorrow another provider could be introduced behind the same architectural boundary.
Webhooks are retried and duplicated by design; every provider event is processed exactly once, so a replay never double-charges anyone.
Designing for a product that doesn't exist yet
Perhaps the hardest architectural requirement was also the least concrete. We didn’t know what Kupci would eventually become.
The original idea was reviews. Then came business ownership, replies, notifications, reservations, menus, widgets, competitor signals, data ingestion, AI enrichment — and more ideas continue to appear. Trying to predict every future feature would have been impossible.
Instead, we designed around boundaries:
- Keep domains separated
- Keep infrastructure replaceable
- Keep frontend and backend independent
- Scale horizontally where necessary
- Automate infrastructure and deployments
- Use specialized storage when a workload actually requires it
- Don’t introduce distributed-system complexity until the problem justifies it
The result isn’t an architecture designed to impress somebody looking at a diagram.
It's an architecture designed to let us keep building.
The architecture was a set of bets
At this point, Kupci stopped being just an idea. We had a product direction. We had architectural boundaries. We had chosen our technologies. We had decided how data would move, how the platform could scale, and how infrastructure would be created.
But architecture diagrams don’t serve customers. Running software does.
The next challenge was turning those decisions into an actual production system. And that’s where things became much more interesting.
→ Next: Building Kupci