Introduction

Microservices get all the attention, but for most teams and projects, a well-structured monolith is the better choice. I learned this the hard way after prematurely splitting a codebase into microservices, only to spend months untangling the resulting complexity.

When I rebuilt the platform from scratch, I chose a different approach: a modular monolith. The application — Forge Hub — serves as both a portfolio and a production blog platform, and its architecture reflects the lessons I've learned about building maintainable Go applications.

What is a Modular Monolith?

A modular monolith is a single deployment unit with clearly defined internal boundaries. Each module has its own domain, data model, and explicit public interface. Internally, the code is as decoupled as microservices — but it compiles into one binary and runs as one process.

+------------------------------------------------------------------+
|                     HTTP Server (Fiber)                          |
+------------------------------------------------------------------+
|  +------------+  +------------+  +----------+                     |
|  |   Auth     |  |   Blog     |  |   CMS    |                     |
|  |   Module   |  |   Module   |  |  Module  |                     |
|  +-----+------+  +-----+------+  +----+-----+                     |
|        |               |              |                           |
|  +-----+---------------+---------------+----------+              |
|  |                   Shared Kernel                 |              |
|  |        (DB, Middleware, Template Engine)         |              |
|  +--------------------------------------------------+              |
+--------------------------------------------------------------------+

The benefits over a traditional monolith:

  • Cognitive load — Developers understand one module at a time, not the entire codebase
  • Parallel development — Teams can work on different modules without conflicts
  • Easier testing — Modules can be tested in isolation
  • Migration path — If a module genuinely needs to become a microservice, the boundary already exists

Directory Structure

The project is organized by domain, not by technical layer. Each domain folder owns its handlers, models, and business logic — the shared kernel only holds what genuinely crosses all modules:

.
├── auth/
│   ├── handlers.go        # Login, logout, 2FA endpoints
│   ├── middleware.go      # Session validation
│   ├── models.go          # User, Session types
│   └── service.go         # Auth business logic
├── blog/
│   ├── handlers.go        # Post CRUD, public rendering
│   ├── models.go          # Post, Tag types
│   └── service.go         # Slug generation, content processing
├── cms/
│   ├── handlers.go        # Admin panel endpoints
│   ├── models.go          # Settings, Projects, Services
│   └── service.go         # CMS business logic
├── analytics/
│   ├── handlers.go        # Stats endpoints
│   ├── middleware.go      # Page view tracking
│   └── models.go          # PageView, DailyStat types
└── shared/
    ├── database/          # DB initialization and migrations
    ├── config/            # Session store, app configuration
    ├── templates/         # Base layouts and shared partials
    ├── static/            # CSS, images, client-side assets
    └── utils/             # Email, helpers used across modules

This is the key structural discipline: auth doesn't import from blog, and blog doesn't import from cms. All cross-module communication goes through the shared kernel or explicit interfaces. If a module's folder gets large, it splits internally — never by borrowing from a sibling.

The Middleware Chain

Every request flows through a middleware pipeline before reaching its handler. This is where the modular monolith really shines — each middleware is a composable unit that can be tested independently.

// main.go — Middleware registration order matters

// 1. Global data injection (footer services, user state)
app.Use(middleware.InjectGlobalData())

// 2. Analytics tracking
app.Use(middleware.TrackPageView())

// 3. Layout switching (public vs admin templates)
app.Use(middleware.DynamicLayoutMiddleware(engine))

// 4. Auth guard (admin routes only)
admin := app.Group("/admin", middleware.RequireAdminAuth)

The InjectGlobalData middleware demonstrates how shared state is assembled without coupling modules:

func InjectGlobalData() fiber.Handler {
    return func(c *fiber.Ctx) error {
        // Load shared data once per request
        c.Locals("SiteName", getSetting("site_name"))
        c.Locals("Services", loadFooterServices())
        c.Locals("CurrentYear", time.Now().Year())
        return c.Next()
    }
}

Why Go?

I chose Go for three reasons: performance, simplicity, and deployment.

Performance

Go compiles to a single native binary with no runtime dependencies. For a portfolio site, this means:

  • Cold start in <10ms — No JVM warmup, no interpreter startup
  • ~15MB memory — The entire application, including template engine and database driver
  • 10,000+ concurrent connections — Goroutines handle this naturally

Simplicity

Go's standard library is remarkably complete. The net/http package handles most HTTP concerns, and Fiber provides a familiar Express-like API on top of it.

Static typing catches entire classes of bugs at compile time. I can refactor a model struct and know immediately if every reference is updated.

Deployment

# Multi-stage Docker build
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o forge .

FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/forge .
COPY --from=builder /app/templates ./templates
COPY --from=builder /app/static ./static
EXPOSE 3031
CMD ["./forge"]

Final image size: ~25MB including the compiled binary, templates, and static assets.

Template Architecture

Go's html/template is often criticized, but it's powerful when used correctly. The key insight is using a layout engine that supports blocks and inheritance.

Base Layout

<!-- templates/layouts/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{.Title}} — {{.SiteName}}</title>
    <link rel="stylesheet" href="/static/css/main.css">
    {{block "head" .}}{{end}}
</head>
<body>
    {{block "header" .}}{{end}}
    <main>{{embed}}</main>
    {{block "footer" .}}{{end}}
    <script src="/static/js/main.js"></script>
    {{block "scripts" .}}{{end}}
</body>
</html>

Template Function Map

Custom template functions eliminate logic from templates:

engine.AddFunc("add", func(a, b int) int { return a + b })
engine.AddFunc("seq", func(n int) []int {
    s := make([]int, n)
    for i := range s {
        s[i] = i + 1
    }
    return s
})
engine.AddFunc("parseJSON", utils.ParseJSON)
engine.AddFunc("colorClass", func(i int) string {
    colors := []string{"primary", "secondary", "accent", "info"}
    return colors[i%len(colors)]
})

Database Layer

GORM provides an excellent ORM for Go, but its real value is in hooks and auto-migration.

Model Hooks

func (p *Post) BeforeCreate(tx *gorm.DB) (err error) {
    if p.ID == uuid.Nil {
        p.ID = uuid.New()
    }
    if p.Slug == "" {
        p.Slug = slugify(p.Title)
    }
    if p.ContentText == "" && p.Content != "" {
        p.ContentText = StripMarkdown(p.Content)
    }
    return
}

func (p *Post) BeforeUpdate(tx *gorm.DB) (err error) {
    if p.Content != "" {
        p.ContentText = StripMarkdown(p.Content)
    }
    return
}

Hooks ensure data consistency without cluttering business logic. The ContentText field is always populated before saving, regardless of which code path triggered the update.

Auto-Migration

db.AutoMigrate(
    &models.Post{},
    &models.User{},
    &models.Projects{},
    &models.Services{},
    &models.PageView{},
    &models.DailyStat{},
    &models.Setting{},
)

Auto-migration is convenient for a single-binary application, but it's not without risk. GORM's AutoMigrate only adds columns and indexes — it never drops or alter existing ones. That means it's safe for additive changes but won't handle renames or type changes. For those, you still need a manual migration. Know the boundary before relying on it in production.

Why SQLite in Production?

This is the question I get asked most. Running SQLite on a public-facing platform sounds reckless. It isn't, if you understand the constraints.

Forge Hub is a read-heavy, single-writer workload. Blog posts are written rarely, read constantly. SQLite's writer lock is never a bottleneck because there's only ever one writer: the admin panel, used by one person.

The performance case is real:

  • No network round-trip — Queries hit a local file, not a TCP socket to a remote server
  • No connection pool overhead — No idle connections, no pool exhaustion under burst traffic
  • WAL mode — Write-Ahead Logging allows concurrent reads during writes, eliminating the "one thing at a time" reputation
// Enable WAL mode on startup
db.Exec("PRAGMA journal_mode=WAL")
db.Exec("PRAGMA synchronous=NORMAL")
db.Exec("PRAGMA cache_size=-64000") // 64MB page cache
db.Exec("PRAGMA temp_store=MEMORY")

The trade-offs I've accepted:

  • Single node only — No horizontal scaling; if the server goes down, the site goes down
  • File-based backups — I run litestream for continuous replication to S3; a plain file copy during writes produces a corrupt database
  • Schema migrations — No ALTER COLUMN, no DROP COLUMN — plan your schema carefully upfront

When would I switch to PostgreSQL? When I need multiple writers, full-text search beyond FTS5, or a team sharing the database directly. None of those apply here.

Real-World Performance

Under load testing with 500 concurrent users:

| Metric | Value | | :--- | :--- | | Requests/sec | 4,200 | | Avg response time | 12ms | | P99 response time | 45ms | | Memory usage | 22MB | | CPU usage | 35% |

The bottleneck under sustained write load is SQLite I/O, which is expected and acceptable for this workload. Read performance is excellent — WAL mode means concurrent reads never block each other.

When NOT to Build a Modular Monolith

Every architectural choice has trade-offs. A modular monolith might not be right if:

  • You need independent scaling — Different modules have dramatically different resource requirements
  • Your team is 20+ engineers — Coordination overhead becomes significant without service boundaries
  • You have polyglot requirements — Different modules genuinely benefit from different languages
  • Your deployment frequency is module-specific — You need to deploy the blog without touching the API

Conclusion

The modular monolith is an underappreciated architecture. It gives you most of the benefits of microservices — clear boundaries, independent testability, and a migration path — without the operational complexity of distributed systems.

For Forge Hub, this architecture means I can add new features, refactor existing code, and deploy with confidence — all from a single 25MB binary.

Start simple. If you genuinely need microservices later, the boundaries will already be there.


Built with Go and hosted on my home lab. Want to see the full codebase? Check out the project on GitHub or read my other posts about specific implementation details.