1. データベースの移行
GORM AutoMigrateは開発には便利ですが、 生産には適さない ロールバックをサポートしておらず、バージョンを追跡せず、安全なスキーマ変更を管理しないためです。専用の移行ツールを使用します。
1.1. golang-移行
# 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.コードで移行を実行する
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.移行用のメイクファイル
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. リポジトリパターン
リポジトリ パターンはデータ アクセス ロジックをビジネス ロジックから分離し、コードのテストと保守を容易にします。
2.1.リポジトリインターフェース
// 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の実装
// 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. サービス層
// 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. 依存性の注入
4.1.手動 DI (コンストラクター インジェクション)
// 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 (コード生成 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. クリーンなアーキテクチャ
┌─────────────────────────────────────────────┐
│ 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. まとめ
- 移行: バージョン管理されたスキーマ変更のための golang-merge
- リポジトリパターン: インターフェース + 実装、個別のデータアクセス
- サービス層: ビジネスロジック、検証、オーケストレーション
- 依存関係の注入:マニュアル(小)、ワイヤー/FX(大)
- クリーンなアーキテクチャ: ハンドラー → サービス → リポジトリ → モデル
次の記事: 認証 - JWT および OAuth2 — JWT トークンを使用した安全な API。