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.
01// 每个域拆成两个 module:02// cms/api → 契约,只依赖 base03// cms/internal → 实现,外部不可 import0405// edge 层做跨域编排06func (h *Handler) SubmitAttempt(c *gin.Context) {07 node := cms.MustGetNode(id) // 域内调用08 result := quiz.Grade(node, answers) // 跨域:只经 api09 cms.Records.Save(node, result) // 再调回 cms10 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.
01之前:6 个服务 · 十余个容器 · 服务发现 · 配置中心02现在:1 个二进制 · 1 个容器 · 启动时自动迁移0304$ make docker && docker run -p 8080:808005[migrate] cms v12 → v14 ✓06[migrate] quiz v07 → v09 ✓07[server] listening on :8080An 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.