Monolithic Architecture #
There is strong industry pressure to always choose the “most modern” architecture: microservices, serverless, event-driven. This pressure is so strong that many teams start new projects directly with 15 separate services before the business domain is well understood, before there is enough traffic to justify distribution complexity, and before the team has the capacity to manage the accompanying operational overhead. The result is often called a distributed monolith — all the downsides of a distributed system without its benefits. Monolithic Architecture is not the antithesis of good architecture — it is a rational, efficient foundation and often the best choice for a growing system. Stack Overflow serves millions of requests per day as a monolith. Shopify operated for years as a monolith before evolving. Basecamp chose to go back to a monolith after a microservices experiment. Knowing when a monolith is the right choice — and how to build it correctly — is a skill just as important as knowing when to split apart.
Three Monolith Variants #
“Monolith” is not a single thing — there is a spectrum from the simplest to the most structured:
flowchart LR
subgraph BOM["Big Ball of Mud"]
B1["Everything mixed:\\nUI, logic, DB\\nin one file/class"]
end
subgraph TM["Traditional Monolith"]
T1["Handler"] --> T2["Service"] --> T3["Repository"]
T3 --> T4[(Database)]
end
subgraph MM["Modular Monolith"]
subgraph UM["User Module"]
UH["Handler"] --> US["Service"] --> UR["Repo"]
end
subgraph OM["Order Module"]
OH["Handler"] --> OS["Service"] --> OR["Repo"]
end
subgraph PM["Payment Module"]
PH["Handler"] --> PS["Service"] --> PR["Repo"]
end
UM & OM & PM --> DB2[(Shared DB)]
end
BOM -->|"add structure"| TM
TM -->|"add boundaries"| MM| Variant | Defining Trait | Team Scalability |
|---|---|---|
| Big Ball of Mud | No structure, everything coupled, nobody dares to change anything | 1–2 developers |
| Traditional Monolith | Has layers (handler/service/repo) but modules are not bounded | 2–8 developers |
| Modular Monolith | Modules with firm boundaries, one deployment | 8–30+ developers |
The goal of this article is to show how to build a healthy Traditional Monolith that can evolve into a Modular Monolith as the team and system grow — and then to a distributed architecture if truly needed.
Key Characteristics of a Monolith #
flowchart TD
subgraph MONO["Monolithic Application"]
subgraph PROC["One Runtime Process"]
API["API Layer\\n/users, /orders, /payments"]
BL["Business Logic\\nValidation, Rules, Calculations"]
DAL["Data Access Layer\\nSQL, ORM, Cache"]
end
PROC -->|"one binary"| BUILD["One Build Artifact"]
BUILD -->|"one pipeline"| DEPLOY["One Deployment"]
end
DEPLOY --> DB[(Database)]
DEPLOY --> CACHE[(Cache)]Four characteristics define a monolith:
One codebase — all code lives in one repository, all developers work in the same place. This makes cross-module refactoring easy and there is no inter-service contract overhead.
One build artifact — compiling once produces one binary or package. No inter-service dependency management, no internal API versioning.
One runtime process — all components run in one OS process. Inter-module communication is a direct function call — zero network latency, zero serialization/deserialization.
One deployment pipeline — deploy once and all features are immediately available. No cross-team deployment coordination.
Why a Monolith Is Faster Early On #
The overhead difference between monolith and microservices becomes very real when the system is under active development:
sequenceDiagram
participant Dev as Developer
participant Code as Codebase
Note over Dev,Code: Monolith: fast development cycle
Dev->>Code: change business logic
Dev->>Code: go build ./...
Dev->>Code: go test ./...
Code-->>Dev: done in seconds
participant MS1 as User Service
participant MS2 as Order Service
participant MS3 as Inventory Service
Note over Dev,MS3: Microservices: coordination overhead
Dev->>MS1: change User API contract
Dev->>MS2: update client code for the User API
Dev->>MS3: update client code for the User API
Dev->>MS1: rebuild + redeploy User Service
Dev->>MS2: rebuild + redeploy Order Service
Dev->>MS3: rebuild + redeploy Inventory Service
Note over Dev,MS3: one change = N deployments + coordinationThis speed is not just about build time — it is about cognitive overhead. In a monolith, a developer can ctrl+click to jump to the implementation of any function in the entire system. In microservices, you need to know which service implements a given contract, which API version is in use, and how to debug cross-service flows.
Go Implementation: A Structured Monolith #
Here is a healthy Go monolith structure — organized but not over-engineered:
// main.go — entry point
package main
import (
"database/sql"
"log"
"net/http"
_ "github.com/lib/pq"
"myapp/internal/user"
"myapp/internal/order"
"myapp/internal/payment"
"myapp/internal/middleware"
)
func main() {
db, err := sql.Open("postgres", "postgres://localhost/myapp?sslmode=disable")
if err != nil {
log.Fatal("database connection failed:", err)
}
defer db.Close()
// Initialize each module
userModule := user.NewModule(db)
orderModule := order.NewModule(db, userModule.Service())
paymentModule := payment.NewModule(db, orderModule.Service())
// Set up the router — all routes in one binary
mux := http.NewServeMux()
// Mount routes per module
userModule.RegisterRoutes(mux)
orderModule.RegisterRoutes(mux)
paymentModule.RegisterRoutes(mux)
// Global middleware
handler := middleware.Chain(mux,
middleware.Logger(),
middleware.Recovery(),
middleware.Auth(),
)
log.Println("Server running on :8080")
log.Fatal(http.ListenAndServe(":8080", handler))
}
// internal/user/module.go — a self-contained User module
package user
import (
"database/sql"
"net/http"
)
// Module encapsulates all user components in one unit
type Module struct {
service *Service
handler *Handler
repository *PostgresRepository
}
func NewModule(db *sql.DB) *Module {
repo := NewPostgresRepository(db)
svc := NewService(repo)
handler := NewHandler(svc)
return &Module{
service: svc,
handler: handler,
repository: repo,
}
}
// Service exposes the internal service for use by other modules
// ✓ Other modules use an interface, not the concrete struct
func (m *Module) Service() UserServiceInterface {
return m.service
}
// RegisterRoutes registers all user routes on the mux
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}", m.handler.Update)
mux.HandleFunc("DELETE /users/{id}", m.handler.Delete)
mux.HandleFunc("GET /users", m.handler.List)
}
// internal/user/service.go
package user
import (
"context"
"errors"
"time"
)
// UserServiceInterface is the contract exposed to other modules
// ✓ Other modules depend on the interface, not the implementation
type UserServiceInterface interface {
GetByID(ctx context.Context, id string) (*UserOutput, error)
ValidateExists(ctx context.Context, id string) error
}
type UserRepository interface {
Save(ctx context.Context, user *User) error
FindByID(ctx context.Context, id string) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
FindAll(ctx context.Context) ([]*User, error)
Delete(ctx context.Context, id string) error
}
// User is the domain entity
type User struct {
ID string
FullName string
Email string
Role string
CreatedAt time.Time
IsActive bool
}
func (u *User) Deactivate() error {
if !u.IsActive {
return errors.New("user is already inactive")
}
u.IsActive = false
return nil
}
type UserOutput struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Email string `json:"email"`
Role string `json:"role"`
}
// Service implements UserServiceInterface
type Service struct {
repo UserRepository
}
func NewService(repo UserRepository) *Service {
return &Service{repo: repo}
}
func (s *Service) GetByID(ctx context.Context, id string) (*UserOutput, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
return &UserOutput{
ID: user.ID,
FullName: user.FullName,
Email: user.Email,
Role: user.Role,
}, nil
}
func (s *Service) ValidateExists(ctx context.Context, id string) error {
_, err := s.repo.FindByID(ctx, id)
return err
}
// CreateInput is the input DTO
type CreateInput struct {
FullName string
Email string
Password string
}
func (s *Service) Create(ctx context.Context, input CreateInput) (*UserOutput, error) {
// Check for a duplicate email
existing, _ := s.repo.FindByEmail(ctx, input.Email)
if existing != nil {
return nil, errors.New("email is already registered")
}
user := &User{
ID: generateID(),
FullName: input.FullName,
Email: input.Email,
Role: "user",
CreatedAt: time.Now(),
IsActive: true,
}
if err := s.repo.Save(ctx, user); err != nil {
return nil, err
}
return &UserOutput{
ID: user.ID,
FullName: user.FullName,
Email: user.Email,
Role: user.Role,
}, nil
}
// internal/order/module.go — the Order module depends on the User module via an interface
package order
import (
"database/sql"
"net/http"
"myapp/internal/user"
)
type Module struct {
service *Service
handler *Handler
}
func NewModule(db *sql.DB, userSvc user.UserServiceInterface) *Module {
repo := NewPostgresRepository(db)
svc := NewService(repo, userSvc)
handler := NewHandler(svc)
return &Module{service: svc, handler: handler}
}
func (m *Module) Service() OrderServiceInterface {
return m.service
}
func (m *Module) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /orders", m.handler.Create)
mux.HandleFunc("GET /orders/{id}", m.handler.GetByID)
mux.HandleFunc("POST /orders/{id}/cancel", m.handler.Cancel)
mux.HandleFunc("GET /users/{user_id}/orders", m.handler.GetByUser)
}
A Scalable Directory Structure #
A good directory structure for a Go monolith that can grow:
myapp/
├── cmd/
│ └── server/
│ └── main.go ← Entry point and wiring
│
├── internal/ ← Internal code — cannot be imported from outside
│ ├── user/ ← User module
│ │ ├── module.go ← Module-internal wiring
│ │ ├── service.go ← Business logic + interface
│ │ ├── handler.go ← HTTP handler
│ │ └── repository.go ← Data access
│ │
│ ├── order/ ← Order module
│ │ ├── module.go
│ │ ├── service.go
│ │ ├── handler.go
│ │ └── repository.go
│ │
│ ├── payment/ ← Payment module
│ │ ├── module.go
│ │ ├── service.go
│ │ ├── handler.go
│ │ └── repository.go
│ │
│ └── middleware/ ← Shared middleware
│ ├── auth.go
│ ├── logger.go
│ └── recovery.go
│
├── pkg/ ← Shared utilities (importable externally)
│ ├── config/
│ │ └── config.go
│ ├── database/
│ │ └── postgres.go
│ └── response/
│ └── response.go
│
└── migrations/ ← Database migrations
├── 001_create_users.sql
├── 002_create_orders.sql
└── 003_create_payments.sql
Use theinternal/package in Go. Packages under theinternal/directory can only be imported by code in the same parent directory. This provides boundary enforcement done by the Go compiler — modules that should not import each other will fail to compile.
Scaling a Monolith #
There is often an assumption that monoliths cannot be scaled. This is not true — there are several effective scaling techniques:
flowchart TD
subgraph VS["Vertical Scaling"]
APP1["Monolith\\n4 CPU, 8GB RAM"] -->|"upgrade"| APP2["Monolith\\n16 CPU, 64GB RAM"]
end
subgraph HS["Horizontal Scaling"]
LB["Load Balancer"] --> M1["Monolith\\nInstance 1"]
LB --> M2["Monolith\\nInstance 2"]
LB --> M3["Monolith\\nInstance 3"]
M1 & M2 & M3 --> DB[(Shared Database)]
end
subgraph RS["Read Replica"]
WDB[(Primary DB\\nWrite)] --> RDB1[(Read Replica 1)]
WDB --> RDB2[(Read Replica 2)]
APP3["Monolith"] -->|"writes"| WDB
APP3 -->|"reads"| RDB1
endEffective scaling techniques for a monolith:
Horizontal scaling — run multiple instances behind a load balancer. This works well as long as the application is stateless (does not store state in memory). Go is very well suited to this because its goroutine-based concurrency model lets a single instance handle thousands of concurrent requests.
Read replicas — separate reads and writes at the database level. Write to the primary, read from replicas. This can eliminate most database bottlenecks without changing the application architecture at all.
Vertical scaling — add CPU and RAM. Not elegant, but effective and cheap in the early stages. It is often more cost-effective than the operational cost of microservices.
Aggressive caching — put Redis in front of frequently used queries. A single cache layer can remove 80–90% of the database load.
Signs a Monolith Needs to Evolve #
A monolith is not a permanent solution for every situation. There are concrete signs that it is time to evolve:
Technical signs:
□ Build time > 10 minutes — all developers wait on the same pipeline
□ Test suite > 30 minutes — the fast feedback loop is lost
□ The database has become a bottleneck that cannot be solved with caching or read replicas
□ One heavy feature (image processing, ML inference) slows the whole application
□ A memory leak in one module crashes the entire application
Organizational signs:
□ > 20 developers working on the same codebase, frequent merge conflicts
□ Team A has to wait for Team B to deploy because of one shared pipeline
□ Onboarding a new developer takes > 2 weeks just to understand the codebase
□ No team dares to change a certain module for fear of side effects
Signs that are NOT reasons to evolve:
□ "Microservices is the industry standard" — not a technical reason
□ "Our traffic will grow 100x next month" — traffic predictions are not current facts
□ "Our competitor uses microservices" — irrelevant to your problem
□ "One module has 2000 lines" — code length is not an architecture indicator
Strangler Fig: Gradual Migration from a Monolith #
When a monolith needs to evolve, the safest way is the Strangler Fig Pattern — extracting one module at a time, without a big bang rewrite:
flowchart TD
subgraph PHASE1["Phase 1: Identify extraction candidates"]
M1["Monolith\\n(all modules)"]
NOTE1["Inventory Module:\\n- high traffic\\n- most frequent bottleneck\\n- boundaries already clear"]
end
subgraph PHASE2["Phase 2: Create a new service, redirect traffic gradually"]
LB["API Gateway /\\nLoad Balancer"]
M2["Monolith\\n(without inventory routes)"]
IS["Inventory Service\\n(new)"]
LB -->|"GET /inventory/*"| IS
LB -->|"all other routes"| M2
end
subgraph PHASE3["Phase 3: The monolith accesses inventory via API"]
M3["Monolith"]
IS3["Inventory Service"]
M3 -->|"HTTP / gRPC"| IS3
Note3["Inventory module removed from the monolith"]
end
PHASE1 --> PHASE2 --> PHASE3The Strangler Fig allows:
- No downtime
- Rollback if there are problems
- The team can learn microservices on one module without big risk
- The extracted domain is already tested in production before becoming fully independent
Monolith vs Modular Monolith vs Microservices #
flowchart LR
M["Monolith\\n1 deployment\\n1 codebase\\ntight coupling"] -->|"+ boundary discipline"| MM["Modular Monolith\\n1 deployment\\n1 codebase\\nfirm boundaries"]
MM -->|"+ independent deployment\\n+ network boundary"| MS["Microservices\\nN deployments\\nN codebases\\nloose coupling"]| Dimension | Monolith | Modular Monolith | Microservices |
|---|---|---|---|
| Deployment | 1 unit | 1 unit | N independent units |
| Communication | Function calls | Function calls | Network (HTTP/gRPC) |
| Inter-module latency | Zero | Zero | 1–10ms+ per hop |
| Data consistency | ACID transactions | ACID transactions | Eventual consistency |
| Scaling | Per-application | Per-application | Per-service |
| Debugging | One log stream | One log stream | Distributed tracing needed |
| Team autonomy | Low | Moderate | High |
| Ops complexity | Very low | Very low | Very high |
| Best for teams | 1–10 developers | 5–30 developers | 20+ developers |
Anti-Patterns to Avoid #
// ✗ "Big Ball of Mud" — everything mixed into one file/handler
func handleRequest(w http.ResponseWriter, r *http.Request) {
// ✗ HTTP parsing
body, _ := io.ReadAll(r.Body)
// ✗ business logic directly in the handler
if len(body) == 0 {
http.Error(w, "empty body", 400)
return
}
// ✗ SQL directly in the handler
db.Exec("INSERT INTO orders (data) VALUES ($1)", body)
// ✗ email directly in the handler
smtp.SendMail("smtp.gmail.com:587", nil, "[email protected]",
[]string{"[email protected]"}, []byte("Order created"))
}
// ✓ Separate responsibilities — handler, service, repository
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
json.NewDecoder(r.Body).Decode(&req)
output, err := h.service.CreateOrder(r.Context(), req)
if err != nil {
writeError(w, 422, err.Error())
return
}
writeJSON(w, 201, output)
}
// ✗ Modules importing each other directly (circular or tight coupling)
// internal/order/service.go
package order
import "myapp/internal/user" // ✗ direct import into the user package
func (s *Service) CreateOrder(userID string) error {
userRepo := user.NewPostgresRepository(s.db) // ✗ coupling to a concrete implementation
user, _ := userRepo.FindByID(userID)
// ...
}
// ✓ Modules communicate via interfaces
package order
// UserValidator is the interface defined by the order module
type UserValidator interface {
ValidateExists(ctx context.Context, id string) error
}
type Service struct {
repo OrderRepository
userSvc UserValidator // ✓ interface — does not know the implementation
}
// ✗ Shared mutable global state between modules
var globalUserCache = map[string]*User{} // ✗ every module can modify this
// ✓ State is managed by the owning module, accessed through methods
type UserService struct {
cache sync.Map // ✓ state inside the struct, not global
}
Monolithic Architecture Review Checklist #
STRUCTURE:
□ Each module lives in a separate directory under internal/
□ Modules do not import each other directly — communication via interfaces
□ No circular dependencies between modules
□ Shared code lives in pkg/ or internal/shared/, not copy-pasted
BOUNDARIES:
□ Each module exposes interfaces, not concrete structs
□ Other modules depend on the module's interface, not its implementation
□ Cross-module data sharing goes through method calls with DTOs, not shared structs
DATABASE:
□ Per-module schemas are clearly defined (even with one database)
□ No complex cross-module JOINs (a sign of incorrect boundaries)
□ Migration files are organized and can run automatically
STATELESSNESS:
□ No in-memory state that cannot be shared between instances
□ Sessions use an external store (Redis, database) not in-memory
□ The application can scale horizontally without code changes
OBSERVABILITY:
□ Structured logging on all endpoints
□ Request IDs are propagated through all layers
□ A health check endpoint is available
□ Metrics (request rate, error rate, latency) are exported
TESTING:
□ Per-module unit tests do not require a database or HTTP server
□ Integration tests exist for the main flows
□ go test -race is run regularly
□ Test coverage is at least 70% for business logic
Summary #
- A monolith is not an inferior architecture — the choice between monolith and microservices is contextual, not about which is more “modern”; many hugely successful systems operate as monoliths.
- There are three monolith variants — Big Ball of Mud (unstructured), Traditional Monolith (layered but unbounded), and Modular Monolith (layered with firm boundaries); aim for the third.
- A monolith is faster in the early stages — no deployment coordination overhead, no inter-module network latency, simpler debugging, easier onboarding.
- Use the
internal/package to enforce boundaries — the Go compiler prevents imports from outside the parent directory; this is the most effective way to keep module boundaries.- Modules communicate via interfaces, not direct imports — when the Order module needs User data, it uses
UserServiceInterface, notuser.PostgresRepository; this is the first step toward independence.- Horizontal scaling is an often-overlooked solution — multiple instances behind a load balancer can handle very large loads; this is cheaper and simpler than microservices for most systems.
- Recognize the signs it is time to evolve — not from industry pressure, but from real pain: long build times, deployments blocking each other, bottlenecks that cannot be solved vertically.
- Use the Strangler Fig for gradual migration — extract one module at a time, redirect traffic gradually; no risky big bang rewrites.
- A Modular Monolith can be an end-game architecture — not every system needs microservices; a modular monolith with firmly kept boundaries can serve dozens of developers efficiently.
- Prioritize simplicity until there is a strong reason for complexity — every added architectural complexity must be paid for by real, measurable benefit; do not pay that cost before receiving the benefit.