Modular Monolith Architecture #
Many teams fall into one of two mistakes. The first: building a monolith without structure, letting all modules import each other freely until nobody knows the side effects of any change — this is the so-called big ball of mud. The second: jumping straight to microservices before the domain is well understood, producing a distributed monolith that has all the downsides of distribution without its benefits. Modular Monolith Architecture sits between those two extremes: one deployment, one binary, one pipeline — but with module boundaries as firm as microservice boundaries. Each module has its own domain, service, and repository. Other modules cannot access a module’s internals directly. Communication happens through interfaces or events. The result: all of a monolith’s operational advantages, with a design quality that allows evolution to microservices whenever needed.
What Distinguishes a Modular Monolith from a Traditional Monolith? #
The difference is not in infrastructure — both are one deployment. The difference is in boundary discipline:
flowchart TD
subgraph TM["Traditional Monolith — Boundaries Not Kept"]
U1["User code"] -->|"direct import"| O1["Order code"]
O1 -->|"direct import"| P1["Payment code"]
P1 -->|"direct import"| U1
U1 -->|"accesses order DB"| DB1[(One DB\\nwithout boundaries)]
O1 -->|"accesses user DB"| DB1
end
subgraph MM["Modular Monolith — Firm Boundaries"]
subgraph UM["User Module"]
UH["Handler"] --> US["Service"] --> UR["Repo"]
UDB[(user schema)]
UR --> UDB
end
subgraph OM["Order Module"]
OH["Handler"] --> OS["Service"] --> OR["Repo"]
ODB[(order schema)]
OR --> ODB
end
subgraph PM["Payment Module"]
PH["Handler"] --> PS["Service"] --> PR["Repo"]
PDB[(payment schema)]
PR --> PDB
end
UM -->|"via interface"| OM
OM -->|"via domain event"| PM
endThree pillars make a modular monolith different from a regular monolith:
| Pillar | Traditional Monolith | Modular Monolith |
|---|---|---|
| Module boundaries | None — everything can import everything | Firm — only via public interfaces |
| Inter-module communication | Direct imports into internals | Via interfaces or domain events |
| Database schema | One schema, all tables mixed | Isolated schemas per module, no cross-module JOINs |
Three Implementation Pillars #
Pillar 1: Each Module Exposes Interfaces, Not Concrete Structs #
A module only exposes interfaces defining what can be done, not how it does it. Other modules may only depend on those interfaces.
// internal/user/api.go — the public API of the User module
// This is the only file other modules are allowed to import
package user
import "context"
// API is the public contract of the User module
// ✓ Other modules depend on this interface, not its implementation
type API interface {
GetByID(ctx context.Context, id string) (*UserDTO, error)
ValidateActive(ctx context.Context, id string) error
GetFullName(ctx context.Context, id string) (string, error)
}
// UserDTO is the DTO shared with other modules
// ✓ Not the internal domain entity — only a data transfer object
type UserDTO struct {
ID string
FullName string
Email string
IsActive bool
}
// Module is the User module entry point exposing its API
type Module struct {
api *service // internal implementation, not exposed
handler *handler
}
func NewModule(db *sql.DB) *Module {
repo := newPostgresRepository(db)
svc := newService(repo)
h := newHandler(svc)
return &Module{api: svc, handler: h}
}
// API returns the implementation of the API interface
// ✓ The return type is an interface, not *service
func (m *Module) API() API {
return m.api
}
// RegisterRoutes registers this module's HTTP routes
func (m *Module) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /users", m.handler.create)
mux.HandleFunc("GET /users/{id}", m.handler.getByID)
mux.HandleFunc("PUT /users/{id}/deactivate", m.handler.deactivate)
}
// internal/user/service.go — internal implementation, NOT exposed to other modules
package user
import (
"context"
"errors"
"time"
)
// service implements the API interface
// This is the internal implementation detail of the User module
type service struct {
repo repository
}
func newService(repo repository) *service {
return &service{repo: repo}
}
// GetByID implements API.GetByID
func (s *service) GetByID(ctx context.Context, id string) (*UserDTO, error) {
u, err := s.repo.findByID(ctx, id)
if err != nil {
return nil, err
}
return &UserDTO{
ID: u.id,
FullName: u.fullName,
Email: u.email,
IsActive: u.isActive,
}, nil
}
// ValidateActive implements API.ValidateActive
func (s *service) ValidateActive(ctx context.Context, id string) error {
u, err := s.repo.findByID(ctx, id)
if err != nil {
return err
}
if !u.isActive {
return errors.New("user is inactive")
}
return nil
}
func (s *service) GetFullName(ctx context.Context, id string) (string, error) {
u, err := s.repo.findByID(ctx, id)
if err != nil {
return "", err
}
return u.fullName, nil
}
// user is the INTERNAL domain entity — not exposed to the outside
type user struct {
id string
fullName string
email string
isActive bool
createdAt time.Time
}
func (u *user) deactivate() error {
if !u.isActive {
return errors.New("user is already inactive")
}
u.isActive = false
return nil
}
Pillar 2: Inter-Module Communication via an Internal Event Bus #
When module A needs to tell module B that something happened without holding a direct reference to module B, use an internal event bus. This is the strongest form of loose coupling in a modular monolith.
// internal/shared/eventbus/eventbus.go — shared component
package eventbus
import (
"context"
"fmt"
"sync"
)
// Event is the interface for all domain events
type Event interface {
EventName() string
}
// Handler is the function handling an event
type Handler func(ctx context.Context, event Event) error
// EventBus is an in-process event bus for inter-module communication
type EventBus struct {
mu sync.RWMutex
handlers map[string][]Handler
}
func New() *EventBus {
return &EventBus{
handlers: make(map[string][]Handler),
}
}
// Subscribe registers a handler for a specific event
func (b *EventBus) Subscribe(eventName string, handler Handler) {
b.mu.Lock()
defer b.mu.Unlock()
b.handlers[eventName] = append(b.handlers[eventName], handler)
}
// Publish publishes an event to all subscribers
// ✓ The publisher does not know who the subscribers are
func (b *EventBus) Publish(ctx context.Context, event Event) error {
b.mu.RLock()
handlers := b.handlers[event.EventName()]
b.mu.RUnlock()
var errs []error
for _, handler := range handlers {
if err := handler(ctx, event); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return fmt.Errorf("%d handlers failed to process event %s", len(errs), event.EventName())
}
return nil
}
// internal/order/events.go — domain events published by the Order module
package order
import "time"
// OrderPlacedEvent is published when an order is successfully created
// ✓ Other modules (notification, inventory) subscribe to this event
type OrderPlacedEvent struct {
OrderID string
CustomerID string
TotalCents int64
Items []OrderItemEvent
OccurredAt time.Time
}
type OrderItemEvent struct {
ProductID string
Quantity int
}
func (e OrderPlacedEvent) EventName() string { return "order.placed" }
// OrderCancelledEvent is published when an order is cancelled
type OrderCancelledEvent struct {
OrderID string
CustomerID string
Reason string
OccurredAt time.Time
}
func (e OrderCancelledEvent) EventName() string { return "order.cancelled" }
// internal/order/service.go — the Order module uses the User API and EventBus
package order
import (
"context"
"errors"
"time"
"myapp/internal/shared/eventbus"
"myapp/internal/user" // ✓ only imports the user package for its types
)
type Service struct {
repo Repository
userAPI user.API // ✓ interface from the User module, not a concrete struct
eventBus *eventbus.EventBus
}
func NewService(repo Repository, userAPI user.API, bus *eventbus.EventBus) *Service {
return &Service{repo: repo, userAPI: userAPI, eventBus: bus}
}
type PlaceOrderInput struct {
CustomerID string
Items []PlaceOrderItem
}
type PlaceOrderItem struct {
ProductID string
Quantity int
PriceCents int64
}
func (s *Service) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*Order, error) {
// Validate the customer via the User module API
if err := s.userAPI.ValidateActive(ctx, input.CustomerID); err != nil {
return nil, errors.New("invalid customer: " + err.Error())
}
// Create the order
order := &Order{
ID: generateID(),
CustomerID: input.CustomerID,
Status: StatusPending,
CreatedAt: time.Now(),
}
total := int64(0)
items := make([]OrderItem, len(input.Items))
for i, item := range input.Items {
items[i] = OrderItem{
ProductID: item.ProductID,
Quantity: item.Quantity,
PriceCents: item.PriceCents,
}
total += item.PriceCents * int64(item.Quantity)
}
order.Items = items
order.TotalCents = total
if err := s.repo.Save(ctx, order); err != nil {
return nil, err
}
// Publish the event — the Notification and Inventory modules will respond
// ✓ Order does not know who subscribes
eventItems := make([]OrderItemEvent, len(items))
for i, item := range items {
eventItems[i] = OrderItemEvent{ProductID: item.ProductID, Quantity: item.Quantity}
}
_ = s.eventBus.Publish(ctx, OrderPlacedEvent{
OrderID: order.ID,
CustomerID: order.CustomerID,
TotalCents: order.TotalCents,
Items: eventItems,
OccurredAt: time.Now(),
})
return order, nil
}
// internal/notification/module.go — the Notification module subscribes to Order events
package notification
import (
"context"
"fmt"
"myapp/internal/order"
"myapp/internal/shared/eventbus"
"myapp/internal/user"
)
type Module struct {
userAPI user.API
bus *eventbus.EventBus
}
func NewModule(userAPI user.API, bus *eventbus.EventBus) *Module {
m := &Module{userAPI: userAPI, bus: bus}
m.registerHandlers()
return m
}
// registerHandlers registers all event handlers
// ✓ Notification subscribes to events from other modules without direct coupling
func (m *Module) registerHandlers() {
m.bus.Subscribe("order.placed", m.handleOrderPlaced)
m.bus.Subscribe("order.cancelled", m.handleOrderCancelled)
}
func (m *Module) handleOrderPlaced(ctx context.Context, event eventbus.Event) error {
e, ok := event.(order.OrderPlacedEvent)
if !ok {
return fmt.Errorf("unexpected event type")
}
// Get the customer's name from the User module
fullName, err := m.userAPI.GetFullName(ctx, e.CustomerID)
if err != nil {
return err
}
// Send the notification
fmt.Printf("[Notification] Order %s created by %s, total: Rp%.2f\n",
e.OrderID, fullName, float64(e.TotalCents)/100)
return nil
}
func (m *Module) handleOrderCancelled(ctx context.Context, event eventbus.Event) error {
e, ok := event.(order.OrderCancelledEvent)
if !ok {
return fmt.Errorf("unexpected event type")
}
fmt.Printf("[Notification] Order %s cancelled: %s\n", e.OrderID, e.Reason)
return nil
}
Pillar 3: Per-Module Schema Isolation #
Even though one database is used, each module may only access tables in its own schema. No cross-module JOINs.
-- Migration: each module has its own schema
CREATE SCHEMA IF NOT EXISTS user_module;
CREATE SCHEMA IF NOT EXISTS order_module;
CREATE SCHEMA IF NOT EXISTS payment_module;
CREATE SCHEMA IF NOT EXISTS notification_module;
-- User tables only in the user_module schema
CREATE TABLE user_module.users (
id UUID PRIMARY KEY,
full_name VARCHAR(200) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- Order tables only in the order_module schema
CREATE TABLE order_module.orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL, -- reference to a user, but WITHOUT a foreign key constraint
status VARCHAR(50) NOT NULL,
total_cents BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- ✗ No cross-schema JOINs:
-- SELECT o.*, u.full_name FROM order_module.orders o
-- JOIN user_module.users u ON u.id = o.customer_id ← DON'T
-- ✓ Data from other modules is fetched via API calls, not JOINs
Enforcing Boundaries with Go Tooling #
Go provides a native mechanism for enforcing module boundaries:
// internal/order/internal/domain/order.go
// ✓ The "internal" sub-package can only be accessed within the order package
// The Go compiler prevents imports from outside
package domain
// Order is an internal module entity — inaccessible from outside the order module
type Order struct {
id string
customerID string
status Status
items []Item
}
// Verify inter-module dependencies with a Go build tag
// Create file: internal/order/check_deps_test.go
//go:build deps_check
package order_test
import (
// ✓ Allowed: importing shared packages
_ "myapp/internal/shared/eventbus"
// ✓ Allowed: importing another module's public API
_ "myapp/internal/user"
// ✗ Not allowed: importing another module's internal packages
// _ "myapp/internal/user/internal/domain" ← compiler error
// _ "myapp/internal/payment/service" ← compiler error
)
Or use the go-cleanarch tool for automatic validation:
# Install
go install github.com/roblaszczak/go-cleanarch@latest
# Run validation — fails if any dependency violates the rules
go-cleanarch -application myapp/internal
# Integrate into CI/CD
# .github/workflows/ci.yml:
# - name: Check architecture
# run: go-cleanarch -application ./internal
Full Directory Structure #
myapp/
├── cmd/
│ └── server/
│ └── main.go ← Entry point: wiring all modules
│
├── internal/ ← Internal code, cannot be imported from outside
│ │
│ ├── user/ ← User module
│ │ ├── api.go ← PUBLIC: Module struct, API interface, UserDTO
│ │ ├── handler.go ← INTERNAL: HTTP handler
│ │ ├── service.go ← INTERNAL: business logic
│ │ ├── repository.go ← INTERNAL: data access
│ │ └── internal/ ← SUPER PRIVATE: only within the user package
│ │ └── domain/
│ │ └── user.go ← Domain entity not exposed at all
│ │
│ ├── order/ ← Order module
│ │ ├── api.go ← PUBLIC: Module struct, API interface
│ │ ├── events.go ← PUBLIC: Domain events (subscribable by other modules)
│ │ ├── handler.go ← INTERNAL
│ │ ├── service.go ← INTERNAL
│ │ └── repository.go ← INTERNAL
│ │
│ ├── payment/ ← Payment module
│ │ ├── api.go
│ │ ├── events.go
│ │ ├── handler.go
│ │ ├── service.go
│ │ └── repository.go
│ │
│ ├── notification/ ← Notification module
│ │ ├── module.go ← Subscribes to events from other modules
│ │ └── sender.go
│ │
│ └── shared/ ← Shared components
│ ├── eventbus/
│ │ └── eventbus.go ← In-process event bus
│ ├── middleware/
│ │ └── auth.go
│ └── response/
│ └── response.go
│
└── migrations/
├── 001_create_user_schema.sql
├── 002_create_order_schema.sql
└── 003_create_payment_schema.sql
Wiring in main.go #
// cmd/server/main.go
package main
import (
"database/sql"
"log"
"net/http"
_ "github.com/lib/pq"
"myapp/internal/notification"
"myapp/internal/order"
"myapp/internal/payment"
"myapp/internal/shared/eventbus"
"myapp/internal/user"
)
func main() {
db, err := sql.Open("postgres", "postgres://localhost/myapp?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Shared event bus — one for the whole application
bus := eventbus.New()
// Initialize modules — each module only receives the dependencies it needs
userModule := user.NewModule(db)
orderModule := order.NewModule(db, userModule.API(), bus)
paymentModule := payment.NewModule(db, orderModule.API(), bus)
notificationModule := notification.NewModule(userModule.API(), bus)
// All modules ready — notification has already subscribed to events via its constructor
// Set up the router
mux := http.NewServeMux()
userModule.RegisterRoutes(mux)
orderModule.RegisterRoutes(mux)
paymentModule.RegisterRoutes(mux)
// notification has no HTTP routes — it is only an event subscriber
_ = notificationModule // keep it in scope so it is not GC'd
log.Println("Modular monolith running on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
flowchart TD
MAIN["main.go\\n(wiring all modules)"]
UM["User Module\\nAPI: GetByID, ValidateActive"]
OM["Order Module\\nAPI: GetByID, CancelOrder"]
PM["Payment Module"]
NM["Notification Module"]
BUS["EventBus\\n(shared)"]
MAIN -->|"NewModule(db)"| UM
MAIN -->|"NewModule(db, userAPI, bus)"| OM
MAIN -->|"NewModule(db, orderAPI, bus)"| PM
MAIN -->|"NewModule(userAPI, bus)"| NM
OM -->|"userAPI.ValidateActive()"| UM
OM -->|"Publish(OrderPlacedEvent)"| BUS
PM -->|"Publish(PaymentProcessedEvent)"| BUS
BUS -->|"handleOrderPlaced()"| NM
BUS -->|"handlePaymentProcessed()"| NMThe Evolution Path to Microservices #
One of the biggest advantages of a properly designed modular monolith is the ease of extracting a module into a microservice when needed:
flowchart TD
subgraph MM["Modular Monolith (now)"]
UM2["User Module"]
OM2["Order Module"]
PM2["Payment Module"]
NM2["Notification Module"]
BUS2["EventBus (in-process)"]
OM2 -->|"via interface"| UM2
OM2 --> BUS2
BUS2 --> NM2
end
subgraph EXTRACT["Extract Payment into a microservice"]
UM3["User Module\\n(still in the monolith)"]
OM3["Order Module\\n(still in the monolith)"]
NM3["Notification Module\\n(still in the monolith)"]
BUS3["Kafka\\n(replacing the EventBus)"]
PS["Payment Service\\n(new microservice)"]
OM3 -->|"via HTTP/gRPC"| PS
PS --> BUS3
BUS3 --> NM3
end
MM -->|"1. Isolate Payment into a separate service\\n2. Replace the in-process bus with Kafka\\n3. Replace interface calls with HTTP"| EXTRACTSafe extraction steps:
1. Verify boundaries — ensure the Payment module does not import other modules
directly (only via interfaces/events)
2. Create the Payment microservice with an API identical to the old API interface
3. Replace the in-process EventBus with Kafka/RabbitMQ for events involving Payment
4. Replace interface calls to Payment with HTTP/gRPC clients
5. Deploy the Payment service separately
6. Remove the Payment module from the monolith
This process can be done gradually without downtime because the interface and event contracts were clearly defined from the start.
Anti-Patterns to Avoid #
// ✗ Direct imports into another module's internals
package order
import "myapp/internal/user/service" // ✗ importing the user package's internals
func (s *Service) PlaceOrder(customerID string) error {
// ✗ accessing the user module's internal implementation
userSvc := service.NewUserService(s.db)
user, _ := userSvc.FindByID(customerID)
_ = user
return nil
}
// ✓ Use the API interface exposed by the module
package order
func (s *Service) PlaceOrder(ctx context.Context, customerID string) error {
// ✓ only using the public API interface
if err := s.userAPI.ValidateActive(ctx, customerID); err != nil {
return err
}
return nil
}
// ✗ Cross-schema database JOINs
// repository.go in the order module
func (r *repo) GetOrdersWithUserName(ctx context.Context) ([]OrderWithUser, error) {
// ✗ JOIN into user_module tables — violates schema isolation
return r.db.QueryContext(ctx, `
SELECT o.id, o.total_cents, u.full_name
FROM order_module.orders o
JOIN user_module.users u ON u.id = o.customer_id
`)
}
// ✓ Fetch data from other modules via API, not via JOINs
func (s *Service) GetOrdersWithUserName(ctx context.Context) ([]OrderDisplay, error) {
orders, err := s.repo.FindAll(ctx)
if err != nil {
return nil, err
}
result := make([]OrderDisplay, len(orders))
for i, order := range orders {
name, _ := s.userAPI.GetFullName(ctx, order.CustomerID) // ✓ via API
result[i] = OrderDisplay{OrderID: order.ID, CustomerName: name}
}
return result, nil
}
// ✗ Shared mutable state in event handlers — race condition
var processedEvents = map[string]bool{} // ✗ shared without a lock
func handleEvent(ctx context.Context, event eventbus.Event) error {
processedEvents[event.EventName()] = true // ✗ data race
return nil
}
// ✓ Use sync.Map or avoid shared state in event handlers
var processed sync.Map
func handleEventSafe(ctx context.Context, event eventbus.Event) error {
processed.Store(event.EventName(), true) // ✓ thread-safe
return nil
}
When a Modular Monolith Is Enough and When to Evolve #
A Modular Monolith is great if:
✓ Teams of 5–30 developers with clear per-module domain ownership
✓ The system is still in a growth phase — the domain is being understood
✓ Fast deployment time matters more than per-service scaling
✓ The team does not yet have the capacity to manage distributed system ops
✓ There is no bottleneck requiring independent per-module scaling
Consider extracting a module into a microservice if:
✗ One module has far higher load than the others
and vertical/horizontal scaling is no longer efficient
✗ The module's owning team needs to deploy independently
without waiting for the monolith's release
✗ The module uses incompatible technology
(e.g., ML inference needing a Python runtime)
✗ Compliance or security requires full runtime isolation
Modular Monolith Review Checklist #
MODULE BOUNDARIES:
□ Each module has an api.go file exposing the API interface and public DTOs
□ No direct imports into another module's internal packages
□ Internal structs and domain entities are not exposed outside the module
□ The compiler can validate boundaries (internal/ package)
INTER-MODULE COMMUNICATION:
□ Modules communicate synchronously via API interfaces (not concrete structs)
□ Modules communicate asynchronously via the EventBus (not direct method calls)
□ Shared DTOs are data transfer objects, not domain entities
□ No circular dependencies between modules
DATABASE ISOLATION:
□ Each module has its own database schema
□ No cross-schema JOINs in repository code
□ Data from other modules is fetched via API calls, not direct queries
□ Migration files are organized per module
EVENT BUS:
□ Publishers do not know who the subscribers are
□ Event handlers are thread-safe (no shared mutable state)
□ Event types have descriptive names (past tense: order.placed)
□ A failure in one handler does not cancel other handlers
BOUNDARY VALIDATION:
□ A tool or test validates dependencies between modules
□ CI/CD runs dependency validation on every push
□ go test -race is run regularly
EVOLUTION READINESS:
□ Each module's API interface is stable and documented
□ Per-module database schemas have no cross-module foreign key constraints
□ Event contracts are documented as candidates for Kafka/RabbitMQ
Summary #
- The Modular Monolith is the sweet spot between monolith and microservices — one deployment with boundary discipline as if already distributed; all of a monolith’s operational advantages, with a design quality that enables evolution.
- Three non-negotiable pillars — public interfaces (other modules may only depend on API interfaces), an internal event bus (async communication without direct coupling), and schema isolation (no cross-schema JOINs).
- Each module exposes an API interface, not concrete structs — this ensures coupling always goes through stable contracts, never through implementation details that can change.
- An in-process EventBus for loose coupling — the module publishing an event does not know who subscribes; this allows adding new subscribers without changing the publisher.
- Schema isolation is a frequently overlooked boundary — cross-schema JOINs create hidden coupling at the database level that is hard to detect and hard to separate when a module needs extraction.
- The Go
internal/package is a free boundary keeper — the Go compiler prevents imports from outside the parent directory; this is the most effective way to enforce boundaries without extra tooling.- Prepare for evolution from the start — design API interfaces, event contracts, and schemas as if the module will become a microservice; this makes future extraction a safe, gradual operation.
- Best for teams of 5–30 developers — small enough for one deployment, large enough for clear boundaries; below that a Traditional Monolith suffices, above that consider extracting specific modules.
- Not a forced stepping stone — a modular monolith can be a very effective end-game architecture; not every system needs to evolve to microservices.
- Team discipline matters more than tooling — well-defined boundaries are useless if the team is not committed to respecting them; reviewing boundary violations in code review is as important as reviewing logic.