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:
- Create a new Goose migration file in
db/migrations/with a timestamp prefix (e.g.,20240115120000_addEmailColumn.sql) - Write the SQL for both
UpandDownoperations to ensure reversibility - Run
make migrate_schema_upto apply the change to your local database - Update the GORM model in
pkg/domain/model/to reflect the new schema (e.g., add anEmailfield) - Update repository methods if needed to handle the new columns
- 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
AutoMigrateobscures when changes occur - Reversibility: Goose requires
Downmigrations for rollbacks, whereasAutoMigratecannot automatically reverse column drops or type changes - Safety:
AutoMigratecan 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 inpkg/adapter/repository/ - No runtime migrations occur; the application assumes the schema already exists, enforced by the separation between
make migrate_schema_upand 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →