Chuyển đến nội dung chính

Lesson 10: Migrations & Repository Pattern

golang-migrate, Atlas migrations. Repository pattern, Service layer, Dependency Injection with Wire/Fx. Clean Architecture project structure.

💻 Programming — Lesson 10 Lesson 10: Migrations & Repository Pattern

Golang: From Basics to Advanced

Part 3: Database & Authentication

xdev.asia

1. Database Migrations

GORM AutoMigrate is convenient for development but Not suitable for production because it does not support rollback, does not track versions, and does not manage secure schema changes. Use specialized migration tools.

1.1. golang-migrate

# Cài đặt
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

# Hoặc brew (macOS)
brew install golang-migrate
# Tạo migration
migrate create -ext sql -dir migrations -seq create_users_table

# Tạo ra 2 files:
# migrations/000001_create_users_table.up.sql
# migrations/000001_create_users_table.down.sql
-- migrations/000001_create_users_table.up.sql
CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    email       VARCHAR(255) NOT NULL UNIQUE,
    password    VARCHAR(255) NOT NULL,
    role        VARCHAR(20) NOT NULL DEFAULT 'user',
    age         INTEGER DEFAULT 0,
    is_active   BOOLEAN DEFAULT TRUE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at  TIMESTAMPTZ
);

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
-- migrations/000001_create_users_table.down.sql
DROP TABLE IF EXISTS users;
-- migrations/000002_create_posts_table.up.sql
CREATE TABLE posts (
    id          BIGSERIAL PRIMARY KEY,
    title       VARCHAR(255) NOT NULL,
    slug        VARCHAR(255) NOT NULL UNIQUE,
    content     TEXT,
    published   BOOLEAN DEFAULT FALSE,
    author_id   BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at  TIMESTAMPTZ
);

CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_slug ON posts(slug);
CREATE INDEX idx_posts_published ON posts(published);
# Chạy migrations
migrate -path migrations -database "postgres://user:pass@localhost:5432/mydb?sslmode=disable" up

# Rollback 1 step
migrate -path migrations -database "..." down 1

# Go to specific version
migrate -path migrations -database "..." goto 3

# Xem version hiện tại
migrate -path migrations -database "..." version

# Force version (khi migration bị stuck)
migrate -path migrations -database "..." force 2

1.2. Run migrations in code

import (
    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/postgres"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

func RunMigrations(databaseURL string) error {
    m, err := migrate.New("file://migrations", databaseURL)
    if err != nil {
        return fmt.Errorf("create migrator: %w", err)
    }
    defer m.Close()
    
    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        return fmt.Errorf("run migrations: %w", err)
    }
    
    version, dirty, _ := m.Version()
    log.Printf("Migration version: %d, dirty: %v", version, dirty)
    
    return nil
}

1.3. Makefile for migrations

DB_URL=postgres://postgres:password@localhost:5432/mydb?sslmode=disable

.PHONY: migrate-create migrate-up migrate-down migrate-force

migrate-create:
	@read -p "Migration name: " name; \
	migrate create -ext sql -dir migrations -seq $$name

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

migrate-down:
	migrate -path migrations -database "$(DB_URL)" down 1

migrate-force:
	@read -p "Force version: " version; \
	migrate -path migrations -database "$(DB_URL)" force $$version

migrate-version:
	migrate -path migrations -database "$(DB_URL)" version

2. Repository Pattern

The Repository pattern separates data access logic from business logic, making code easy to test and maintain.

2.1. Repository Interface

// internal/repository/user_repository.go
package repository

import "context"

type UserRepository interface {
    Create(ctx context.Context, user *model.User) error
    GetByID(ctx context.Context, id uint) (*model.User, error)
    GetByEmail(ctx context.Context, email string) (*model.User, error)
    List(ctx context.Context, filter UserFilter) ([]model.User, int64, error)
    Update(ctx context.Context, user *model.User) error
    Delete(ctx context.Context, id uint) error
}

type UserFilter struct {
    Search   string
    Role     string
    IsActive *bool
    Page     int
    Limit    int
    SortBy   string
    SortDir  string
}

type PostRepository interface {
    Create(ctx context.Context, post *model.Post) error
    GetByID(ctx context.Context, id uint) (*model.Post, error)
    GetBySlug(ctx context.Context, slug string) (*model.Post, error)
    List(ctx context.Context, filter PostFilter) ([]model.Post, int64, error)
    Update(ctx context.Context, post *model.Post) error
    Delete(ctx context.Context, id uint) error
    GetByAuthor(ctx context.Context, authorID uint) ([]model.Post, error)
}

2.2. GORM Implementation

// internal/repository/gorm_user_repository.go
package repository

import (
    "context"
    "errors"
    "fmt"
    
    "gorm.io/gorm"
)

type gormUserRepository struct {
    db *gorm.DB
}

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

func (r *gormUserRepository) Create(ctx context.Context, user *model.User) error {
    return r.db.WithContext(ctx).Create(user).Error
}

func (r *gormUserRepository) GetByID(ctx context.Context, id uint) (*model.User, error) {
    var user model.User
    err := r.db.WithContext(ctx).
        Preload("Profile").
        First(&user, id).Error
    
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, ErrNotFound
    }
    return &user, err
}

func (r *gormUserRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) {
    var user model.User
    err := r.db.WithContext(ctx).
        Where("email = ?", email).
        First(&user).Error
    
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, ErrNotFound
    }
    return &user, err
}

func (r *gormUserRepository) List(ctx context.Context, filter UserFilter) ([]model.User, int64, error) {
    var users []model.User
    var total int64
    
    query := r.db.WithContext(ctx).Model(&model.User{})
    
    // Apply filters
    if filter.Search != "" {
        query = query.Where("name ILIKE ? OR email ILIKE ?",
            "%"+filter.Search+"%", "%"+filter.Search+"%")
    }
    if filter.Role != "" {
        query = query.Where("role = ?", filter.Role)
    }
    if filter.IsActive != nil {
        query = query.Where("is_active = ?", *filter.IsActive)
    }
    
    // Count total
    if err := query.Count(&total).Error; err != nil {
        return nil, 0, fmt.Errorf("count users: %w", err)
    }
    
    // Sorting
    sortBy := "created_at"
    if filter.SortBy != "" {
        sortBy = filter.SortBy
    }
    sortDir := "DESC"
    if filter.SortDir == "asc" {
        sortDir = "ASC"
    }
    query = query.Order(fmt.Sprintf("%s %s", sortBy, sortDir))
    
    // Pagination
    page := filter.Page
    if page < 1 { page = 1 }
    limit := filter.Limit
    if limit < 1 { limit = 20 }
    offset := (page - 1) * limit
    
    err := query.Offset(offset).Limit(limit).Find(&users).Error
    return users, total, err
}

func (r *gormUserRepository) Update(ctx context.Context, user *model.User) error {
    return r.db.WithContext(ctx).Save(user).Error
}

func (r *gormUserRepository) Delete(ctx context.Context, id uint) error {
    return r.db.WithContext(ctx).Delete(&model.User{}, id).Error
}

// Sentinel errors
var ErrNotFound = errors.New("record not found")

3. Service Layer

// internal/service/user_service.go
package service

import (
    "context"
    "fmt"
    
    "golang.org/x/crypto/bcrypt"
)

type UserService struct {
    repo repository.UserRepository
}

func NewUserService(repo repository.UserRepository) *UserService {
    return &UserService{repo: repo}
}

type CreateUserInput struct {
    Name     string `json:"name"     validate:"required,min=2,max=50"`
    Email    string `json:"email"    validate:"required,email"`
    Password string `json:"password" validate:"required,min=8"`
}

type UpdateUserInput struct {
    Name  *string `json:"name"  validate:"omitempty,min=2,max=50"`
    Email *string `json:"email" validate:"omitempty,email"`
    Age   *int    `json:"age"   validate:"omitempty,gte=0,lte=120"`
}

func (s *UserService) Create(ctx context.Context, input CreateUserInput) (*model.User, error) {
    // Check duplicate email
    existing, err := s.repo.GetByEmail(ctx, input.Email)
    if err == nil && existing != nil {
        return nil, fmt.Errorf("email already exists")
    }
    
    // Hash password
    hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
    if err != nil {
        return nil, fmt.Errorf("hash password: %w", err)
    }
    
    user := &model.User{
        Name:     input.Name,
        Email:    input.Email,
        Password: string(hashed),
        Role:     "user",
        IsActive: true,
    }
    
    if err := s.repo.Create(ctx, user); err != nil {
        return nil, fmt.Errorf("create user: %w", err)
    }
    
    return user, nil
}

func (s *UserService) GetByID(ctx context.Context, id uint) (*model.User, error) {
    return s.repo.GetByID(ctx, id)
}

func (s *UserService) List(ctx context.Context, filter repository.UserFilter) ([]model.User, int64, error) {
    return s.repo.List(ctx, filter)
}

func (s *UserService) Update(ctx context.Context, id uint, input UpdateUserInput) (*model.User, error) {
    user, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, err
    }
    
    if input.Name != nil { user.Name = *input.Name }
    if input.Email != nil { user.Email = *input.Email }
    if input.Age != nil { user.Age = *input.Age }
    
    if err := s.repo.Update(ctx, user); err != nil {
        return nil, fmt.Errorf("update user: %w", err)
    }
    
    return user, nil
}

func (s *UserService) Delete(ctx context.Context, id uint) error {
    return s.repo.Delete(ctx, id)
}

4. Dependency Injection

4.1. Manual DI (Constructor Injection)

// cmd/api/main.go
func main() {
    // Load config
    cfg := config.Load()
    
    // Connect database
    db, err := database.Connect(cfg.DatabaseURL)
    if err != nil {
        log.Fatal(err)
    }
    
    // Wire up dependencies (manual DI)
    userRepo := repository.NewGormUserRepository(db)
    postRepo := repository.NewGormPostRepository(db)
    
    userService := service.NewUserService(userRepo)
    postService := service.NewPostService(postRepo)
    authService := service.NewAuthService(userRepo, cfg.JWTSecret)
    
    userHandler := handler.NewUserHandler(userService)
    postHandler := handler.NewPostHandler(postService)
    authHandler := handler.NewAuthHandler(authService)
    
    // Setup router
    router := handler.NewRouter(userHandler, postHandler, authHandler)
    
    // Start server
    server := &http.Server{
        Addr:    ":" + cfg.Port,
        Handler: router,
    }
    
    log.Printf("Server starting on :%s", cfg.Port)
    log.Fatal(server.ListenAndServe())
}

4.2. Google Wire (Code Generation DI)

// Khi project lớn, manual DI phức tạp → dùng Wire

// go install github.com/google/wire/cmd/wire@latest

// internal/wire/wire.go
//go:build wireinject

package wire

import (
    "github.com/google/wire"
)

func InitializeApp(cfg *config.Config) (*App, error) {
    wire.Build(
        database.Connect,
        repository.NewGormUserRepository,
        repository.NewGormPostRepository,
        service.NewUserService,
        service.NewPostService,
        handler.NewUserHandler,
        handler.NewPostHandler,
        handler.NewRouter,
        NewApp,
    )
    return nil, nil
}

// Chạy: wire ./internal/wire/
// Tự generate wire_gen.go với dependency graph

5. Clean Architecture

┌─────────────────────────────────────────────┐
│                  HTTP Layer                  │
│  Handler → Parse Request → Call Service      │
│            → Format Response                 │
├─────────────────────────────────────────────┤
│               Service Layer                  │
│  Business Logic → Validation → Orchestrate   │
│  Depends on: Repository Interface            │
├─────────────────────────────────────────────┤
│             Repository Layer                 │
│  Data Access → GORM/SQL → Return Models     │
│  Implements: Repository Interface            │
├─────────────────────────────────────────────┤
│              Domain Models                   │
│  User, Post, Tag → No dependencies          │
└─────────────────────────────────────────────┘
my-api/
├── cmd/api/main.go               # Entry point + DI wiring
├── internal/
│   ├── config/config.go          # Configuration
│   ├── model/                    # Domain models
│   │   ├── user.go
│   │   └── post.go
│   ├── repository/               # Data access (interface + impl)
│   │   ├── interfaces.go         # Repository interfaces
│   │   ├── gorm_user.go          # GORM implementation
│   │   └── gorm_post.go
│   ├── service/                  # Business logic
│   │   ├── user_service.go
│   │   └── post_service.go
│   ├── handler/                  # HTTP handlers
│   │   ├── router.go
│   │   ├── user_handler.go
│   │   └── post_handler.go
│   ├── middleware/               # HTTP middleware
│   │   ├── auth.go
│   │   └── logger.go
│   └── dto/                     # Data Transfer Objects
│       ├── request.go
│       └── response.go
├── migrations/                   # SQL migrations
├── go.mod
├── Makefile
└── Dockerfile

6. Summary

  • Migrations: golang-migrate for version-controlled schema changes
  • Repository pattern: Interface + implementation, separate data access
  • Service layer: Business logic, validation, orchestration
  • Dependency Injection: Manual (small), Wire/Fx (large)
  • Clean Architecture: Handler → Service → Repository → Model

Next article: Authentication - JWT & OAuth2 — secure API with JWT tokens.