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
VariantDefining TraitTeam Scalability
Big Ball of MudNo structure, everything coupled, nobody dares to change anything1–2 developers
Traditional MonolithHas layers (handler/service/repo) but modules are not bounded2–8 developers
Modular MonolithModules with firm boundaries, one deployment8–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 + coordination

This 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 the internal/ package in Go. Packages under the internal/ 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
    end

Effective 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 --> PHASE3

The 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"]
DimensionMonolithModular MonolithMicroservices
Deployment1 unit1 unitN independent units
CommunicationFunction callsFunction callsNetwork (HTTP/gRPC)
Inter-module latencyZeroZero1–10ms+ per hop
Data consistencyACID transactionsACID transactionsEventual consistency
ScalingPer-applicationPer-applicationPer-service
DebuggingOne log streamOne log streamDistributed tracing needed
Team autonomyLowModerateHigh
Ops complexityVery lowVery lowVery high
Best for teams1–10 developers5–30 developers20+ 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, not user.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.

← Previous: Layered   Next: Modular Monolith →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact