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.