Micro Mono Modu
An architecture choice is a bet on years of running cost: the stages a system passes through, five shapes to pick from, and how to choose between them.
Introduction
Software engineering is hard and volatile. As software craftspeople we adapt constantly to changing business needs and strategy. On top of that, we have our own technical needs to cater for. Beyond choosing a tech stack — which languages, frameworks, infrastructure and open-source tools to lean on — pinning down the right architecture from the get-go can be an absolute conundrum. In this post we look at the different scenarios a system passes through and how to make the best of each, as well as the decisions that might paint us into a corner too early.
The argument underneath is simple: architecture is a bet on years of running cost, and most of it is invisible the day the decision is made.
The different stages of software
A system is not one thing for its whole life. It starts as an experiment, becomes a product, then becomes something somebody has to keep running. Each stage rewards a different set of decisions, and the mistakes that hurt most are the ones made for the stage the system is in now and paid for in the stage it is about to enter.
The stages are not a waterfall and most systems skip one. What changes at each boundary is who carries the risk, and the question worth asking at every stage is what the cheapest exit looks like: the exit is what the next stage pays for.
Proof of concept
The term proof of concept can describe at least two different activities. It often focuses on the technical aspect of a given problem: the first lines of code in a new project, written to test a theory or an approach to solving a challenge. It can also be a smaller exercise applied to existing software; in this post we mean the former.
This is the stage where we test ideas, design patterns and integrations. Code here will likely be rewritten many times over before it ever sees a live environment. It is written quickly and cheaply, usually non-optimised, with no tests or very few. The team is very small — one to three engineers, usually quite experienced — and Kanban is the project management framework, if there is one at all: they know what they are investigating now, might know what comes next if their hunch is correct, and know the end goal, but not all the steps to get there.
Timebox the question rather than the architecture: decide what it has to answer to justify a prototype, and stop when it is answered. The code is a by-product — keep it or bin it knowingly — and write down which assumption turned out to be false before the next team rediscovers it.
Prototype
A prototype is the upgrade, the next stage from proof of concept. The main technical challenges have been cracked and understood. It is time to build on that knowledge and get closer to something that can be shown to non-technical stakeholders. By the end of the prototype phase the business should be able to green-light the project, or ask for adjustments and iterate, sometimes back to proof of concept if the changes are drastic.
The team is the initial engineers plus new ones, rarely more than eight in total. More resources early will not necessarily speed up delivery: too many cooks slow things down through design by committee or, worse, bikeshedding. Kanban is often still a good fit, but a business nervous about timelines can try a short-cycle scrum or scrumban.
Engineers still need adaptability, because the implementation may have to pivot quickly. Code quality and optimisation should be higher than in the proof of concept, but it does not need to be production ready, and it often changes broadly. TDD suits this stage: it guides the right design patterns, validates the business needs, and lets the implementation change without rewriting every test, so refactors cannot change behaviour.
Two artefacts survive the pivot: a rough contract of the operations the business actually talks about, and a performance envelope measured on the worst realistic day.
Greenfield
After the prototype has been validated, from both the business and the technical perspective, it is time to go live. Code is optimised, all features — or at least the ones selected for the MVP — are implemented, performance tested and tweaked. Non-functional requirements appear on the backlog: documentation, monitoring, analytics, SLAs. The team size might stay the same or be increased, driven more by the size of the project than by a technical constraint. The system should be well understood by now, and most of the work is business requirements, often highly parallelisable. Scrum works here with an efficient technical project manager behind the team or teams, while Kanban remains a solid contender with more experienced people.
The trap is treating the non-functional list as a hardening phase at the end: load is tested against the prototype’s envelope rather than a developer’s laptop, and the rollback is rehearsed before it is needed.
When the system replaces something, the migration is the project. A backfill needs a rate limit, a restart point and a way to run twice without duplicating anything, and the cutover plan belongs on the backlog with the same definition of done as the features.
Operation and maintenance
This is where the architecture bill arrives: the system is live and somebody is on call. On-call load, blast radius, release cadence under change freezes, dependency patching, capacity, runbooks: none of it appeared in the proof of concept, and all of it follows from the system’s shape. Non-functional requirements become first-class here. SLIs, error budgets, alerting somebody acts on — without them, operation becomes judgement calls at three in the morning.
Observability comes in tiers. First, is it up: a synthetic check on the path a user takes, plus the golden signals per deployable. Then what is it doing: structured logs with a request id, and one dashboard per user journey rather than one per service. Only then why this request is slow: sampled traces and a profiler. Teams that buy the last tier first own expensive pictures of problems they cannot see.
An incident taxonomy stops every incident becoming an emergency. Three levels are enough — users cannot work, users are degraded with a workaround, everything else is a ticket — and the level decides who is woken, who is told, and what is written afterwards, in the week, with owners and dates rather than a document nobody reopens.
Patching is the least glamorous line on the bill and the one that ages worst: base images and runtimes monthly, minors quarterly, majors yearly with a named owner and the week of work budgeted. Its prerequisite is an inventory of what is actually running, not what the lock file claims, and upgrades go out through the pipeline you already trust, so a bad one is a rollback.
Error budgets turn an argument into arithmetic: 99.9% over thirty days allows about forty-three minutes of unavailability, 99.95% about twenty-two. Spent budget pauses feature work until reliability work restores it, and that rule was agreed in advance by people who were not tired.
Cost scales with deployables, not features. The marginal module is a code review; the marginal service is a container, an image, a pipeline, a certificate, a dashboard, a patching cadence and a rota slot.
End of life
Every system ends: planning the exit is cheaper than pretending it will not. There are only three exits. Retire: archive the data, delete the infrastructure, tell the users where the capability went. Replace: run a strangler around the old process until nothing is left of it. Absorb: fold the capability into a platform somebody already runs, and let them carry it.
What makes an exit cheap is the boring material: clear module boundaries, a documented data model, a dependency inventory, a written runbook. What makes it expensive is the immortal zombie, the system nobody dares switch off because nobody knows what depends on it. This post arrived by that route: the article was worth keeping, the pipeline that served it was not.
The handover is a short list that almost nobody has ready. The schema, and the meaning of the columns nobody dares touch. The runbook: how to deploy, how to roll back, what each alert means, who to call when it fires. Credentials, and who can rotate them once the original team has gone. The retention schedule, including any legal hold that forbids deletion — an archive that ignores a hold is a liability, and one that keeps everything forever is a different one. And a restore from backup that somebody has actually run this year, because a backup nobody restored is a hope.
The different architectures of software
Five shapes come up again and again. They differ in what a deployable is, what fails the build when a boundary is crossed, and who is on call for the result.
Monolith
One deployable, one process, one database. Calls between parts of the system are in-process function calls. The strengths are easy to underestimate: a single transaction, the freedom to refactor across any boundary, one toolchain, one pipeline, one place to look when something breaks, and the cheapest possible thing to run at small scale. The costs mirror that. You scale the whole application to scale one part of it. The blast radius of any bug is the whole system. Build and test time grows with the codebase rather than with the change, and every team releasing into it competes for the same artefact.
A monolith is the right answer more often than the conference circuit suggests: a team of five that owns one product, a domain still being discovered, a workload that lives or dies by one transaction, a latency budget with no room for a network hop, a first customer who needs the thing next month. In each of those, distribution buys nothing and spends the attention the team has least of.
Most arguments about monoliths go wrong here: a monolith is not a big ball of mud. The mud is the failure mode, not the architecture. The difference is whether the internal boundaries are deliberate — modules with names, owners, and something that fails the build when they are ignored. A monolith without those boundaries is just an old application.
Telling the two apart needs no audit, just three questions. Can one module be changed without reading the rest? Does anything fail when a boundary is crossed? Can you name each module’s owner, and would they recognise the description?
Microservices
Microservices are independently deployable units drawn around business capabilities, each owning its data. The strengths follow from that independence: teams release on their own cadence, a failure or load spike stays inside one service, and each service picks the technology that suits it. The price is distributed transactions, eventual consistency, network latency and partial failure, versioned contracts and duplicated data.
The organisational point matters more than the technical one: microservices scale teams at least as much as they scale software. Conway’s law decides more of the outcome than any diagram. Draw the boundaries where the ownership already is, or the architecture describes how teams talk, not how the system is built.
The first rule is one every team agrees with and every team breaks under deadline: a service reads another’s data through its contract, never through its tables. The moment two services share a schema they share a deploy cadence too, and the second one is a module with a network hop. A join across two services is a distributed query nobody can operate.
C#
public sealed class OrdersService(IPriceClient prices, IOrderRepository orders)
{
public async Task<Order> PlaceAsync(Sku sku, int quantity, CancellationToken ct)
{
var quote = await prices.QuoteAsync(sku, quantity, ct);
// The neighbour's quote, never its tables.
var order = Order.Place(sku, quantity, quote.Total);
await orders.AddAsync(order, ct);
return order;
}
}
Java
public final class OrdersService {
private final PriceClient prices;
private final OrderRepository orders;
public OrdersService(PriceClient prices, OrderRepository orders) {
this.prices = prices;
this.orders = orders;
}
public Order place(Sku sku, int quantity) {
var quote = prices.quote(sku, quantity);
return orders.add(Order.place(sku, quantity, quote.total()));
}
}
Go
type Pricing struct {
remote PriceContract
store OrderStore
}
func (p Pricing) Place(ctx context.Context, sku string, qty int) error {
quote, err := p.remote.Quote(ctx, sku, qty)
if err != nil {
return fmt.Errorf("quote %s: %w", sku, err)
}
// Another service's schema is never read, only its quote.
return p.store.Insert(ctx, Order{SKU: sku, Qty: qty, Total: quote.Total})
}
Calls between services fail in ways in-process calls cannot: timeouts where the work succeeded anyway, retries that arrive twice, partial success. Retrying is the default answer and it only works when the operation is idempotent — the caller supplies a key, the receiver recognises the repeat and returns the first result. Without it, a retry storm is a duplicate-order generator.
Writing to a database and publishing an event are two systems, and one of them fails first. The outbox pattern makes the write and the event a single transaction: the message goes into a table beside the business rows, and a relay publishes it afterwards until it succeeds. Consumers then have to tolerate the same event twice, which is the idempotency requirement wearing a different hat.
No distributed transaction is worth its operational cost. A saga replaces it with local transactions and compensating actions: reserve stock, take payment, confirm the order; if payment fails, release the stock. Choreography decouples the services and hides the flow; orchestration puts the sequence in one place somebody owns. Either way the intermediate states are visible to users, so the model has to include them.
Contracts move, so versioning is a habit rather than a project. Additive changes first, because old callers ignore what they do not know; breaking changes go through expand and contract — ship both shapes, move the callers, remove the old one when the traffic graph says nobody is left. Consumer-driven contract tests catch the drift in CI rather than in production.
A service catalogue is not bureaucracy. It answers the four questions asked in every incident: what is this, who owns it, what does it depend on, and where are its dashboards. Kept current by the pipeline that deploys the service, it doubles as the retirement list.
Microservices in a monorepo
One repository, many deployables. The wins are real: one atomic commit can change a service and all its callers together, one dependency bump covers everything, lint and CI conventions come for free, code stays discoverable, and a contract cannot drift from its callers: the skew shows up in the same build. The build graph becomes a thing you operate: without affected-only builds, CI time explodes with every service. Access control is coarser: anyone with the repository can change everything. And nothing stops somebody importing across a service boundary, so the repository stops enforcing it.
Affected-only builds are what make the repository affordable. Bazel’s target graph, Nx’s project graph, Gradle’s configuration cache, go work with a changed-package list: each turns forty services into a CI run proportional to the change rather than the repository. Without one, the tenth service doubles the build time of the first, and the team’s answer is to stop running the suite.
CI cost is real patience: a fifteen-minute pipeline, twenty times a day, is a working week a month spent waiting. The lever is not a bigger runner but not building what did not change, caching what did, and running the cheapest check first.
Access control is coarser by construction: whoever can read the repository can read every service and every fixture. That is fine where it is already true, and a blocker where a contractor, a regulated dataset or a customer-specific fork lives in the same tree. The alternatives are one repository per trust boundary or a great deal of discipline.
A test that fails the build is the cheapest enforcement available:
C#
public class ServiceBoundaryTests
{
[Fact]
public void BillingMustNotReachIntoOrders()
{
var result = Types.InAssembly(typeof(BillingModule).Assembly)
.That().ResideInNamespace("Billing")
.ShouldNot().HaveDependencyOn("Orders.Internal")
.GetResult();
Assert.True(result.IsSuccessful);
}
}
Java
@AnalyzeClasses(packages = "com.example")
class ServiceBoundaryTest {
@ArchTest
static final ArchRule billingMustNotReachIntoOrders =
noClasses().that().resideInAPackage("..billing..")
.should().dependOnClassesThat()
.resideInAPackage("..orders.internal..");
}
Go
package billing_test
func TestBillingDoesNotDependOnOrdersInternal(t *testing.T) {
cmd := exec.Command("go", "list", "-deps", "./billing")
out, err := cmd.Output()
if err != nil {
t.Fatal(err)
}
forbidden := "example.com/app/orders/internal"
if strings.Contains(string(out), forbidden) {
t.Fatalf("billing depends on %s", forbidden)
}
}
Modular monolith
A modular monolith is one deployable with explicit internal modules and enforced interfaces, and one schema per module instead of one free-for-all. The boundaries are real and in-process, which means none of the distributed bill: no network hops, no eventual consistency, no versioned contracts between modules. You get most of modularity’s benefits for a fraction of the operational cost, and extraction later is possible because the seam already exists.
The pitfalls are all about discipline. Boundaries decay unless something fails the build when they are crossed. A shared table is the beginning of the end: once two modules write the same rows, there is only one module in practice. And “we will split it out later” rarely survives contact with the next quarter’s roadmap, which is fine as long as nobody was relying on it. The only public surface of a module is its contract:
C#
namespace Billing;
public interface IBillingModule
{
Invoice Issue(OrderId orderId, Money total);
}
internal sealed class BillingModule(IInvoiceStore store, IClock clock) : IBillingModule
{
public Invoice Issue(OrderId orderId, Money total) =>
store.Save(Invoice.Draft(orderId, total, clock.UtcNow));
}
Java
package billing;
public interface BillingModule {
Invoice issue(OrderId orderId, Money total);
}
record DefaultBillingModule(InvoiceStore store) implements BillingModule {
public Invoice issue(OrderId orderId, Money total) {
return store.save(Invoice.draft(orderId, total));
}
}
Go
package billing
type Module interface {
Issue(orderID OrderID, total Money) (Invoice, error)
}
type module struct{ store Store }
func New(store Store) Module { return &module{store: store} }
func (m module) Issue(orderID OrderID, total Money) (Invoice, error) {
return m.store.Save(InvoiceDraft(orderID, total))
}
Enforcement starts with the language, which costs nothing: C# internal, Go’s unexported identifiers, Java’s package-private types. Make the implementation invisible outside its module and half the boundaries hold without a single test.
Architecture tests cover the rest — module A does not reference module B’s internals, the domain does not reference the web layer, nothing outside a module touches its tables. They are ordinary unit tests over the compiled assembly that fail with the offending type named.
One schema per module is the part teams skip, and it is not a database detail: once two modules write the same rows, the transaction boundaries, the migrations and the release order are shared too. A module that needs another’s data asks for it through the contract, or keeps a read model of its own that it can rebuild.
Ownership is the cheapest layer: a CODEOWNERS entry per module turns “somebody should look at this” into a review request to the team that owns the consequences.
Modular microliths
This is the term this post has been building towards: a small number of coarse services, each internally modular. The services are sized around a domain rather than entities, and there are typically between three and eight of them. You buy isolation where it matters — compliance, scale, failure containment — and keep the operational count low enough to run without a platform team. It is usually a destination a system grows into, not a starting point, and the guardrail is one service per bounded context, never one per noun. The distinction sounds academic until you have seen the opposite: forty services named after tables, each with a pipeline, owned by nobody.
Sizing heuristics, in the order they usually decide it: one team, one on-call rota, one release cadence. If two groups can deploy it without talking, it is probably too big; if one person can hold its whole state in their head and the rest of the system never notices, probably too small. A service owns a bounded context, not an entity.
Extraction is mechanical, which is the point. Measure the coupling: static analysis of who calls whom, and a grep for the tables the module touches. Find the seam, the narrowest interface the rest of the system already uses or the one you can introduce without changing behaviour. Put a facade there and route everything through it, still in-process, behind tests. Then extract behind the facade — same contract, now a network call, with the retries and idempotency above. Move the data last, once the code boundary has held for a release or two.
The journey
The same system travels through proof of concept, prototype, greenfield, operation and end of life, and its architecture may change on the way. You are not choosing “the architecture” once; you are choosing the next step that keeps the following ones possible. The property worth protecting is reversibility: boundaries that can move, contracts that can be versioned, data with one owner, and deploys that do not require deploying everything.
What actually triggers a change of shape is rarely a design review. It is a second team that needs to deploy without waiting; the first outage whose blast radius is the whole system; a compliance requirement that one dataset sits behind its own access boundary; a tenfold traffic increase on a single endpoint; or a rewrite estimate nobody will sign. Team shape, blast radius, regulation, traffic and cost are five different arguments.
Two failure modes matter here. Premature distribution is microservices in a proof of concept — all of the operational cost and none of the scale. Architectural freeze is the opposite: a monolith nobody can change because every change feels risky. Both ignore the step after the next one.
| Stage | Team shape | Architecture that usually fits | What to protect |
|---|---|---|---|
| Proof of concept | 1–3 | One deployable, no boundaries | Speed; expect the rewrite |
| Prototype | Up to 8 | Modular monolith, boundaries sketched | Tests around behaviour |
| Greenfield | Project-sized | Modular monolith, boundaries enforced | Contracts and data ownership |
| Operation and maintenance | A team on call | Modular monolith or modular microliths | Seams, runbooks, dependency inventory |
| End of life | Whoever is left | Freeze, strangle, retire | Documented data model |
Choosing an architecture
Read the diagram as a decision tree, not an answer. A new build starts from the scaling question — independent scaling, release cadence or failure isolation — and stays a modular monolith when the answer is no; when it is yes it works down through who owns deployments and whether one repository and pipeline can serve them. An existing system starts from whether its module boundaries still hold: if they do, keep it; if they can be made explicit without a rewrite, do that; if not, the choice is strangling one module at a time behind a seam or freezing the interface for retirement. Every path ends in its own outcome, so no two branches share an answer.
flowchart TD
Q0{"New build or an existing system?"}
Q0 -->|"New"| Q1{"Independent scaling, release cadence or isolation?"}
Q0 -->|"Existing"| Q2{"Do module boundaries still hold?"}
Q1 -->|"No"| R1["Modular monolith, boundaries deliberate"]
Q1 -->|"Yes"| Q3{"More than one team owning deployments?"}
Q3 -->|"No"| R2["Modular microliths: a few coarse services"]
Q3 -->|"Yes"| Q4{"One repository and pipeline workable?"}
Q4 -->|"Yes"| R3["Microservices in one repository"]
Q4 -->|"No"| R4["Microservices in separate repositories"]
Q2 -->|"Yes"| R5["Keep it: modular monolith, boundaries hold"]
Q2 -->|"No"| Q5{"Boundaries recoverable without a rewrite?"}
Q5 -->|"Yes"| R6["Modular monolith: make boundaries explicit"]
Q5 -->|"No"| Q6{"System near the end of its life?"}
Q6 -->|"Yes"| R7["Freeze, narrow the interface, retire or replace"]
Q6 -->|"No"| R8["Strangle: extract one module at a time"]Three shapes, none of them a client. A two-person team building scheduling software for one industry, one database, three months to launch: a modular monolith. A payments platform with four teams, a regulator asking who can read card data, and three services already deploying weekly: microservices where the compliance boundary is, one collapsed modular service per domain elsewhere. A fifteen-year-old system whose author left and whose boundaries are gone: freeze the feature set, put a facade in front of the two capabilities the business still needs, and strangle those two.
Four things override any diagram: team topology, risk tolerance, the deadline, and compliance or data residency requirements. A regulator or a fixed migration date decides before any of the above does, and admitting it early beats drawing a diagram nobody follows.
When in doubt, build a modular monolith. It is the cheapest architecture to be wrong about, and the only one where being wrong the first time does not cost a rewrite. If the boundaries are real, services remain available later, whenever the team and the traffic justify them. That is the work we do on the architecture and technical advisory side of an engagement.