Not a utopia, but a pragmatic strategy: start with the cost-effectiveness of a monolith to validate your business quickly, and later, when traffic grows, split into microservices with near-zero migration cost. This article explores how to achieve such capability in Go projects using a compile‑time dependency injection tool that promotes a unified service entry interface.
1. The Common Architectural Dilemma
Most Go backend teams have faced this dilemma:
In the early stage, microservices sound great—independent deployment, independent scaling, technological heterogeneity. But the reality is that maintaining multiple independent processes, setting up service discovery, building distributed tracing, and handling cross-service debugging come with a heavy infrastructure cost, especially when the business model is still unproven.
As the business grows, the monolith starts to show its pain points: a specific module needs independent scaling due to traffic spikes, but the whole application must be redeployed together; a change in one place affects the entire system, increasing the regression testing cost; as the team grows, merge conflicts in a shared codebase become frequent.
Teams often fall into an awkward cycle: choosing monolith for cost reasons early on, and later rebuilding into microservices at a high cost—often much higher than expected.
The core question is: Is there an architectural approach that allows us to run at very low cost in the early stage, yet transition to microservices with minimal effort when needed?
2. Core Ideas: Unified Interface + Modular Organisation
The key to enabling progressive architecture lies in two design principles:
2.1 Each Service Exposes a Uniform Executable Interface
If every service’s startup entry is a standard function—say, func(context.Context) error—then:
- For merged deployment, simply run these functions concurrently in
mainusingerrgroupor goroutines. - For standalone deployment, each service just calls its own function in its own
main.
The code path is identical; there is no difference.
2.2 Each Service Has Its Own Isolated Dependency Graph
Each service should manage its own dependencies independently, without sharing the dependency graph with others. That way:
- When merged, dependencies do not interfere with each other.
- When split, each service can run on its own without external adjustments.
3. Using a Compile‑time DI Tool to Enforce the Interface
To conveniently build each service with its own isolated dependency graph and expose the same func(context.Context) error signature, we can use a compile‑time dependency injection tool. The tool described here is not Uber’s go.uber.org/dig (a runtime‑reflection library); it is an independent implementation that generates code at compile time, resulting in zero runtime overhead.
With this tool, each service module defines its providers and invokes its startup logic inside a dig.Build call, which returns exactly the desired function type:
// user/di.go
func InitUserService(cfg *UserConfig) func(context.Context) error {
return dig.Build(
dig.Supply(cfg),
dig.Provide(NewUserRepo),
dig.Provide(NewUserHandler),
dig.Invoke(func(h *UserHandler) { h.Start() }),
)
}
// order/di.go
func InitOrderService(cfg *OrderConfig) func(context.Context) error {
return dig.Build(
dig.Supply(cfg),
dig.Provide(NewOrderRepo),
dig.Provide(NewOrderHandler),
dig.Invoke(func(h *OrderHandler) { h.Start() }),
)
}
Key points:
-
dig.Buildreturns a pure Go function, not a framework object. - This function signature matches standard library patterns (
errgroup.Go,http.Server, etc.). - Each service’s dependency graph is fully encapsulated and does not interfere with others.
The generated code has zero runtime reflection and zero runtime dependency, with performance identical to manually written initialisation.
4. The Evolutionary Path
With the foundation above, the migration path becomes straightforward.
Phase 1: Early stage – Merged Deployment
All services run in a single process:
func main() {
eg, ctx := errgroup.WithContext(context.Background())
eg.Go(InitUserService(userCfg))
eg.Go(InitOrderService(orderCfg))
eg.Go(InitPaymentService(paymentCfg))
// more services...
if err := eg.Wait(); err != nil {
log.Fatal(err)
}
}
Cost: managing 1 binary, 1 process, 1 set of logs.
Benefits: easy development and debugging, zero‑latency cross‑service calls (in‑process function calls).
Phase 2: Business growth – Gradual Split
When a particular service (e.g., order service) becomes a hotspot and needs independent scaling:
// order-service/main.go – standalone deployment
func main() {
if err := InitOrderService(orderCfg)(context.Background()); err != nil {
log.Fatal(err)
}
}
Core business logic – zero changes. Other services remain merged.
Phase 3: Maturity – Full Microservices
Every service runs independently, with service discovery, API Gateway, and other infrastructure in place.
Notice: every InitXxxService function has the same signature from day one. From the very beginning, the code is already structured for microservices—you just chose to run them together in one process first.
Merged deployment and microservice deployment share the exact same code path. The former is not a compromised version; it is a subset of the latter.
5. Important Constraint: Configuration Injection Inside Modules
When organising modules, one critical limitation must be observed: inside a dig.Module, you cannot use dig.Supply with function parameters (runtime values), because the code generator runs at compile time and cannot capture those values.
// ❌ Incorrect: Supplying a function parameter inside Module
func UserServiceModule(cfg *UserConfig) dig.Option {
return dig.Module(
dig.Supply(cfg), // compile error: cfg is a function parameter, invisible at generation time
dig.Provide(NewUserRepo),
)
}
// ✅ Correct: Module only organises providers; configuration is supplied at Build level
func UserServiceModule() dig.Option {
return dig.Module(
dig.Provide(NewUserRepo),
dig.Provide(NewUserHandler),
// do NOT Supply configuration here
)
}
func InitUserService(cfg *UserConfig) func(context.Context) error {
return dig.Build(
dig.Supply(cfg), // supply configuration at Build level
UserServiceModule(),
)
}
This limitation is inherent to compile‑time code generation: all dependencies are resolved during go generate, while function parameters are only known at runtime, so the generator cannot anticipate them.
This design choice also brings a significant benefit—generated code has zero runtime reflection and zero runtime dependency, with performance identical to manually written initialisation.
6. Conclusion
Progressive architecture is not about a specific deployment pattern; it is about architectural flexibility:
- Start with a monolith to keep costs low and validate business quickly.
- Evolve into microservices as needed—scale independently and evolve autonomously.
- Throughout the process, the code path remains consistent—no “special code for monolith” and no costly rewrites for splitting.
In the Go ecosystem, the key to such flexibility is having a DI tool that provides a unified executable interface and independent, composable dependency graphs. The compile‑time approach described here returns a standard func(context.Context) error—which aligns perfectly with Go's philosophy of preferring simple functions and composition over framework‑bound objects.
The dig tool mentioned in this article is a compile‑time dependency injection implementation **independent of Uber's go.uber.org/dig. It focuses on code generation and zero‑runtime overhead, making it suitable for building progressive architecture as described above.
Top comments (0)