GoにおけるClean Architecture:保守性とスケーラビリティの高いGoアプリケーションを構築する

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

誰も触りたがらないコードベース

Goデベロッパーなら誰もがこの壁にぶつかる。プロジェクトに参加してリポジトリを開くと、800行もあるmain.goが待ち受けている。データベースクエリがビジネスロジックの隣に書かれ、HTTPハンドラーがSQLを直接呼び出している。あらゆるものが密結合しているため、テストを書くことはほぼ不可能だ。

私も経験した。「ちょっとした社内ツール」として始まったGoのサービスが、いつの間にか重要なシステムに成長していた。何かを変えると別の3箇所が壊れるため、誰も手を出したがらない。その経験があったからこそ、Clean Architectureは私にとって譲れない選択となった。

Goはシンプルさを推奨しており、それはGoの最大の長所の一つだ。しかし、そのシンプルさゆえに、開発者は初期段階での構造化をついサボりがちになる。後回しにすれば、後で代償を払うことになる——たいていはデッドライン直前か、深夜2時のインシデント対応中に

Goプロジェクトが崩壊する理由:根本原因

ほとんどのGoプロジェクトは同じパターンで崩壊する:

  • 神パッケージ:明確な所有権もなく何でも詰め込まれた、単一のutilsserviceパッケージ。
  • インフラ関心事の漏洩:ビジネスロジックがdatabase/sqlをインポートしたり特定のORMを直接呼び出したりすることで、実装の差し替えが不可能になる。
  • テスト不能なコード:実際のデータベース接続を開いたり、HTTPリクエストを直接送ったりする関数——テストをスキップするか、すべてに対して遅くて壊れやすい結合テストを書くしかない。
  • 循環依存:パッケージAがパッケージBをインポートし、パッケージBがパッケージAをインポートする。Goのコンパイラはこれを検出するが、機能開発の代わりに何時間も再構成に費やすことになる。

これは規律の失敗ではなく、構造の失敗だ。意図的なアーキテクチャがなければ、経験豊富な開発者でも密結合に流れてしまう——その瞬間は単純に速いから。

Clean Architectureと標準レイヤードアーキテクチャの違い

ほとんどのGoデベロッパーは基本的なレイヤードアプローチを知っている:ハンドラー → サービス → リポジトリ。何もないよりはましだが、内側のレイヤーが外側に依存することをまだ許してしまう。サービスが具体的なデータベース構造体をインポートするのは、この漏洩のよくある例だ。

Clean Architectureが強制するのは一つの厳格なルールだ:依存関係は内側のみに向かう。中心にあるビジネスロジックは、データベースやHTTP、外部フレームワークについて何も知らない。外側のレイヤー(HTTPハンドラー、データベースアダプター)が内側のレイヤーに依存する。その逆はない。

Goでは、実際にはレイヤーは次のように対応する:

  • エンティティ / ドメイン:純粋なGo構造体とビジネスルール。外部パッケージのインポートなし。
  • ユースケース:アプリケーション固有のビジネスロジック。エンティティとインターフェース定義にのみ依存する。
  • インターフェースアダプター:コントローラー(HTTPハンドラー)、プレゼンター、リポジトリ実装。
  • フレームワーク & ドライバー:最外層——データベースドライバー、HTTPルーター、外部API。

実践的なGoプロジェクト構造

以下は、Clean Architectureで構築するGoサービスに使用するディレクトリ構成だ:

myapp/
├── cmd/
│   └── api/
│       └── main.go          # エントリーポイント、全依存関係を組み立てる
├── internal/
│   ├── domain/
│   │   ├── user.go          # エンティティ:User構造体、ドメインエラー
│   │   └── user_repository.go  # インターフェース:UserRepository
│   ├── usecase/
│   │   ├── user_usecase.go  # ビジネスロジック
│   │   └── user_usecase_test.go
│   ├── adapter/
│   │   ├── http/
│   │   │   └── user_handler.go  # HTTPハンドラー
│   │   └── repository/
│   │       └── postgres_user_repo.go  # DB実装
│   └── infrastructure/
│       └── db.go            # データベース接続のセットアップ
└── go.mod

ステップ1:ドメインレイヤーを定義する

ドメインレイヤーには、コアエンティティとリポジトリインターフェースを配置する。ここでは外部インポートは許可されない。

// internal/domain/user.go
package domain

import "errors"

type User struct {
    ID    int64
    Name  string
    Email string
}

var (
    ErrUserNotFound    = errors.New("ユーザーが見つかりません")
    ErrDuplicateEmail  = errors.New("メールアドレスが既に存在します")
)

// internal/domain/user_repository.go
package domain

import "context"

type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*User, error)
    Save(ctx context.Context, user *User) error
}

UserRepositoryが構造体ではなくインターフェースであることに注目してほしい。具体的な実装はアダプターレイヤーに置く。このたった一つの設計上の選択が、全体をテスト可能にする鍵となる。

ステップ2:ユースケースレイヤーを作成する

ユースケースにはアプリケーションのビジネスルールを記述する。具体的な実装ではなく、ドメインインターフェースに依存する。

// internal/usecase/user_usecase.go
package usecase

import (
    "context"
    "myapp/internal/domain"
)

type UserUseCase struct {
    repo domain.UserRepository
}

func NewUserUseCase(repo domain.UserRepository) *UserUseCase {
    return &UserUseCase{repo: repo}
}

func (uc *UserUseCase) GetUser(ctx context.Context, id int64) (*domain.User, error) {
    user, err := uc.repo.FindByID(ctx, id)
    if err != nil {
        return nil, err
    }
    return user, nil
}

func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email string) (*domain.User, error) {
    user := &domain.User{Name: name, Email: email}
    if err := uc.repo.Save(ctx, user); err != nil {
        return nil, err
    }
    return user, nil
}

ステップ3:リポジトリアダプターを実装する

具体的なデータベース実装はアダプターレイヤーに置く。ドメインインターフェースをインポートしてそれを満たす形で実装する。

// internal/adapter/repository/postgres_user_repo.go
package repository

import (
    "context"
    "database/sql"
    "myapp/internal/domain"
)

type PostgresUserRepo struct {
    db *sql.DB
}

func NewPostgresUserRepo(db *sql.DB) *PostgresUserRepo {
    return &PostgresUserRepo{db: db}
}

func (r *PostgresUserRepo) FindByID(ctx context.Context, id int64) (*domain.User, error) {
    user := &domain.User{}
    err := r.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = $1", id,
    ).Scan(&user.ID, &user.Name, &user.Email)
    if err == sql.ErrNoRows {
        return nil, domain.ErrUserNotFound
    }
    return user, err
}

func (r *PostgresUserRepo) Save(ctx context.Context, user *domain.User) error {
    return r.db.QueryRowContext(ctx,
        "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
        user.Name, user.Email,
    ).Scan(&user.ID)
}

ステップ4:main.goで全体を組み立てる

依存性の注入は最外層——main.go——で行う。具体的な実装をここで生成し、内側に向かって注入していく。

// cmd/api/main.go
package main

import (
    "database/sql"
    "log"
    "net/http"

    _ "github.com/lib/pq"
    "myapp/internal/adapter/repository"
    httpAdapter "myapp/internal/adapter/http"
    "myapp/internal/usecase"
    "myapp/internal/infrastructure"
)

func main() {
    db, err := infrastructure.NewPostgresDB()
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // 依存関係を内側から外側に向けて組み立てる
    userRepo := repository.NewPostgresUserRepo(db)
    userUC := usecase.NewUserUseCase(userRepo)
    userHandler := httpAdapter.NewUserHandler(userUC)

    mux := http.NewServeMux()
    mux.HandleFunc("/users", userHandler.GetUser)

    log.Println("サーバーをポート:8080で起動します")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

真の効果:データベースなしでテストする

ここでアーキテクチャの真価が発揮される。UserUseCaseUserRepositoryインターフェースに依存しているため、テスト用のモックに差し替えることができる——実際のデータベースは不要だ。

// internal/usecase/user_usecase_test.go
package usecase_test

import (
    "context"
    "testing"
    "myapp/internal/domain"
    "myapp/internal/usecase"
)

type mockUserRepo struct {
    users map[int64]*domain.User
}

func (m *mockUserRepo) FindByID(_ context.Context, id int64) (*domain.User, error) {
    if u, ok := m.users[id]; ok {
        return u, nil
    }
    return nil, domain.ErrUserNotFound
}

func (m *mockUserRepo) Save(_ context.Context, user *domain.User) error {
    user.ID = int64(len(m.users) + 1)
    m.users[user.ID] = user
    return nil
}

func TestGetUser_NotFound(t *testing.T) {
    repo := &mockUserRepo{users: make(map[int64]*domain.User)}
    uc := usecase.NewUserUseCase(repo)

    _, err := uc.GetUser(context.Background(), 999)
    if err != domain.ErrUserNotFound {
        t.Errorf("ErrUserNotFoundを期待しましたが、%vが返りました", err)
    }
}

このテストはミリ秒単位で実行される。Dockerも不要。データベースのセットアップも不要。不安定なネットワーク通信も不要。ビジネスロジックに対する高速で信頼性の高いフィードバックだけがある。

よくある間違いとその回避策

5つの本番Goサービスにこのパターンを適用してきた経験から、最もよく見られる間違いを挙げる:

  • バリデーションをHTTPハンドラーに書く:「メールアドレスは一意でなければならない」「年齢は正の数でなければならない」といったビジネスルールのバリデーションは、ユースケースまたはドメインレイヤーに属する。ハンドラーは入力形式の検証のみを行い、ビジネスルールを強制すべきではない。
  • ユースケースからデータベースモデルを返す:ユースケースが生の*sql.RowやORMモデルを返している場合、インフラの関心事が上位に漏洩している。リポジトリの境界でドメイン構造体にマッピングすること。
  • 小規模サービスへの過剰設計:フルレイヤー化には実際のオーバーヘッドが伴う。別のAPIにプロキシするだけの3エンドポイントのマイクロサービスなら、この構造はおそらく過剰だ。コードベースが成長する場所に適用すること。
  • contextの伝播を省略する:すべてのレイヤーを通じて必ずcontext.Contextを渡すこと。リクエストのキャンセルとデッドライン伝播が可能になる——どちらも本番環境では重要だ。

この構造が最も効果を発揮するとき

この構造のセットアップコストが報われる3つのシナリオがある:

  1. データベースの切り替えPostgreSQLからMySQLへの移行や、Redisキャッシュレイヤーの追加は、アダプターレイヤーの変更のみで済む。ユースケースのコードはそのまま。
  2. 第二の配信メカニズムの追加REST APIの横にgRPCエンドポイントが必要になった場合、ユースケースレイヤーはそのまま再利用できる。新しいgRPCハンドラーを書くだけだ。
  3. 新メンバーのオンボーディング:明確な境界により、新しいコードがどこに属するかが一目瞭然になる。新しいチームメンバーは、関係のない部分を壊すリスクを抑えながら、より速く貢献できる。

初日はオーバーヘッドに感じる。しかし半年後、大きな機能をリリースしているときにテストスイートが10秒以内に完了する——そのとき、最初の苦労が完全に報われたと実感するだろう。

Share: