Lessons from a Monolith: Refactoring a Go Web App for Scale & Sanity

# Lessons from a Monolith: Refactoring a Go Web App for Scale & Sanity

Every developer has one: a project that starts small and rapidly grows beyond its initial design. For me, that project was Operation Corporation, a persistent browser-based business game. It was fully functional, with a concurrent game engine, secure authentication, and a dynamic forum system. It worked, and I was proud of it.

But under the hood, a problem was brewing. The entire backend—every handler, database query, and helper function—was living in a single, massive main.go file. It was becoming a ticking time bomb for technical debt.

Adding new features was slow, debugging was a nightmare, and the thought of another developer ever trying to understand the code was terrifying. It was time for a great refactor.

The Goals: From a Script to an Architecture

My goal wasn't just to move code around. I wanted to rebuild the foundation with professional software engineering principles in mind. I set out with four clear objectives:

  • Separation of Concerns: Each part of the application (database logic, HTTP handling, configuration) should be independent and responsible for only one thing.
  • Dependency Injection: Eliminate global variables (like var db *sql.DB) and instead provide dependencies explicitly to the parts of the app that need them.
  • Maintainability: A logical folder structure that makes it easy to find code and understand the application's flow.
  • A Robust Error System: A centralized way to handle errors gracefully, log them properly, and show user-friendly custom error pages.

The Cornerstone: Dependency Injection with an Application Struct

The biggest architectural shift was moving away from global variables. In the old main.go, the database connection and session manager were available everywhere, which can lead to messy, unpredictable code.

The solution was to create a central Application struct. This struct's job is to hold all the shared "dependencies" of my application.

// In /internal/handlers/handlers.go
package handlers

// ... imports ...

type Application struct {
    DB             *sql.DB
    Logger         *log.Logger
    Session        *scs.SessionManager
    Config         *config.Config
    TemplateCache  map[string]*template.Template
    UserModel      *models.UserModel
    PostModel      *models.PostModel
    // ... other dependencies
}

Now, instead of handlers reaching out for a global db variable, the database connection pool (DB) is held within the app struct. All my handlers are now methods on this struct, giving them clean and explicit access to the dependencies they need.

A Place for Everything: The New Project Layout

To enforce a true separation of concerns, I adopted a standard, multi-package project structure. It immediately brought clarity and order to the codebase.

/danielpage.me/
|
|-- /cmd/web/         # Main application entry point
|
|-- /internal/
|   |-- /config/      # Configuration loading
|   |-- /database/    # Database models and methods
|   |-- /server/      # Handlers, routes, and middleware
|
|-- /pkg/             # Shared helper utilities (like my Slugify function)
|
|-- /ui/              # All UI assets (templates and static files)
|
|-- go.mod

This layout isn't just for looks; it creates clear boundaries. The database package knows nothing about HTTP, and the server package doesn't know the specifics of how the database queries are written. This is the key to building maintainable software.

The Unsung Hero: The Middleware Chain

With a professional router like Chi, setting up a robust middleware chain became simple. Every single request to my application now passes through a series of checks before it ever reaches a page handler.

// in /internal/server/routes.go
func (app *Application) Routes() http.Handler {
	mux := chi.NewRouter()
	
	// These run on every request, in order.
	mux.Use(middleware.Recoverer)        // Catches panics
	mux.Use(app.Session.LoadAndSave)     // Manages sessions
	mux.Use(nosurf.New)                  // Protects against CSRF
	mux.Use(app.authenticationMiddleware)  // Checks who the user is

    // ... rest of my routes ...
	
	return csrfHandler
}

This chain provides panic recovery, session management, and CSRF protection for the entire application, making it secure by default.

Debugging the Refactor: Lessons from the Trenches

No major refactor is without its "gotchas," and I hit a couple of classics.

  1. The nil pointer dereference Panic: After getting everything wired up, clicking "Manage Blog Posts" caused a panic. The log was clear: the error was in my PostModel. The problem? In main.go, I had initialized the model without passing it the database connection pool: PostModel: &models.PostModel{}. The PostModel struct existed, but the DB field inside it was nil. A simple one-line fix—PostModel: &models.PostModel{DB: db}—solved it instantly. A great lesson in the importance of proper initialization.

  2. The Infinite Error Loop: The first time I hit an error, my server went crazy, logging thousands of lines of the same error: template "admin_layout.html" is undefined. I had created a perfect error handling system, but my template cache wasn't loading the error layouts themselves! This created an infinite loop: an error would occur, the server would try to render the error template, fail to find it, which would trigger another error, and so on. Fixing the NewTemplateCache function to parse all layouts and pages was the final key.

Was It Worth It?

Absolutely. The application is now a joy to work on. Adding new features (like tip of the day and competitions) was fast and predictable because the structure was sound. The codebase is clean, secure, and something I'm now incredibly proud to showcase.

This refactor wasn't just about cleaning up code; it was about investing in a professional foundation that will support the project for years to come.


0 Comments

Be the first to leave a comment!

Leave a Comment