Skip to content

h3nr7

Build the Monolith You Can Take Apart

Grounded Agentic Media Platform as an e2e example

Display image for GAMP

On one side is the monolith: one codebase, one database, one deploy. It's fast to build and easy to change, and it's what nearly every successful company started with. Everyone also knows how that story tends to end. Two years in, the monolith is a tangle. Every change touches everything, and the team stops shipping features for six months to "break it into microservices".

Every startup architecture argument eventually turns into the same false choice.

On the other side is microservices from day one. You get clean boundaries, independent scaling and the architecture diagram investors like to see. You also get a five-person team running a distributed system, debugging network failures instead of talking to customers, and building infrastructure for scale they may never reach.

Both options make you pay. The monolith defers the bill and adds interest. Microservices make you pay up front for something you might never need.

There's a third option, and it's less exotic than it sounds. Build a monolith whose internal boundaries are already service boundaries, and make every crossing of those boundaries explicit from day one. Do that and growth stops being an architectural event. It becomes a series of deployment decisions, each taken when a measurement demands it and each reversible if the measurement was wrong.

This article walks through that approach on a concrete system. The principles aren't new, and teams like Shopify have scaled a long way on enforced-boundary monoliths. What's useful is seeing how a handful of simple rules turn "we'll rewrite it later" into "we'll redeploy it later".

The case study: an AI platform that spends money

The system is a platform for producing media with AI agents. A request comes in, for example "make a two-minute explainer video on this product, using these sources". Agents write a script, check every factual claim against its evidence, generate a voice-over, plan shots to fit the narration, generate images and video clips, and assemble the final cut.

Two properties make this a good stress test for any architecture.

First, it spends real money at almost every step. A video clip from a frontier model costs real money per attempt, and it's billed when you submit it, not when you like the result. A bug that loops is a direct hit to the bank balance, not just a slow page.

Second, it waits a lot. Generating a video takes minutes. A workflow might pause for an hour waiting for a person to approve the spend. The system has to stop cleanly, remember exactly where it was, and pick up again on whatever machine is available, without paying twice for anything.

Get the architecture wrong here and the bill shows up within the week, not in two years. So the ideas below are the ones that survived that pressure.

Idea 1: Draw the lines where the transactions are

Most modular monoliths fail because their boundaries are drawn along technical layers ("the API layer", "the data layer") or along the org chart. Neither survives contact with real requirements.

The rule we use is narrower, and it's mechanical enough that you can't argue your way around it: anything that must change in a single database transaction belongs to a single module.

Take budget. When the system accepts a video job, it has to reserve money for it at the same moment. If the job is recorded but the budget hold isn't, you can overspend. If the hold is recorded but the job isn't, you've locked money for nothing. Those two writes must commit together, so the budget ledger lives inside the module that creates jobs. It doesn't get its own "budget service". It doesn't sit in a shared "billing" layer. It goes where the transaction forces it to go.

Applied across the system, that rule gives six modules: one that runs agent workflows, one that generates media, one that checks facts, one for assets, one for editable documents, and one that runs untrusted code in sandboxes. Each owns its own slice of the database, and no module may read another's tables. That's enforced by the database itself, with one database role per module and permissions only on its own schema, not by a code-review checklist.

That last point matters more than it looks. Boundaries that are only conventions erode under deadline pressure. Every boundary in this design is checked by a machine. Modules can't import each other's code, which a linter checks in CI. They can't query each other's tables, which database permissions prevent. They can't publish messages under another module's name, which message-bus permissions prevent. A tired engineer at 11pm can't accidentally weld two modules together, because the build fails.

Idea 2: Make every conversation between modules explicit

This is where most modular monoliths quietly turn back into regular monoliths. Modules may be separate folders, but they talk through plain function calls, and a function call hides what the caller actually needs.

When module A calls module B, A could mean very different things. It might need an answer right now ("is this claim supported by the evidence?"). It might be asking B to do some work ("generate this video"). It might be telling B something that already happened ("this job finished"). In one process all three look identical. Across a network they behave completely differently, and that's why extraction usually turns into a rewrite.

So every interaction between modules is declared as one of four kinds, each with fixed rules:

  • Queries read data or compute an answer. They have no side effects and a strict timeout. They're safe to retry, and harmless to lose.

  • Commands ask another module to do something. They're written to an outbox in the same database transaction as the caller's own change, delivered through a durable queue, and the outcome comes back later as an event. The receiver can refuse.

  • Events announce something that already happened. Other modules use them to keep their own local copy of what they need, so they don't have to keep asking.

  • Session calls cover edits that need an immediate result but stay private until committed, like an agent editing a document. They happen inside a session the caller opened, and other modules see nothing until the session closes.

The payoff is simple to state: commands and events go through the message bus even when everything runs in one process. That costs a few milliseconds per command, which doesn't matter for work measured in seconds or minutes. It means that when a module later moves to its own server, nothing about how commands and events travel changes.

Only queries change transport. In the monolith they're in-process calls, and after extraction they become network requests. Queries are read-only, so a lost one is harmless; you just ask again.

Before and after extracting a module: the calling code is unchanged, queries switch from a local adapter to a network adapter, and commands and events travel unchanged.
Figure 1: Extracting a module. The calling code doesn't change. Queries switch from in-process to network calls by configuration, and commands and events were going over the message bus all along.

That's the whole trick. Everything that can't be lost was a durable message from day one, and the one thing that switches transport is the one thing that's safe to lose.

Idea 3: Keep exactly one source of truth, and test it by wiping things

A system like this ends up with several stores: a database, a message bus, caches, object storage for media files. Distributed systems get much of their pain from truth leaking into places that can't hold it, like a cache someone started trusting or a queue that turned out to be the only record of a payment.

One question keeps this honest, and it's worth asking about every store you run: if this were wiped right now, would anything become wrong, or only slower?

Exactly one store is allowed to answer "wrong", and that's the database. The message bus can be wiped and repair loops will re-send anything that was in flight, using the database. The status views and caches can be wiped and rebuilt from the database with one command. Every decision that matters (waking a paused workflow, retrying a job, spending money) re-reads the database rather than trusting a cache.

That one rule gives you three things for free:

  • Disaster recovery. Losing the message bus completely is a recoverable inconvenience, not an incident.

  • Migration. Moving the bus to a new platform is a cutover, not a data migration, because nothing irreplaceable lives in it.

  • Frugality. At the start the bus runs on a single node with no replication. Replication buys availability, not correctness, so you pay for it when traffic justifies it.

That last point is easy to miss, and it's where this approach saves a startup real money. Many "production-ready" architectures replicate everything from day one because their correctness depends on it. If correctness lives in exactly one place, everything else can stay cheap until it needs not to be.

Idea 4: Put the checks in the structure, not the prompt

This is the part specific to AI systems, and the part most teams get wrong.

The fashionable way to build an agent is to give a model a long list of tools and a long prompt explaining when to use them: "Always fact-check claims before generating media." "Always wait for jobs to finish before declaring success." "Never spend more than the budget."

A rule the model can skip will sometimes be skipped. Not often, and not on purpose, but at scale "sometimes" is a certainty. When the skipped step is a fact-check before a paid video render, "sometimes" means you pay to produce something false.

So the design separates two styles of work and treats both as equals. Pipelines are workflows where the order is fixed in code, because the expensive steps are known in advance. Agents are loops where the model chooses the next step, because the right step depends on what the last one produced. They share the same foundation for pausing, resuming, approvals and budget. The difference is who decides what happens next.

In the video pipeline, the order follows cost. Checking text is cheap, so it goes first. Voice is cheap and gives exact timings, so it comes next. Video is expensive and billed on submit, so it comes last and is planned to fit the voice timings exactly. Each stage gates the next, so you never pay for video on text that hasn't been checked.

An eight-stage media pipeline ordered by cost: script, fact-check, normalise, voice, shot plan, paid image and video jobs, compose, final QA.
Figure 2: The media pipeline, ordered by cost. Only one stage spends serious money per attempt, and every stage before it is a cheaper check.

For open-ended agents, the critical checks are gates in the workflow's structure, not tools the model may call. After every step, the code (not the model) checks whether any paid jobs are still pending, and if so the agent waits. The agent can't finish while any job it started is unfinished. Before an edit is committed, a fact-checking model scores the factual claims, and anything unsupported goes back to the agent.

A few disciplines make those checks trustworthy rather than decorative:

  • Use calibrated checkers, and calibrate them yourself. The fact-checker is a small model that returns a probability rather than prose. Its thresholds come from your own labelled examples, not from a vendor's benchmark.

  • Fail closed. If the fact-checker is down, the system abstains or asks a person. It doesn't quietly carry on unchecked.

  • Enforce budget in code. Every tool call goes through a single gatekeeper that validates it, checks permissions and prices it against the run's cap before anything happens. A looping agent stops at its budget, not at the invoice.

  • Quarantine untrusted code. Code the model writes runs in disposable, network-isolated sandboxes with no secrets inside, destroyed after every session.

The principle behind all four is the same: agents decide, pipelines execute, and anything that protects money or truth is enforced by code, never requested by prompt.

Idea 5: Treat growth as a ladder of deploys

With the first four ideas in place, scaling stops being a project. It becomes a ladder, and you climb a rung only when a measurement tells you to.

  • Stage 0: Everything in one process. Move on when: you're starting out.

  • Stage 1: The public API separated from background work. Move on when: background work slows down user-facing requests.

  • Stage 2: Each kind of work in its own process group. Move on when: different parts need different resources, limits or secrets.

  • Stage 3: The data tier scaled: pooling, replication. Move on when: database connections or bus availability become the constraint.

  • Stage 4: A module extracted into its own service. Move on when: a measured reason: scaling profile, security boundary, team ownership.

Every rung uses the same code. Stages 0 to 2 differ only in a command-line flag that picks which roles a process runs. Stage 4 adds one small package to the same repository plus a new deploy. The code is never split into separate repositories, because services are deploys, not codebases.

Stage 0 runs every role in one process; stage 2 runs one process group per role, on the same code and the same data tier.
Figure 3: Stage 0 to stage 2. The "split" arrow is a deployment change; the code, the contracts and the messages are identical on both sides.

Two things keep this honest over time.

First, the test suite runs in every topology. CI runs the full end-to-end suite with everything in one process, again with every role in its own container, and again with every module talking over the network with injected latency and its own restricted database access. If a shortcut sneaks in, say one module reading another's tables or two roles sharing memory, the split-mode run catches it in CI rather than in production. When all three topologies pass, extracting any module is an operational task.

Second, you extract for measured reasons, and rarely. Every extraction adds network failure modes, another deploy pipeline and another thing to monitor. The point of making extraction cheap is that you're never forced into it early. Our first extraction candidate is the media-generation module: it has a different scaling profile, the most sensitive API keys and long-running work. The agent-runtime module will probably never be extracted, and that's fine.

What it costs

This approach isn't free, and pretending otherwise would undercut everything above. Here's the honest bill.

Some discipline up front. Declaring every interaction's kind, writing commands to an outbox and keeping one schema per module takes more thought than calling a function. For the first few weeks it feels like ceremony. After that, the cost per feature is close to zero, and the tooling catches mistakes before reviewers have to.

A few milliseconds per command. Sending commands through the message bus inside one process is slower than a direct call. For workflows measured in seconds and minutes it doesn't matter. If your system is dominated by sub-millisecond interactions between components, this design will annoy you.

A small workflow engine of your own. Jobs, waits, outboxes and repair loops are the machinery that dedicated durable-execution platforms sell. Building a lean version is reasonable while each job is a single external call. If jobs start chaining many steps, adopt a durable-execution platform rather than growing your own.

It's the wrong choice when you don't know the domain yet. If you're still discovering what the product is, drawing module boundaries is premature, because you'll draw them in the wrong places. Prototype as messily as you like. Apply this when the shape of the business has settled enough that "anything that must change together belongs together" gives stable answers.

Adopt the rules, not the products

Everything here was built with specific tools: a particular cloud, database, message bus, workflow framework and set of AI providers. None of them are the point, and all of them will date. Vendors will rename products, frameworks will churn, and AI providers will overtake each other every few months.

What lasts is a short list of rules about where truth lives, who may change what, and what a machine checks on every commit:

  • One source of truth; everything else can be rebuilt.

  • Every interaction between modules declared as a query, command, event or session call.

  • One transaction, one module.

  • Caller-generated IDs on everything that moves money or state, so retries never pay twice.

  • Cheap checks before expensive steps, enforced as structure, never left to the model.

  • Boundaries enforced by tools, because conventions rot and checks don't.

A team that holds to these can start as small as a single process and grow as far as its product earns, one deploy at a time, without ever stopping to rewrite.

The monolith was never the problem. A monolith you can't take apart is.