why your go module isnt a monolith (and how to fix it if it is)

what is a go module, really?

first, let's clear up a common misconception. a go module is simply a collection of go packages stored in a file tree with a go.mod file at its root. it's designed to manage dependencies and versioning. by this definition alone, a go module isn't inherently a monolith. it's a unit of distribution and versioning, not a statement about your application's architecture.

however, the way we structure our code within that module can absolutely lead to a monolithic architecture. this happens when we tightly couple all our application's components—like your http handlers, business logic, and data access code—into a single, indivisible unit.

signs your go module might be a monolith

how can you tell if your project has crossed the line? here are some key warning signs:

  • giant main.go file: your main function is hundreds of lines long, handling everything from database connections to http routing.
  • tight coupling: your business logic packages directly import and depend on database or http-specific packages. changing your database would require rewriting huge chunks of your application.
  • everything is in "internal": you're using the internal directory pattern, but it's become a giant catch-all for your entire application, making code reuse impossible.
  • long build times: a small change in one part of the codebase triggers a rebuild of the entire application.

the power of a multi-module workspace

starting with go 1.18, the introduction of workspaces is a game-changer for fighting monoliths. a workspace allows you to work on multiple modules simultaneously without needing to publish them to a version control system first. this is perfect for a clean, multi-repo or multi-module architecture.

imagine you're building a full-stack application with a separate frontend and api. instead of one giant module, you can structure your project like this:

my-app/
├── go.work
├── api/
│   ├── go.mod
│   ├── main.go
│   └── internal/
└── web/
    ├── go.mod
    ├── main.go
    └── internal/

your go.work file would use the use directive to include both modules:

go 1.21

use (
    ./api
    ./web
)

now, you can develop the api and web modules independently, but the go toolchain treats them as a single unit when you're inside the workspace. this is a foundational step for modern devops practices, enabling independent testing, building, and deployment.

refactoring strategy: the dependency inversion principle

let's look at a practical code example. a monolithic pattern often directly calls a database from business logic.

before (tightly coupled monolith):

// package myapp/database
package database

func getuser(id int) (user, error) {
    // directly interacts with postgresql
    err := db.queryrow("select ...", id).scan(...)
    // ...
}
// package myapp/handlers
package handlers

import "myapp/database"

func getuserhandler(w http.responsewriter, r *http.request) {
    // tightly coupled to the specific database package
    user, err := database.getuser(1)
    // ...
}

after (using interfaces for loose coupling):

first, define an interface in a central, neutral package (like myapp/entities). this is key for clean code architecture.

// package myapp/entities
package entities

type user struct {
    id   int
    name string
}

// userrepository defines the contract for storing and retrieving users.
// the business logic depends on this abstract interface, not a concrete implementation.
type userrepository interface {
    getuser(id int) (user, error)
}
// package myapp/database
package database

import "myapp/entities"

// postgresrepo implements the userrepository interface.
type postgresrepo struct {
    db *sql.db
}

func (r *postgresrepo) getuser(id int) (entities.user, error) {
    // implementation details for postgresql
}

// newuserrepository is a factory function to get the implementation.
// this is the only place that knows about the concrete postgres type.
func newuserrepository(db *sql.db) entities.userrepository {
    return &postgresrepo{db: db}
}
// package myapp/handlers
package handlers

import "myapp/entities"

func getuserhandler(repo entities.userrepository) http.handlerfunc {
    return func(w http.responsewriter, r *http.request) {
        // now, this handler only depends on the abstract interface.
        // we can easily swap the implementation for testing or for using a different database!
        user, err := repo.getuser(1)
        // ...
    }
}

this refactoring, guided by the dependency inversion principle, makes your code flexible, testable (you can easily create a mock userrepository), and ready to be split into separate modules if needed.

when (and how) to break a module apart

if your single module has become too large, it's time to split. a good candidate for a new module is a cohesive package or set of packages that:

  • has a well-defined, single purpose (e.g., user-auth, payment-processor).
  • could be useful in other projects.
  • has a different release cycle from your main application.

to split it out, you can use the go mod init command in a new directory and then update your main module's go.mod file to require the new local module using a replace directive during development. this approach is crucial for managing complex coding projects and is a staple of scalable software engineering.

conclusion: embrace modular thinking

remember, your go module isn't your enemy. the enemy is the temptation to create tightly coupled, inseparable code. by leveraging workspaces, adhering to design principles like dependency inversion, and knowing when to split code into separate modules, you can build systems that are:

  • easier to maintain and test
  • ready for independent deployment (a core devops goal)
  • scalable and flexible for future changes

start small. identify one tightly coupled package in your project and apply these principles. you'll quickly see how a modular approach makes your full-stack development in go more enjoyable and productive.

Comments

Discussion

Share your thoughts and join the conversation

Loading comments...

Join the Discussion

Please log in to share your thoughts and engage with the community.