# We skipped microservices and built a modular monolith

Why a small team with a fixed stack chose one Go binary per app on one Postgres instead of microservices, what that costs, and the triggers that would make us split.

Around 2016 I worked on real-time analytics and machine learning for a large video back-end. It served a few million subscribers, each with a mix of set-top boxes, phones and desktop computers. That is a long way from hyperscale, but it was a real production load. The back-end our platform belonged to was a proprietary monolith that had grown over the years before, and the engineering team decided to refactor it into microservices. They put significant effort into it.

The refactor was already under way when I joined, and it was still going when my engagement ended about a year and a half later. For all that time we worked on a platform in flux: the core logs were still produced the old way, while a new SDK had been deployed only in part, and only on mobile devices. Our analytics had to absorb both.

The trend was hard to miss. When I studied Computer Science around 2000, the standard design was three tiers: presentation, business logic and data. Fifteen to twenty years later the success stories of the hyperscalers had made microservices the default answer, and Pokémon GO was the famous example. Niantic ran the game's application logic on Kubernetes on Google Cloud, and in the [account Google Cloud published](https://cloud.google.com/blog/products/containers-kubernetes/bringing-pokemon-go-to-life-on-google-cloud) the teams had targeted 1X traffic, with a worst case of roughly 5X. Real traffic reached 50X. An architecture that absorbs that has earned its reputation.

---

## What we were choosing between

When we designed the Amfelt platform, our first instinct was the same one: microservices. Then we stopped and estimated what it would cost us.

We were a small team on a deliberately narrow stack: Postgres, Go and HTMX. We were pre-launch. We hoped for thousands of customers and had none yet. Microservices would have bought us independent scaling and independent releases for load and teams we did not have, and they would have charged for it up front. Every service boundary is a network call to design, version, trace and keep consistent. Deploying them properly meant running a Kubernetes cluster, with all the configuration that comes with one. For a team our size that is engineering time taken directly from the product.

The alternative has costs too, and we wrote them down before choosing it. A modular monolith gives up per-module scaling: a surge in one module consumes the same process as everything else. A change to one module ships in the same release as a change to any other. The boundaries between modules are enforced by structure, but they still depend on code review to stay honest. And one shared database is a single point of failure for the core, which we mitigate with backups and a managed offering rather than by design. We accepted those costs because each of them has a known way out, and the microservices costs do not go away once you have paid them.

---

## What we built instead

The application has three parts. The app serves the gated web interface, our control plane. The agent runs on the customer's side, next to their data. Both connect to a single api, a Go web server that talks to our Postgres database, to third-party services such as authentication, and to our OpenTelemetry provider. The web server itself is a private Go package that also powers our internal knowledge base, so the code that all three share is written once.

Inside the api, the boundaries between modules are drawn where the compiler and the database can enforce them instead of the network. In Go, a module is a package with a narrow surface. In Postgres, each module owns a schema and its tables are protected by row-level security policies. Other modules read through documented interfaces and write only through the owner. None of it needs a service mesh, and a call between two modules is a function call inside one transaction. When one module's tables have to reference another's, the database enforces it with foreign keys, so we get strong consistency and no reconciliation jobs.

The agent is the one part we split out from the start, because it is meant to run inside a customer's network instead of ours, which makes it a separate artifact whatever we do with the rest. Connectors are released separately too, so the catalog can grow on its own schedule. Those are decisions about specific components with specific reasons, and they are the only distributed pieces we have.

---

## Keeping the way out open

We did not rule microservices out; we kept the path to them short. Our core services are containerized, so if we ever do move to scalable pods, that is a deployment change and the code stays as it is. Go helps here: it keeps our services' memory footprint small. That lets us scale up a single instance first and scale out the whole api second, before we need to think about extracting anything.

Extraction itself stays a refactor for the reason the boundaries exist: a module that already owns its package and its schema can be lifted out along lines that are already drawn. We also wrote down what would make us do it. Load is the obvious one: if a single module consistently saturates the shared process and scaling the whole binary stops being cost-effective, that module earns its own service.

Cadence is the second: a module that needs releases the rest of the platform cannot accommodate, say a regulated module with its own change control, should not have to wait for everyone else. The third is isolation. If a customer or a compliance regime requires separation at the database level that schemas and row-level security cannot give, structure inside one database is no longer an answer. Until one of those happens, the complexity of a distributed system would be effort spent on our architecture instead of on our platform.

Don't reach for microservices by default. If your scale does not demand them yet, look seriously at a modular monolith: it lets you concentrate on the features and stability of the core before you take on the extra complexity.

If you want to see how the pieces fit together, the [architecture overview](/docs/concepts/architecture/) walks through the platform plane by plane.
