← Blog
/POST · ALFIE MILLS

A Modular Go Web App Pattern with Echo, GORM and golang-migrate

Every time I start a small Go service I find myself reaching for the same shape: Echo for HTTP, GORM for the database, and golang-migrate for schema versioning. I finally sat down and codified it into a pattern I can clone for any new domain. Here's how it's laid out.

The directory structure

The core idea is that each domain entity is a self-contained module. Everything that entity needs (routes, handlers, model, seed data) lives in one folder:

project-root/
├── main.go              # Entry point
├── Makefile             # Build, run, migration commands
├── config/
│   └── database.go      # DB initialization (global singleton)
├── migrations/
│   ├── 000001_create_pokemon.up.sql
│   └── 000001_create_pokemon.down.sql
└── modules/
    ├── routes.go        # Central route registration
    └── animal/
        ├── config.go    # Module route + seed registration
        ├── handler.go   # HTTP handlers
        ├── animal.go    # GORM model + DB operations
        └── seed.go      # Idempotent seed data

Adding a feature means adding a folder, not threading changes through five different files.

The entry point

main.go stays deliberately boring. Initialise the database, spin up Echo with logging and recovery middleware, register a health check, and hand off to the modules:

func main() {
    config.InitDatabase()

    e := echo.New()
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())

    e.GET("/health", func(c echo.Context) error {
        return c.JSON(200, map[string]string{"status": "healthy"})
    })

    modules.RegisterRoutes(e)
    e.Logger.Fatal(e.Start(":1323"))
}

The database is a global GORM singleton initialised once at startup. It's not the most testable choice (for that you'd inject the DB as a dependency), but for a small service the simplicity wins.

Migrations are SQL, not AutoMigrate

This is the decision I feel most strongly about. GORM ships with AutoMigrate, which inspects your structs and mutates the schema to match. It's convenient until it isn't, there's no clean rollback and no record of what changed when.

Instead I keep schema as plain, version-controlled SQL files with matching up/down pairs:

CREATE TABLE IF NOT EXISTS animals (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    created_at DATETIME,
    updated_at DATETIME,
    deleted_at DATETIME,
    name TEXT NOT NULL,
    species TEXT NOT NULL,
    age INTEGER DEFAULT 0,
    habitat TEXT
);

CREATE INDEX IF NOT EXISTS idx_animals_deleted_at ON animals(deleted_at);

Two things to note. The created_at / updated_at / deleted_at columns mirror what GORM's embedded gorm.Model expects, and indexing deleted_at keeps soft-delete queries fast, GORM appends WHERE deleted_at IS NULL to every read by default.

The Makefile wraps the migrate CLI so I never have to remember the flags:

DB_URL=sqlite3://app.db
MIGRATE=$(shell go env GOPATH)/bin/migrate

migrate-up:
    $(MIGRATE) -path migrations -database "$(DB_URL)" up

migrate-create:
    $(MIGRATE) create -ext sql -dir migrations -seq $(name)

make migrate-create name=create_widgets scaffolds a new up/down pair; make migrate-up applies everything pending.

The module pattern

A central hub registers each module, so wiring up a new one is a single line:

func RegisterRoutes(e *echo.Echo) {
    animal.Register(e.Group("/animals"))
    // Add new modules here
}

Each module's config.go seeds its data and declares its routes:

func Register(g *echo.Group) {
    seed()
    g.GET("", getAll)
    g.GET("/:id", getByID)
}

The model file holds the GORM struct and all database operations, keeping data access in one place:

type Animal struct {
    gorm.Model
    Name    string `json:"name"`
    Species string `json:"species"`
    Age     int    `json:"age"`
    Habitat string `json:"habitat"`
}

func GetAll() ([]Animal, error) {
    var animals []Animal
    result := config.DB.Find(&animals)
    return animals, result.Error
}

Handlers stay thin, bind input, call the model, return JSON. There's no service layer, and that's intentional. When a handler is just CRUD, a service layer is ceremony. I'll add one the moment business logic outgrows simple reads and writes, and not a minute before.

Idempotent seeding

Seeds run on every startup but only do work once:

func seed() {
    var count int64
    config.DB.Model(&Animal{}).Count(&count)
    if count > 0 {
        return
    }
    // ... insert seed rows
}

This means a fresh clone of the repo plus make migrate-up && make run gives you a working, populated API with zero manual steps.

Why bother codifying it

The point of pinning this down isn't the pattern itself, it's that I stop re-litigating these decisions every time. SQLite is the default driver here, but because data access is isolated in model files, swapping in Postgres is a config change rather than a rewrite. Adding a new entity is a five-step checklist: migration, SQL, run it, create the module, register it. That predictability is the whole value.