Skip to main content
  1. Posts/

Designing a Modular Monolith in Go: Structure, Boundaries, and Practical Patterns

·2209 words·11 mins·
Go Architecture Golang Modular-Monolith Architecture
Dave Amit
Author
Dave Amit
Principal Architect with nearly two decades designing distributed systems and cloud-native platforms. I bring deep systems expertise, hands-on AI-assisted engineering workflows, and a track record of shipping things that actually scale. Currently writing Go and Rust, running on Kubernetes, and exploring what happens when strong architectural judgment meets modern AI tooling.
Table of Contents
Go Architecture - This article is part of a series.
Part 2: This Article

The conversation around microservices often skips an important question: what problem are you actually solving? For most teams the operational overhead of microservices — distributed tracing, independent deploys, network failures, service meshes — creates more problems than it solves; Martin Fowler’s MonolithFirst makes the argument at length, and my own rule of thumb is that below a couple of dozen engineers the trade is rarely worth it. A modular monolith is frequently the right default: single deployable, strong internal boundaries, easy to evolve.

This post is about building one in Go — and like Part 1, it holds itself to the show-me standard: every boundary claim comes with the command output that proves it, from a small skeleton repo on go1.26.4. The skeleton lives at daveamit/go-architecture-demos — clone it and run 02-modular-monolith/demo.sh to replay every receipt below. That matters here more than anywhere, because the most important fact about modular monoliths in Go is one most posts on the topic never state: Go does not enforce module boundaries. The whole design below is arranging things so that the one boundary Go does enforce, plus one small test, do the work.

What “modular” actually means
#

A module isn’t just a package or a directory. It’s a unit of code that owns its domain, hides its implementation, and — this is the part that has to be literal, not aspirational — imports no other module. In practice:

  • Each module owns its types, storage access, and business logic
  • Modules never import each other; every cross-module interaction is mediated by the composition root
  • An orchestration layer (internal/app) wires everything together and owns all translation between module vocabularies

If you can replace a module’s implementation without touching anything outside internal/app, the boundary is drawn correctly. And “modules never import each other” isn’t a vibe — it’s a one-line check: go list -deps ./internal/billing | grep orders should print nothing. We’ll turn that into a CI test below.

Directory layout
#

cmd/service/
  main.go              ← call run(), nothing else (Part 1's thin-main pattern)
internal/
  app/                 ← composition root: all wiring, all translation
  orders/
    service.go         ← concrete Service, domain types, events
    internal/store/    ← orders' private guts, hidden even from billing
  billing/
    billing.go         ← its own types; imports no other module
  platform/
    bus/               ← in-process event bus (shared plumbing)
api/                   ← protobuf / OpenAPI definitions
migrations/            ← one directory, but schema-per-module inside
go.mod

Two deliberate differences from the layouts you usually see. There’s no pkg/ — by Part 1’s own bar (would another team import this today?), a single service’s logging and pagination helpers don’t qualify; shared plumbing lives in internal/platform/. And each module can carry its own nested internal/ for its private guts — that’s not decoration, it’s the enforcement mechanism, as the receipts below show.

graph TD
  cmd["cmd/service"] -->|starts| app["internal/app"]
  app -->|wires| orders["orders"]
  app -->|wires| billing["billing"]
  app -->|owns| bus["platform/bus"]
  orders -.->|"Created event"| bus
  bus -.->|"app's adapter translates"| billing

Solid arrows are imports — note they all start at app. Dashed arrows are runtime event flow, and even those meet at the bus: there is no edge of any kind between orders and billing. Keeping the second half of that sentence true is the subject of the next section.

The uncomfortable truth: nothing enforces these boundaries
#

Here’s the experiment every modular-monolith post should run and almost none does. In the skeleton repo, billing reaches directly into orders — skipping the events, calling the service, the classic Friday-evening shortcut:

// internal/billing/violation.go
import "example.com/modmono/internal/orders"

func (s *Service) totalFor(ctx context.Context, svc *orders.Service, id string) int {
	o, _ := svc.Get(ctx, id)
	return o.Total
}

Does anything stop it?

go build: PASS (no complaint)
go vet:   PASS (no complaint)

Builds clean, vets clean, and billing’s dependency graph now quietly contains the whole orders module:

$ go list -deps ./internal/billing | grep internal/orders
example.com/modmono/internal/orders/internal/store
example.com/modmono/internal/orders

Part 1 made a point of separating enforced boundaries from conventions — by that standard, every module boundary in this post is convention. The root-level internal/ protects you from other repos; between sibling packages inside it, the toolchain is indifferent. Left alone, boundaries erode one expedient import at a time, and eighteen months later “extract billing into a service” is archaeology.

So you bring two guards.

Guard 1: a nested internal/ per module. The internal/ rule is directory-based (Part 1 has the receipts), so internal/orders/internal/store is importable only under internal/orders/. Point the shortcut at orders’ guts instead of its surface and the build fails before compilation:

package example.com/modmono/internal/billing
	internal/billing/violation.go:3:8: use of internal package
	example.com/modmono/internal/orders/internal/store not allowed

That’s the only compiler-adjacent enforcement you get, and it protects a module’s implementation, not its boundary.

Guard 2: a boundary test. The module-to-module rule needs ~25 lines of CI:

// internal/app/boundaries_test.go
func TestModuleBoundaries(t *testing.T) {
	forbidden := map[string][]string{
		"./../billing": {"internal/orders"},
		"./../orders":  {"internal/billing"},
	}
	for pkg, banned := range forbidden {
		out, err := exec.Command("go", "list", "-deps", pkg).Output()
		if err != nil {
			t.Fatalf("go list %s: %v", pkg, err)
		}
		for _, b := range banned {
			if strings.Contains(string(out), b) {
				t.Errorf("%s imports %s: module boundary violated", pkg, b)
			}
		}
	}
}

Green on the clean tree; re-add the shortcut and:

--- FAIL: TestModuleBoundaries (0.05s)
    boundaries_test.go:23: ./../billing imports internal/orders: module boundary violated
FAIL

One gotcha, learned the honest way: this test shells out to go list, so Go’s test cache doesn’t know its result depends on other packages’ source — a cached ok will happily mask a fresh violation. Run it with -count=1 in CI. (depguard via golangci-lint does the same job declaratively, if you already run the linter.)

Three patterns that make this work
#

1. Interfaces on the consumer side
#

The instinct from other ecosystems is for each module to publish its own interface. Go’s own code review guidance says the opposite — interfaces belong in the package that uses the values, and “do not define interfaces on the implementor side” — and here the convention is load-bearing. If orders publishes orders.Service and billing consumes it, billing imports orders, and “modules don’t import each other” just died in the constructor signature.

So: modules expose concrete types; consumers define their own narrow interfaces; internal/app adapts one to the other. Billing declares what it needs, in its own vocabulary:

// internal/billing/billing.go — imports no other module
type OrderEvent struct {
	OrderID string
	Amount  int
}

func (s *Service) HandleOrderCreated(ctx context.Context, e OrderEvent) { ... }

and the composition root does the translating:

// internal/app/app.go — the only package that sees everyone
created := bus.New[orders.Created]()
bill := billing.New(out)

created.Subscribe(func(ctx context.Context, e orders.Created) {
	bill.HandleOrderCreated(ctx, billing.OrderEvent{OrderID: e.OrderID, Amount: e.Amount})
})

This is also where “translate at the boundary” stops being a separate pattern and becomes a consequence: orders.Created never crosses into billing; the adapter converts it explicitly. (DDD calls the industrial-strength version of this an anti-corruption layer; between two co-owned modules, a typed adapter in app is the right-sized dose.) And the payoff is that the boundary claim is now machine-checked — the go list test above passes precisely because billing’s imports contain no trace of orders.

2. In-process events — with a real bus, not a hand-wave
#

When an order is created, billing reacts, but order creation shouldn’t couple to billing. The tool is an in-process event bus, and it deserves to be shown rather than gestured at, because every hard question about events hides in the bus’s semantics. The skeleton’s bus is ~40 lines with three stated choices:

// internal/platform/bus/bus.go
type Bus[T any] struct {
	mu   sync.RWMutex
	subs []func(context.Context, T)
}

func (b *Bus[T]) Publish(ctx context.Context, ev T) {
	b.mu.RLock()
	subs := b.subs
	b.mu.RUnlock()
	for _, h := range subs {
		func() {
			defer func() {
				if r := recover(); r != nil {
					log.Printf("bus: handler panic recovered: %v", r)
				}
			}()
			h(ctx, ev)
		}()
	}
}

The choices, out loud: typed via generics — one Bus[T] per event type, so a payload mismatch is a compile error instead of a stringly-typed topic plus a runtime type assertion. Synchronous dispatch — deterministic and trivially testable; a slow handler slows the publisher, and if that’s unacceptable the subscriber opts into a goroutine, visibly. Panics recovered — a broken handler is a log line, not an outage:

invoice created for order 42 (120)
bus: handler panic recovered: billing exploded on suspiciously large amount
service still alive after handler panic
exit: 0

Now the honest paragraph, because the folklore version of this pattern ends with “and when you need durability, swap in a real queue — your code doesn’t change.” That claim is false, and believing it hurts. A real queue delivers at least once, so every handler must become idempotent — that’s module code and often a schema (dedup keys). A queue carries bytes, not Go structs, so events grow serialization and versioning. And publish-after-commit acquires a crash window — process dies between the DB commit and the publish, event gone — whose real fix, the transactional outbox, restructures the module’s write path. What the in-process bus actually buys is call-site decoupling today and a seam where a queue could go later. If that later is real, write handlers idempotent from day one.

3. Data boundaries: the part everyone skips
#

Modules that share one database can still be modular — if you treat the schema as part of the boundary. The rules that keep extraction possible: each module owns its tables (schema-per-module, or a table prefix), no cross-module foreign keys, no cross-module JOINs. A foreign key from billing_invoices to orders_orders is an import statement the boundary test can’t see, and it will veto the extraction years later.

Being in one process also grants the modular monolith its actual superpower, so use it deliberately: you can wrap two modules’ writes in a single transaction and get atomicity microservices spend outbox-and-saga machinery approximating. The discipline is to do it consciously, confined to internal/app, per flow. And pick per flow between the two consistency modes you now have: billing is exactly the module where fire-and-forget async is a business incident — “order accepted, payment never attempted” — so an order-plus-invoice flow may deserve the shared transaction, while “send the welcome email” belongs on the bus.

Testing strategy
#

  • Unit tests: each module in isolation. Consumer-side interfaces earn their keep here — billing’s OrderEvent needs no mocks of orders at all, just a struct literal.
  • Event flows: the synchronous bus makes them deterministic — publish, then assert, no sleeps, no flakes.
  • Integration tests: spin up the real app against the same database engine you deploy (testcontainers, not SQLite-in-memory), guarded with a build tag per Part 1 so go test ./... stays fast.
  • The boundary test, with -count=1, in CI, forever. It’s the architecture, executable.
  • Don’t share domain fixtures across modules; a shared internal/testutil for infrastructure helpers (container spin-up, golden files) is fine.

When to actually split into services
#

Measurable triggers, not vibes:

  • Divergent resource profiles — one module needs 64 GB of cache or a GPU while the rest need 512 MB. Note that raw traffic isn’t this: a stateless monolith handles a 10× hot path by adding replicas.
  • Deploy contention you can quantify — release-train queue time, rollback blast radius, teams blocking each other’s deploys week after week.
  • Fault isolation or compliance — a component whose failure or audit scope genuinely must be contained.
  • Team ownership — Conway pressure is real; when a module has a dedicated team with its own on-call, the service boundary may follow the org boundary.

“It might need to scale someday” is still not a reason. And when a real trigger fires, the disciplines above are the migration path: the module’s adapter surface in app becomes the RPC client, its schema is already separate, its events move from bus to broker — the diff concentrates in internal/app and a new cmd/.

Opinionated Takeaway
#

  • Default to the modular monolith. Fowler’s monolith-first argument has aged better than most microservice migrations.
  • Say it plainly: Go will not hold these boundaries. A direct cross-module import builds and vets clean — you saw it. Nested internal/ protects each module’s guts; a 25-line go list test (run -count=1) protects the graph. No third guard exists.
  • Interfaces belong to consumers. Modules export concrete types; app adapts. This is what makes “modules don’t import each other” true, checkable, and idiomatic per Go’s own review guidance.
  • Show your bus. Typed, synchronous, panic-recovering is a fine default — but whatever you choose, the semantics must be written down, because they’re where the events pattern succeeds or fails.
  • Durability is not a swap. At-least-once means idempotent handlers, and publish-after-commit means an outbox. Decide on day one whether that future exists.
  • The schema is part of the boundary. No cross-module FKs or JOINs — and spend the single-transaction superpower deliberately, in app, on the flows that are business-critical.

A modular monolith isn’t a compromise on the way to microservices — it’s a deliberate architecture that keeps operational complexity low while preserving the ability to extract later. In Go it stays honest only if you enforce it yourself. The compiler gives you one wall; build the rest as tests.


Previous in the Go Architecture series: How to structure Go projects — cmd/, internal/, pkg/, and what the toolchain actually enforces.


Go Architecture series: 1. How to structure Go projects · 2. Designing a Modular Monolith in Go

Go Architecture - This article is part of a series.
Part 2: This Article

Related

How to structure Go projects (practical conventions, not rules)
·1874 words·9 mins
Go Architecture Golang Project-Structure Monolith Best-Practices
Stop the Plumbing Hell: Building `bs`
·486 words·3 mins
Go Devops Build-Systems Bs Engineering