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

Bài 10: Migrations & Repository Pattern

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

💻 Lập trình — Bài 10 Bài 10: Migrations & Repository Pattern

Golang: Từ Cơ bản đến Nâng cao

Phần 3: Database & Authentication

xdev.asia

1. Database Migrations

GORM AutoMigrate tiện cho development nhưng không phù hợp production vì không hỗ trợ rollback, không track version, không quản lý schema changes an toàn. Dùng migration tool chuyên dụng.

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. Chạy migrations trong 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 cho 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

Repository pattern tách biệt data access logic khỏi business logic, giúp code dễ test và 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. Tổng kết

  • Migrations: golang-migrate cho version-controlled schema changes
  • Repository pattern: Interface + implementation, tách data access
  • Service layer: Business logic, validation, orchestration
  • Dependency Injection: Manual (nhỏ), Wire/Fx (lớn)
  • Clean Architecture: Handler → Service → Repository → Model

Bài tiếp theo: Authentication - JWT & OAuth2 — bảo mật API với JWT tokens.