Running Database Migrations and Schema Management with GORM in Golang Clean Architecture

The manakuro/golang-clean-architecture repository uses Goose for version-controlled SQL migrations while leveraging GORM strictly for runtime database operations, ensuring explicit schema evolution and production safety.

This guide examines how the golang-clean-architecture project handles database migrations and schema management with GORM. Rather than relying on GORM's automatic migration features, the codebase implements a deliberate separation between schema evolution (managed by the Goose CLI) and data access (handled by GORM repositories).

Understanding the Hybrid Migration Architecture

The repository follows a dual-tool strategy that distinguishes between schema definition and runtime ORM operations. This approach prevents accidental schema changes in production while maintaining type-safe database interactions.

Goose handles all structural changes through versioned SQL files stored in db/migrations/. GORM manages connection pooling, query building, and struct-to-table mapping at runtime. This separation ensures that database schema changes are explicit, code-reviewed, and reversible.

Setting Up the GORM Database Connection

The database connection is established in pkg/infrastructure/datastore/db.go, where the NewDB function constructs a MySQL DSN from environment configuration and returns a *gorm.DB instance.

package main

import (
    "golang-clean-architecture/pkg/infrastructure/datastore"
    "golang-clean-architecture/pkg/adapter/repository"
)

func main() {
    db := datastore.NewDB() // Returns *gorm.DB configured for MySQL
    
    // Inject into repository layer
    userRepo := repository.NewUserRepository(db)
    // Repository is now ready for CRUD operations
}

The connection function handles DSN formatting, connection pooling, and GORM configuration without executing any schema modifications.

Managing Schema Changes with Goose Migrations

Schema evolution is managed entirely through Goose migration files located in db/migrations/. These SQL files are timestamped and version-controlled, providing an immutable history of database changes.

Creating Migration Files

Migration files follow the Goose annotation format with explicit Up and Down sections. The following example from db/migrations/20190802195721_createUsers.sql creates the users table that maps to the GORM model:

-- +goose Up
-- +goose StatementBegin
CREATE TABLE users (
   id INT NOT NULL AUTO_INCREMENT,
   name varchar(255) DEFAULT NULL COMMENT 'user name',
   age varchar(255) DEFAULT NULL COMMENT 'age',
   created_at datetime DEFAULT NULL COMMENT 'created at',
   updated_at datetime DEFAULT NULL COMMENT 'updated at',
   deleted_at timestamp NULL DEFAULT NULL COMMENT 'deleted at',
   INDEX user_id (id),
   PRIMARY KEY(id)
) ENGINE = InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='user';
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP TABLE users;
-- +goose StatementEnd

Running Migrations via Make

The Makefile provides convenience targets for executing Goose commands without memorizing CLI flags:


# Create the MySQL database (runs ./bin/init_db.sh)

make setup_db

# Apply all pending up-migrations

make migrate_schema_up

# Roll back the last migration

make migrate_schema_down

# Reset the schema (down then up)

make migrate_schema_reset

These commands ensure that schema changes happen explicitly during deployment rather than automatically at application startup.

Mapping GORM Models to Database Tables

Domain models in pkg/domain/model/user.go define GORM structs that map to tables created by Goose migrations. The User struct includes GORM tags for column mapping and soft-delete support:

package model

import "time"

type User struct {
    ID        uint       `gorm:"primary_key" json:"id"`
    Name      string     `gorm:"column:name" json:"name"`
    Age       string     `gorm:"column:age" json:"age"`
    CreatedAt time.Time  `gorm:"column:created_at" json:"created_at"`
    UpdatedAt time.Time  `gorm:"column:updated_at" json:"updated_at"`
    DeletedAt *time.Time `gorm:"column:deleted_at" sql:"index" json:"deleted_at"`
}

The struct fields align exactly with the SQL schema defined in the Goose migration, ensuring type safety without runtime schema modification.

Implementing the Repository Layer

The repository implementation in pkg/adapter/repository/user.go receives the GORM instance via NewUserRepository and provides CRUD operations. No automatic migration occurs at runtime—the repository assumes the schema already exists:

package repository

import (
    "golang-clean-architecture/pkg/domain/model"
    "gorm.io/gorm"
)

type UserRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) FindAll() ([]*model.User, error) {
    var users []*model.User
    result := r.db.Find(&users)
    return users, result.Error
}

func (r *UserRepository) Create(user *model.User) (*model.User, error) {
    result := r.db.Create(user)
    return user, result.Error
}

This design enforces the separation between schema management (Goose) and data access (GORM).

Development Workflow for Database Changes

When modifying the database schema in this codebase, follow this explicit workflow:

  1. Create a new Goose migration file in db/migrations/ with a timestamp prefix (e.g., 20240115120000_addEmailColumn.sql)
  2. Write the SQL for both Up and Down operations to ensure reversibility
  3. Run make migrate_schema_up to apply the change to your local database
  4. Update the GORM model in pkg/domain/model/ to reflect the new schema (e.g., add an Email field)
  5. Update repository methods if needed to handle the new columns
  6. Commit both the SQL migration and the code changes to maintain synchronization between schema and models

Why Avoid GORM AutoMigrate in Production

The repository deliberately avoids GORM's AutoMigrate feature for several critical reasons:

  • Explicit control: Goose migrations provide a clear, versioned history of every schema change with timestamps and checksums, while AutoMigrate obscures when changes occur
  • Reversibility: Goose requires Down migrations for rollbacks, whereas AutoMigrate cannot automatically reverse column drops or type changes
  • Safety: AutoMigrate can drop columns or constraints unexpectedly if struct tags change, making it risky for production environments
  • Reviewability: SQL migration files can be reviewed in pull requests by database administrators, while code-based auto-migration hides logic in compiled binaries

Summary

  • Goose CLI handles all schema migrations through versioned SQL files in db/migrations/, providing explicit, reversible database changes
  • GORM manages database connections via datastore.NewDB() and provides the ORM layer for repository implementations in pkg/adapter/repository/
  • No runtime migrations occur; the application assumes the schema already exists, enforced by the separation between make migrate_schema_up and application startup
  • Development workflow involves creating Goose migration files, applying them via Make targets, then updating GORM models in pkg/domain/model/ to match

Frequently Asked Questions

What is the difference between Goose and GORM AutoMigrate?

Goose is a database migration tool that uses versioned SQL files to explicitly define schema changes with Up and Down commands, while GORM AutoMigrate automatically creates or updates database tables based on Go struct definitions at runtime. The golang-clean-architecture repository uses Goose to ensure migrations are explicit, reversible, and reviewable, avoiding the risks of automatic schema modifications in production.

How do I add a new column to the database in this architecture?

Create a new Goose migration file in db/migrations/ with a timestamp prefix (e.g., 20240115120000_add_column.sql), write the ALTER TABLE statement in the -- +goose Up section and the corresponding rollback in -- +goose Down, then run make migrate_schema_up. Finally, update the corresponding GORM model in pkg/domain/model/ to include the new field with appropriate tags.

Is it safe to run migrations automatically when the application starts?

No, the repository explicitly avoids running migrations at application startup. The NewUserRepository function and other repository constructors expect the schema to already exist, and migrations must be applied manually via make migrate_schema_up or the Goose CLI before starting the application. This prevents race conditions, partial migrations, and accidental schema changes during deployments.

How does the repository handle database seeding for development?

The repository includes a dedicated seeding mechanism separate from migrations. Running make seed executes a Go program (typically located in cmd/seed/main.go) that uses the GORM repository layer to insert sample data into the existing schema. This approach requires the schema to be migrated first via make migrate_schema_up, maintaining the separation between schema structure and data population.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →