Unilearning · Architecture

What we lost when we folded 6 microservices into 1 binary

A modular monolith is not a retreat to a single monolith. It trades runtime constraints for compile-time ones — and both the cost and the gain are real.

The old system was 6 microservices calling one another over HTTP with URL configuration. It sounds entirely standard, but the real cost sat in two places: the network overhead and failure handling of service-to-service calls, and the fact that changing one feature meant touching three repositories and deploying three services.

Turning the calls back into function calls

The heart of a modular monolith is not “don’t split” — it is “split inside the process”. 8 business domains live in one process, and calls between domains are ordinary function calls: no network, no serialization, no service discovery.

unile-go — the two-layer dependency graph
01// 每个域拆成两个 module:
02// cms/api → 契约,只依赖 base
03// cms/internal → 实现,外部不可 import
04
05// edge 层做跨域编排
06func (h *Handler) SubmitAttempt(c *gin.Context) {
07 node := cms.MustGetNode(id) // 域内调用
08 result := quiz.Grade(node, answers) // 跨域:只经 api
09 cms.Records.Save(node, result) // 再调回 cms
10 c.JSON(200, result)
11}

Boundaries enforced by the compiler, not by lint

Go’s internal mechanism makes a cross-domain import fail the build outright. That constraint is harder than any lint rule or architecture document — documents go stale; the compiler does not.

  • Domains may only import each other’s api (contracts), never internal (implementation)
  • api depends only on base, and base has zero external dependencies
  • Logic that orchestrates several domains is written in edge, so the dependency graph is a two-layer tree
  • Adding a domain just means adding its api and internal — the boundary rules never need to be argued again

What we lost

The costs are real, and we will not pretend otherwise:

  • Independent scaling is gone. Whichever domain is under load, the only option is to scale the whole thing
  • Fault isolation is weaker. A panic in one domain can affect the entire process (backstopped by recover and health checks)
  • Release granularity is coarser. Changing one domain means re-releasing the entire binary
  • Stack lock-in. One domain cannot be written in Go while another uses Java

Why the trade is worth it

Because this system’s real bottleneck was never single-domain throughput — it was iteration speed and operational complexity. 6 microservices meant a dozen-odd containers, service discovery, a config centre, distributed tracing — and the cost of that infrastructure far outweighs the little scaling elasticity we gave up.

Delivery shape, before and after
01之前:6 个服务 · 十余个容器 · 服务发现 · 配置中心
02现在:1 个二进制 · 1 个容器 · 启动时自动迁移
03
04$ make docker && docker run -p 8080:8080
05[migrate] cms v12 → v14 ✓
06[migrate] quiz v07 → v09 ✓
07[server] listening on :8080

An architecture decision is, at bottom, a comparison of costs — not a pursuit of whatever shape is “more advanced”. If single-domain throughput ever truly becomes the bottleneck, splitting that domain back out amounts to copying the api contract once.

More Notes
Get in touch

Hand the complexity of identity, agents and private domain to one governable kernel

Whether you are replacing an existing IAM, building an agent platform, or trying to make private-domain operations actually work — start with a 30-minute architecture call. We will first judge whether this is the kind of problem we are good at, and say so plainly if it is not.