Innovative Architecture with Go Workspaces and Contract Definitions
Cet article est disponible en français.
The choice between monolith and microservices is often framed as a dilemma where you must “pick your poison.” Monoliths are easy to start but frequently turn into a big ball of mud. Microservices offer isolation but introduce massive operational overhead from day one.
In this article, we explore a middle ground detailed in our latest architecture white paper: The Go Modular Monolith with Workspaces and “Pure” Contract Definitions.
The Problem: Boundary Erosion & Dependency Hell
In traditional Go monoliths, boundaries are maintained by convention. While internal/ packages help to some extent, nothing strictly prevents serviceA from taking a dependency on serviceB, turning any future refactoring into a nightmare.
But there is an even more insidious problem: Dependency Hell.
In a standard monolith (a single go.mod), every service must share exactly the same version of every library.
- If Service A needs
aws-sdk-gov1… - And Service B needs
aws-sdk-gov2… - You’re stuck. The entire platform is held back by the technical debt of a single service.
The Solution: Go Workspaces + Pure Contract Definitions
This pattern rests on three foundational pillars to provide strong boundaries, independent dependency graphs, and flexible distribution.
1. Go Workspaces (go.work)
Instead of one massive go.mod file, each module is treated as an independent Go module within a single repository. The Go workspace coordinates these modules, letting them coexist in a monorepo while letting the compiler prevent unauthorized imports between them.
Critically, this gives you Independent Dependency Graphs. Module A and Module B can use different versions of the same library without conflict.
2. The “Pure Contract Definition” Pattern
This is the secret recipe. Instead of modules calling each other directly, they communicate via a Contract Definition — a separate module with zero dependencies.
A contract definition is strictly a contract. To prevent any coupling, we enforce (via CI) that a contract definition contains ZERO logic and is generated automatically from .proto files using the protoc-gen-go-contracts plugin.
Each domain automatically generates:
- The service interface — the Go contract that consumers use.
- Event constants —
TopicXxxconstants +Topics []stringfor subscriptions. - Domain error codes — typed constants aligned with the proto enum.
- The HTTP client —
NewPrivateHTTPClient/NewPublicHTTPClientfor network transport.
// contracts/go/application/auth/auth_private_service_contract_gen.go
// Code generated by protoc-gen-go-contracts. DO NOT EDIT.
type AuthPrivateService interface {
ValidateToken(ctx context.Context, req *authv1.ValidateTokenRequest) (*authv1.ValidateTokenResponse, error)
}3. Hexagonal Architecture (Ports and Adapters)
Within each module, a strict hierarchy is maintained. The concrete implementation lives inside the module, exposed via a ContractAdapter in the inbound adapters layer.
- Domain Layer: Pure business logic, zero external dependencies.
- Application Layer: Use cases and Ports (interfaces).
- Adapters Layer: Implementations. This is where the
ContractAdapterlives (e.g.,modules/auth/internal/adapters/inbound/inproc/).
Technical Flow: Request → Todo → Auth (In-Process) → Response
The following diagram illustrates the request lifecycle at runtime. It shows how the Todo module validates a JWT token against the Auth module within a single process, while strictly respecting the architectural seams.
sequenceDiagram
autonumber
actor User
participant H as Todo Connect Handler
participant M as Auth Middleware
participant P as Todo Port (AuthPrivateService)
participant I as ContractAdapter (inproc)
participant C as Contract Interface
participant S as Auth Application Service
User->>H: HTTP/Connect Request
H->>M: token validation
M->>P: need to validate JWT
note right of P: "⚡ Cross-Module Call (Function Call)"
P->>I: dispatch via defauth.AuthPrivateService interface
I->>C: implements the contract interface
C->>S: call auth use case
S-->>C: domain result
C-->>I: AuthPrivateService response
I-->>M: validated token / error
M-->>H: enriched context (user ID)
H-->>User: HTTP ResponseComponent Wiring and Execution Flow
Each module exposes its ContractAdapter via an accessor on *Module. The consumer sees only the interface — never the concrete type.
The Provider: Exposing a Contract
// modules/auth/auth.go
func (m *Module) PrivateService() defauth.AuthPrivateService {
return inproc.NewContractAdapter(m.service)
}The in-process adapter implements the generated interface:
// modules/auth/internal/adapters/inbound/inproc/contract_adapter.go
type ContractAdapter struct{ svc application.AuthService }
var _ defauth.AuthPrivateService = (*ContractAdapter)(nil)The Consumer: The Composition Root
The composition root (cmd/mmw/main.go) is the only place where modules are instantiated and wired together. Cross-module dependencies are injected via contract interfaces, never via pointers to concrete module types.
// cmd/mmw/main.go
func initModules(logger *slog.Logger, dbPool *pgxpool.Pool, ...) ([]pfcore.Module, error) {
// 1. Auth — no inter-module dependencies
authModule, _ := auth.New(auth.Infrastructure{
DBPool: dbPool,
EventBus: eventBus,
Logger: logger.With("module", auth.ModuleName),
})
// 2. Todo — depends on Auth's private service for JWT validation
todoModule, _ := todo.New(todo.Infrastructure{
DBPool: dbPool,
EventBus: eventBus,
Logger: logger.With("module", todo.ModuleName),
AuthSvc: authModule.PrivateService(), // returns defauth.AuthPrivateService
})
// 3. Notifications — subscribes to events from all modules
notifModule, _ := notifications.New(notifications.Infrastructure{
Subscriber: rawBus,
Topics: append(tododef.Topics, authdef.Topics...),
})
return []pfcore.Module{authModule, todoModule, notifModule}, nil
}The Platform Runner
Modules are not launched manually. The mmw-platform library orchestrates them with an errgroup in shared fate mode: if any module fails (DB loss, unrecovered panic), the shared context is cancelled and all other modules shut down cleanly.
// cmd/mmw/main.go
modules, _ := initModules(logger, dbPool, rawBus, eventBus)
platform.New(logger, modules).Run(ctx)Each module implements the core.Module interface — a constraint verified at compile time:
var _ pfcore.Module = (*Module)(nil)
func (m *Module) Start(ctx context.Context) error {
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error { return m.server.Start(gCtx) }) // Connect RPC server
g.Go(func() error { m.relay.Start(gCtx); return nil }) // outbox → Watermill relay
g.Go(func() error { return m.router.Run(gCtx) }) // event router
return g.Wait()
}mmw: The Framework That Holds It All Together
Building a modular monolith involves a recurring amount of technical plumbing: setting up an HTTP server compatible with Connect/gRPC, chaining middlewares, handling OS signals, implementing the outbox, abstracting PostgreSQL transactions… The mmw framework (github.com/piprim/mmw) solves this once and for all.
It is composed of two distinct parts with different lifecycles.
The Runtime Platform (pkg/platform)
This is a dependency in each module’s go.mod. It provides everything a module needs to participate in the monolith without reinventing the wheel:
| Component | What it provides |
|---|---|
| HTTP Server | Pre-configured middleware chain (Logger → Recovery → CORS → BearerAuth → Mux), h2c support for Connect RPC, /debug/monit endpoint for health checks |
| BearerAuth Middleware | Token validation via a TokenValidator closure, injection of userID into the request context |
| Outbox relay | PostgreSQL polling every 2s, Watermill publishing, FOR UPDATE SKIP LOCKED for multi-replica safety |
| Unit of Work | Abstraction over *pgxpool.Pool / pgx.Tx behind a uniform DBExecutor interface |
| Config loader | Layered TOML loading (default.toml → <APP_ENV>.toml → environment variables) |
| Connect interceptors | Error logging with intact eris stack trace, without exposing details to the client |
| Auth context | authctx.WithUserID / authctx.UserID to propagate identity through handlers |
The CLI (mmw-cli)
The CLI is purely a development accelerator — it is never embedded in the production binary.
mmw new module # interactive scaffold: generates modules/<name>/, contracts/, go.work, mise.toml
mmw new contract # generates only the contract definition files
mmw check arch # validates architectural boundaries (see next section)
mmw test coverage # prints a test coverage table by packageThe mmw new module command is particularly noteworthy: it launches an interactive form that generates the complete structure of a new module (domain, application, adapters, migrations, proto, contracts) and updates go.work and mise.toml automatically. Adding a new module to the architecture takes seconds.
Enforcing Architectural Rules
The boundaries of this architecture are guaranteed at two distinct levels.
Level 1: The Go Compiler
Cross-module imports into internal/ packages are structurally impossible. Each functional module has its own go.mod; the Go compiler physically refuses to let a module depend on another functional module’s internals. This is not a convention — it is a property of the language.
✗ IMPOSSIBLE — guaranteed compile error:
modules/todo/go.mod does not reference modules/auth,
so modules/todo can never import modules/auth/internal/... Level 2: mmw check arch
What the compiler cannot verify — layer violations within a single module — is validated by mmw check arch (or mise run arch:check) on every CI run:
| Validator | Rule enforced |
|---|---|
DomainPurityValidator |
internal/domain/ does not import adapters/, infra/, or application/ |
ApplicationPurityValidator |
internal/application/ does not import adapters/ or infra/ |
ContractPurityValidator |
contracts/go/application/ does not import application or infra code |
LibDependencyValidator |
libs/ and mmw/ do not import functional modules |
When a violation occurs, the CI error message points directly to the offending file and import:
✗ Architecture validation failed
✗ todo Validating service architecture boundaries
modules/todo/internal/domain/todo.go
imports modules/todo/internal/adapters/postgres (domain → adapter violation)
Fix: Move this dependency behind a port interface in the application layer.Testability as a Direct Consequence
Hexagonal architecture is not just about code organization: it directly determines the speed and reliability of your test suite.
/\ System Tests — 1 suite at the monolith level
/ \ (real binary, Postgres via testcontainers, full flows)
/____\
/ \ Integration Tests — per module, adapters only
/________\
/ \ Application Tests — mocked ports, orchestration logic
/____________\
/ \ Unit Tests — pure domain, zero infrastructure
/________________\Domain tests run in < 1ms with zero external dependencies:
func TestTodo_Complete(t *testing.T) {
title, _ := domain.NewTitle("Buy milk")
todo, _ := domain.NewTodo(title, domain.EmptyDescription, nil, uuid.New())
err := todo.Complete()
require.NoError(t, err)
assert.Equal(t, domain.StatusCompleted, todo.Status())
assert.Len(t, todo.Events(), 2) // TodoCreated + TodoCompleted
}Application layer tests mock only the ports — the architectural boundaries — never the concrete implementations:
func TestCreateTodoCommand_Execute(t *testing.T) {
// Mock the PORTS (application layer interfaces)
// never PostgresRepository or WatermillDispatcher directly
todoRepo := mocks.NewTodoRepository(t)
eventDispatcher := mocks.NewEventDispatcher(t)
uow := mocks.NewUnitOfWork(t)
// ... expectations ...
// Test the REAL SERVICE — business logic, not wiring
svc := application.NewTodoApplicationService(todoRepo, uow, eventDispatcher)
resp, err := svc.CreateTodo(ctx, &todov1.CreateTodoRequest{Title: "Buy milk", UserId: uuid.NewString()})
require.NoError(t, err)
assert.NotEmpty(t, resp.GetTodo().GetId())
}This pyramid is possible because the dependency rules are respected: if domain/ imports nothing external, its tests cannot need anything external either. It is an emergent property of the architecture, not a discipline to maintain manually.
Why “Contract Definition” Instead of “Shared Kernel”?
A common trap in Go is the Shared Kernel, where common logic is dumped into a pkg/ or util/ folder. This leads to tight coupling: change a validation rule in the shared kernel and you break 5 modules.
The Pure Contract Definition pattern avoids this by enforcing strict rules:
- Generated, never hand-written: Contract definitions are produced by
protoc-gen-go-contractsfrom.protofiles. They contain only interfaces, proto DTOs, error codes, and event constants. - Zero business dependencies: The
mmw-contractsmodule depends only onconnectrpc/connectandgoogle.golang.org/protobuf— never on a functional module. - Structural impossibility of cycles: A contract module cannot import a functional module. Cycles are physically impossible.
✓ VALID:
modules/todo → contracts/go/application/auth → (connect + protobuf only)
✗ IMPOSSIBLE (compile error):
contracts/go/application/auth → modules/authIf you find yourself putting validation logic or calculations into a contract definition, you are recreating a shared-kernel monolith.
The Evolution Path
The beauty of this architecture lies in its migration path: you do not have to decide on the final deployment strategy on day one.
- Start In-Process: Deploy a single binary. Modules communicate via function calls (< 1µs latency), orchestrated by the platform runner.
- Add Contracts: Introduce Protobuf/Connect when you need formal schemas — contract definitions automatically generate HTTP clients alongside in-process clients.
- Distribute: When the Auth Module needs to scale independently, simply swap the adapter in
main.go.
The switchover mechanism (configuration-based):
// Before (monolith — in-process)
todoModule, _ := todo.New(todo.Infrastructure{
AuthSvc: authModule.PrivateService(), // direct function call
})
// After (distributed — Connect over HTTP)
todoModule, _ := todo.New(todo.Infrastructure{
AuthSvc: defauth.NewPrivateHTTPClient(
authv1connect.NewAuthPrivateServiceClient(&http.Client{}, "https://auth.internal"),
),
})The defauth.AuthPrivateService interface is identical in both cases. The Todo module has no idea which transport is being used — and not a single line of business logic changes.
Conclusion
The Go Modular Monolith with Workspaces is designed for teams of 5 to 20 developers who need to move fast but want to keep their options open.
It delivers the “monorepo experience” with “microservices discipline”, protecting against both boundary erosion and dependency conflicts. Automatic contract generation from Protobuf ensures that inter-module interfaces remain a first-class artifact — versioned, typed, and never left to rot in a drawer.
Further Reading
- GitHub Repository & White Paper
- The framework mmw
- Go Workspaces Documentation
- Connect RPC for Go
- Buf — Protobuf toolchain