🐡

Go言語で学ぶクリーンアーキテクチャ~基本概念から実装まで~

2025/01/06に公開

基礎概念編

0. プログラミング初心者の方へ

この記事では、ソフトウェア設計の重要な考え方の一つである「クリーンアーキテクチャ」について解説します。最初は難しく感じるかもしれませんが、具体例を交えながら段階的に説明していきますので、ゆっくりと理解を深めていただければと思います。

アーキテクチャって何?

  • アーキテクチャ:プログラムの設計図や構造のこと(家の間取りのようなもの)

なぜアーキテクチャが重要なの?

例えば、料理を作るときのことを考えてみましょう

  • 整理された台所(良いアーキテクチャ)

    • 道具が整理されている
    • 材料の保管場所が決まっている
    • 作業スペースが確保されている
    • → スムーズに料理ができる!
  • 散らかった台所(悪いアーキテクチャ)

    • 道具がどこにあるかわからない
    • 材料が散らばっている
    • 作業スペースが狭い
    • → 料理が大変...

プログラムも同じです。良い設計(アーキテクチャ)があれば

  • 修正が簡単
  • 新しい機能の追加がスムーズ
  • バグ(不具合)の発見が容易
  • チームでの開発がしやすい

1. クリーンアーキテクチャとは

クリーンアーキテクチャの基本的な考え方

クリーンアーキテクチャは、「きれいで整理された設計」を実現するための方法論です。
以下のような特徴があります

  1. 部品を分けて考える

    • 例:家電製品の部品のように、プログラムの各部分を独立した部品として扱う
    • メリット:部品の交換や修理が簡単になる
  2. 重要な部分を中心に置く

    • 例:家の間取りで、リビングを中心に配置するように
    • メリット:最も大切な機能を守りやすくなる
  3. 変更の影響を最小限に抑える

    • 例:携帯電話のケースを変えても中身は影響を受けないように
    • メリット:一部を変更しても、全体に影響が及ばない

具体例:ECサイトで考えてみよう

オンラインショッピングサイトを例に、クリーンアーキテクチャの考え方を説明します

  1. 中心となる重要な部分(コア)

    • 商品の情報
    • 価格の計算ルール
    • 在庫管理のルール
      → これらは「ビジネスルール」と呼ばれます
  2. 周辺の部分(外側)

    • ウェブページのデザイン
    • データベースとの連携
    • 決済システムとの連携
      → これらは「技術的な詳細」と呼ばれます

ビジネスルールとは?

ビジネスルールは、そのサービスやシステムの「核となる仕組み」のことです。

  • スーパーマーケットの例

    • 商品の値引きルール(期限が近づいたら20%オフなど)
    • ポイントの付与ルール(100円で1ポイントなど)
    • 在庫の発注ルール(残り10個になったら追加発注するなど)
  • 銀行の例

    • 利息の計算方法
    • 口座間送金のルール
    • 残高不足時の処理ルール
  • ホテル予約システムの例

    • 部屋の料金計算ルール(シーズンごとの料金変更など)
    • キャンセルポリシー(3日前まで無料など)
    • 予約の重複チェックルール

これらのルールは、使用する技術に関係なく存在する重要なルールです。

技術的な詳細とは?

技術的な詳細は、ビジネスルールを「どのように実現するか」という具体的な方法のことです。

  • データの保存方法

    • MySQLデータベースを使う
    • ファイルに保存する
    • クラウドストレージを使う
  • ユーザーインターフェース

    • ウェブサイトとして表示
    • スマートフォンアプリとして表示
    • コマンドライン画面として表示
  • 外部システムとの連携

    • クレジットカード決済システム
    • メール送信システム
    • 地図表示システム

これらは、同じビジネスルールを実現するための手段であり、状況に応じて変更可能な部分です。

項目 ビジネスルール 技術的な詳細
意味 「何をするか」のルール 「どうやって実現するか」の方法
• 商品が3個以上で10%引き
• 在庫が10個以下で発注
• 会員登録には年齢確認が必要
• データをMySQLに保存
• スマホアプリで表示
• AWSで実行
特徴 • あまり変更されない
• サービスの根幹となる部分
• 比較的よく変更される
• 実装方法の選択肢

例:ECサイトの場合

ビジネスルール 技術的な詳細
1,000円以上の買い物で
100ポイント付与
ポイントをデータベースに
記録して画面に表示

なぜ分けて考えるの?

例えば、ECサイトの商品割引ルール(ビジネスルール)は、それがウェブサイトで表示されても、スマートフォンアプリで表示されても、同じように機能する必要があります。

ビジネスルールと技術的な詳細を分けることで

  • 新しい技術への対応が容易になる
  • システムの一部を変更しても、核となる部分は影響を受けない
  • テストがしやすくなる
  • チーム間での分業がしやすくなる

クリーンアーキテクチャでは、この「中心」と「周辺」をはっきりと分けて設計します。

設計編

1. クリーンアーキテクチャの層構造について

クリーンアーキテクチャでは、プログラムを「層(レイヤー)」と呼ばれる複数の階層に分けて設計します。これは、お菓子の箱のように、中身を層になって包み込むイメージです。

なぜ層に分けるの?

例えば、チョコレートの箱を考えてみましょう

  1. 一番内側:チョコレート本体(最も大切なもの)
  2. その周り:個包装(保護)
  3. その外側:仕切り(整理)
  4. 一番外側:箱(見た目、保護)

このように層に分けることで

  • 中身(チョコレート)を傷つけずに、箱のデザインを変更できる
  • 個包装を変えても、チョコレートの味は変わらない
  • 仕切りの配置を変えても、他の部分に影響しない

プログラムも同じように、重要な部分を層で包み込んで保護します。

クリーンアーキテクチャの4つの層

  1. エンティティ層(一番内側)

    • ビジネスの基本的なルールやデータ
      • 商品の定義(名前、価格、説明など)
      • ユーザーの定義(名前、メールアドレス、パスワードなど)
    • 特徴は、めったに変更されない、最も重要な部分
  2. ユースケース層(2番目)

    • 具体的な業務の流れ
      • 商品を注文する手順
      • ユーザー登録の手順
      • 在庫を確認する手順
    • 特徴は、アプリケーションの主要な機能を定義
  3. インターフェースアダプター層(3番目)

    • 外部とのやり取りを調整する部分
      • Webページからのデータの受け取り方
      • データベースへの保存方法
      • 外部サービスとの連携方法
    • 特徴は、内側の層と外側の層を「通訳」する役割
  4. フレームワーク&ドライバー層(一番外側)

    • 具体的な技術の詳細
      • データベースの種類(MySQL, PostgreSQLなど)
      • Webフレームワーク(Echo, Ginなど)
      • 外部サービス(決済システム、メール配信など)
    • 特徴は、最も変更されやすい部分

層と層の関係:依存関係の方向

重要なルール:「依存関係は常に内側に向かう」

例えて説明するのが難しいのですが、以下例で依存関係を説明します。

レストランのキッチンと注文システムの例

  1. 料理人とレシピ(内側:ビジネスルール)

    • ハンバーグの作り方
    • ソースの配合
    • 焼き加減の基準

    → これらは「どのシステムで注文を受けるか」は関係なく、常に同じ

  2. 注文を受ける方法(外側:技術的な詳細)

    • 店員が手書きで注文を取る
    • タブレットで注文を取る
    • 客が自分でスマホから注文する

    → これらは「料理の作り方」を知った上で機能する

重要なポイント

  • 料理の作り方(内側)は、注文方法(外側)が変わっても変化しない
  • 注文システム(外側)は、料理の内容(内側)を知っていないと機能できない

つまり、「内側のルールは外側の実装方法を気にしない」が、「外側の実装は内側のルールに従って動く」ということになります。

基本実装編

1. プロジェクト構造の解説

クリーンアーキテクチャに基づいたプロジェクト構造を以下のように設計します

myapp/
├── cmd/
│   └── main.go                    # アプリケーションのエントリーポイント
│
├── domain/                        # ドメイン層(中心の層)
│   ├── entity/
│   │   └── user.go               # ユーザーエンティティの定義
│   └── repository/
│       └── user.go               # リポジトリのインターフェース
│
├── usecase/                       # ユースケース層
│   └── user.go                   # ユーザー関連のユースケース実装
│
├── interface/                     # インターフェースアダプター層
│   ├── handler/
│   │   └── user.go              # HTTPハンドラー
│   └── repository/
│       └── user.go              # リポジトリの実装
│
└── infrastructure/               # インフラストラクチャ層(最も外側)
    ├── database.go              # データベース接続の管理
    └── server.go                # HTTPサーバーの設定

各ディレクトリの役割

  1. cmd/

    • アプリケーションのエントリーポイント
    • 依存関係の注入を行う
    • 各コンポーネントの初期化と接続
  2. domain/

    • アプリケーションの中心となるビジネスロジック
    • エンティティの定義(entity/
    • リポジトリのインターフェース(repository/
    • ビジネスルールやバリデーション
  3. usecase/

    • アプリケーション固有のビジネスロジック
    • ユースケースの実装
    • エンティティの操作とビジネスルールの適用
  4. interface/

    • 外部とのインターフェース
    • HTTPハンドラー(handler/
    • データベースアクセスの実装(repository/
  5. infrastructure/

    • 外部サービスとの接続
    • データベース設定
    • HTTPサーバー設定

2. 各層の詳細実装

2.1 エンティティ層の実装(Domain Layer)

エンティティ層は、ビジネスルールとデータ構造を定義する最も中心的な層です。

// domain/entity/user.go
package entity

import (
    "errors"
    "time"
    "regexp"
)

// User エンティティ
type User struct {
    ID        int
    Name      string
    Email     string
    CreatedAt time.Time
    UpdatedAt time.Time
}

// NewUser ユーザー作成のファクトリ関数
func NewUser(name, email string) (*User, error) {
    user := &User{
        Name:      name,
        Email:     email,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }
    
    if err := user.Validate(); err != nil {
        return nil, err
    }
    
    return user, nil
}

// ビジネスルール:メールアドレスの形式チェック
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)

// Validate ユーザーデータの検証
func (u *User) Validate() error {
    if len(u.Name) < 3 {
        return errors.New("name must be at least 3 characters")
    }
    
    if !emailRegex.MatchString(u.Email) {
        return errors.New("invalid email format")
    }
    
    return nil
}

// UpdateEmail メールアドレス更新のビジネスロジック
func (u *User) UpdateEmail(newEmail string) error {
    if !emailRegex.MatchString(newEmail) {
        return errors.New("invalid email format")
    }
    
    u.Email = newEmail
    u.UpdatedAt = time.Now()
    return nil
}

// UpdateName 名前更新のビジネスロジック
func (u *User) UpdateName(newName string) error {
    if len(newName) < 3 {
        return errors.New("name must be at least 3 characters")
    }
    
    u.Name = newName
    u.UpdatedAt = time.Now()
    return nil
}

エンティティ層でのポイント

  • ビジネスルールをメソッドとして実装
  • 入力値の検証ロジックを含む
  • 外部依存を持たない純粋なビジネスロジック

エンティティ層は、アプリケーションの「基本的なルール」を定義する場所です

  1. ビジネスルールをメソッドとして実装

    • 「ユーザー名は3文字以上必要」というルールをValidate()メソッドとして実装
    • 「メールアドレスの形式チェック」をUpdateEmail()メソッドとして実装
    • これらは「システムの基本的なルール」を表現しています
  2. 入力値の検証ロジック

    • 不正なデータが入力されないようにチェックする仕組み
    • 例:メールアドレスが正しい形式か、名前が短すぎないかなど
    • これは「データの品質を保つための基本ルール」です
  3. 外部依存を持たない

    • データベースやWebの仕組みに依存しない設計
    • つまり、「データをどこに保存するか」とは関係なく機能する
    • 純粋なビジネスルールだけを扱う

2.2 リポジトリインターフェースの定義(Domain Layer)

リポジトリは、データの永続化に関する抽象的なインターフェースを提供します。

// domain/repository/user.go
package repository

import (
    "github.com/your-username/myapp/domain/entity"
    "errors"
)

// UserRepository ユーザーデータの永続化インターフェース
type UserRepository interface {
    Create(user *entity.User) error
    FindByID(id int) (*entity.User, error)
    FindByEmail(email string) (*entity.User, error)
    FindAll() ([]*entity.User, error)
    Update(user *entity.User) error
    Delete(id int) error
}

// カスタムエラーの定義
var (
    ErrUserNotFound = errors.New("user not found")
    ErrDuplicateEmail = errors.New("email already exists")
    ErrInvalidUser = errors.New("invalid user data")
)

リポジトリインターフェースのポイント

  • データベースに依存しない抽象的な操作を定義
  • ドメイン固有のエラーを定義
  • 基本的なCRUD操作をカバー

リポジトリは「データの保存方法」を抽象的に定義します

  1. 抽象的な操作を定義

    • 「保存する」「検索する」などの基本操作を定義
    • 具体的な保存方法(MySQLやMongoDBなど)は指定しない
    • これにより、後で保存方法を変更しやすくなる
  2. ドメイン固有のエラー

    • システム特有のエラー状況を定義
    • 例:「ユーザーが見つからない」「メールアドレスが重複している」など
    • これにより、エラーの種類を明確に区別できる

2.3 ユースケース層の実装(Usecase Layer)

ユースケース層では、具体的なビジネスロジックのフローを実装します。

// usecase/user.go
package usecase

import (
    "github.com/your-username/myapp/domain/entity"
    "github.com/your-username/myapp/domain/repository"
)

// UserUsecase ユーザー関連のユースケース
type UserUsecase struct {
    userRepo repository.UserRepository
}

// NewUserUsecase ユースケースの生成
func NewUserUsecase(repo repository.UserRepository) *UserUsecase {
    return &UserUsecase{
        userRepo: repo,
    }
}

// RegisterUser 新規ユーザー登録のユースケース
func (u *UserUsecase) RegisterUser(name, email string) error {
    // メールアドレスの重複チェック
    existingUser, err := u.userRepo.FindByEmail(email)
    if err != nil && err != repository.ErrUserNotFound {
        return err
    }
    if existingUser != nil {
        return repository.ErrDuplicateEmail
    }
    
    // 新規ユーザーの作成
    user, err := entity.NewUser(name, email)
    if err != nil {
        return err
    }
    
    // ユーザーの保存
    return u.userRepo.Create(user)
}

// GetUser ユーザー取得のユースケース
func (u *UserUsecase) GetUser(id int) (*entity.User, error) {
    user, err := u.userRepo.FindByID(id)
    if err != nil {
        return nil, err
    }
    return user, nil
}

// UpdateUserEmail メールアドレス更新のユースケース
func (u *UserUsecase) UpdateUserEmail(id int, newEmail string) error {
    // 既存ユーザーの取得
    user, err := u.userRepo.FindByID(id)
    if err != nil {
        return err
    }
    
    // メールアドレスの更新
    if err := user.UpdateEmail(newEmail); err != nil {
        return err
    }
    
    // 更新の保存
    return u.userRepo.Update(user)
}

ユースケース層のポイント

  • ビジネスロジックのフローを制御
  • エンティティとリポジトリを組み合わせて使用
  • 具体的なユースケースごとにメソッドを定義

ユースケース層は「具体的な業務の流れ」を実装します

  1. ビジネスロジックのフロー制御

    • 例:「新規ユーザー登録」の手順
      1. メールアドレスの重複チェック
      2. ユーザー情報の検証
      3. データの保存
    • 具体的な「業務の手順」を表現
  2. エンティティとリポジトリの組み合わせ

    • エンティティ(基本ルール)とリポジトリ(データ保存)を組み合わせて
    • 実際の業務フローを作り上げる

2.4 インターフェースアダプター層 - ハンドラーの実装(Interface Layer)

HTTPリクエストを処理し、ユースケースとの橋渡しを行います。

// interface/handler/user.go
package handler

import (
    "net/http"
    "strconv"
    "github.com/your-username/myapp/usecase"
    "github.com/your-username/myapp/domain/repository"
    "github.com/labstack/echo/v4"
)

var (
    errInvalidRequest = "Invalid request"
    errInternalServer = "Internal server error"
)

// UserHandler HTTPハンドラー
type UserHandler struct {
    userUsecase *usecase.UserUsecase
}

// NewUserHandler ハンドラーの生成
func NewUserHandler(u *usecase.UserUsecase) *UserHandler {
    return &UserHandler{
        userUsecase: u,
    }
}

// リクエスト/レスポンスの構造体
type createUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

type userResponse struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

// CreateUser ユーザー作成ハンドラー
func (h *UserHandler) CreateUser(c echo.Context) error {
    var req createUserRequest
    if err := c.Bind(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": errInvalidRequest,
        })
    }
    
    err := h.userUsecase.RegisterUser(req.Name, req.Email)
    if err != nil {
        switch err {
        case repository.ErrDuplicateEmail:
            return c.JSON(http.StatusConflict, map[string]string{
                "error": "Email already exists",
            })
        default:
            return c.JSON(http.StatusInternalServerError, map[string]string{
                "error": errInternalServer,
            })
        }
    }
    
    return c.JSON(http.StatusCreated, map[string]string{
        "message": "User created successfully",
    })
}

// GetUser ユーザー取得ハンドラー
func (h *UserHandler) GetUser(c echo.Context) error {
    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": "Invalid user ID",
        })
    }
    
    user, err := h.userUsecase.GetUser(id)
    if err != nil {
        switch err {
        case repository.ErrUserNotFound:
            return c.JSON(http.StatusNotFound, map[string]string{
                "error": "User not found",
            })
        default:
            return c.JSON(http.StatusInternalServerError, map[string]string{
                "error": errInternalServer,
            })
        }
    }
    
    return c.JSON(http.StatusOK, userResponse{
        ID:    user.ID,
        Name:  user.Name,
        Email: user.Email,
    })
}

ハンドラー層のポイント

  • HTTPリクエスト/レスポンスの変換
  • エラーハンドリングとステータスコードの設定
  • 入力値の基本的なバリデーション

ハンドラー層は「外部とのやり取り」を担当します

  1. リクエスト/レスポンスの変換

    • Webからの要求(JSON)を内部で使用できる形式に変換
    • 内部のデータをWebで返せる形式(JSON)に変換
    • 例:JSONで受け取った情報をUser構造体に変換
  2. エラーハンドリング

    • エラーが発生した時の処理を定義
    • 適切なHTTPステータスコードの設定
    • エラーメッセージの整形
    • 例:メールアドレス重複時は409エラー、データ不正時は400エラー

3. (発展実装編)インフラストラクチャ層の実装

3.1 データベース接続の管理

// infrastructure/database.go
package infrastructure

import (
    "database/sql"
    "fmt"
    "time"
    _ "github.com/go-sql-driver/mysql"
)

type DBConfig struct {
    Host     string
    Port     int
    User     string
    Password string
    DBName   string
}

func NewDB(config DBConfig) (*sql.DB, error) {
    dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true",
        config.User,
        config.Password,
        config.Host,
        config.Port,
        config.DBName,
    )
    
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        return nil, fmt.Errorf("failed to connect to database: %w", err)
    }
    
    // コネクションプールの設定
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(25)
    db.SetConnMaxLifetime(5 * time.Minute)
    
    // 接続テスト
    if err := db.Ping(); err != nil {
        return nil, fmt.Errorf("failed to ping database: %w", err)
    }
    
    return db, nil
}

3.2 リポジトリの実装

// interface/repository/user.go
package repository

import (
    "database/sql"
    "fmt"
    "github.com/your-username/myapp/domain/entity"
    "github.com/your-username/myapp/domain/repository"
)

type userRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) repository.UserRepository {
    return &userRepository{db: db}
}

func (r *userRepository) Create(user *entity.User) error {
    query := `
        INSERT INTO users (name, email, created_at, updated_at)
        VALUES (?, ?, ?, ?)
    `
    
    result, err := r.db.Exec(query,
        user.Name,
        user.Email,
        user.CreatedAt,
        user.UpdatedAt,
    )
    if err != nil {
        return fmt.Errorf("failed to create user: %w", err)
    }
    
    id, err := result.LastInsertId()
    if err != nil {
        return fmt.Errorf("failed to get last insert id: %w", err)
    }
    
    user.ID = int(id)
    return nil
}

func (r *userRepository) FindByID(id int) (*entity.User, error) {
    query := `
        SELECT id, name, email, created_at, updated_at
        FROM users
        WHERE id = ?
    `
    
    user := &entity.User{}
    err := r.db.QueryRow(query, id).Scan(
        &user.ID,
        &user.Name,
        &user.Email,
        &user.CreatedAt,
        &user.UpdatedAt,
    )
    
    if err == sql.ErrNoRows {
        return nil, repository.ErrUserNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("failed to find user: %w", err)
    }
    
    return user, nil
}

func (r *userRepository) FindByEmail(email string) (*entity.User, error) {
    query := `
        SELECT id, name, email, created_at, updated_at
        FROM users
        WHERE email = ?
    `
    
    user := &entity.User{}
    err := r.db.QueryRow(query, email).Scan(
        &user.ID,
        &user.Name,
        &user.Email,
        &user.CreatedAt,
        &user.UpdatedAt,
    )
    
    if err == sql.ErrNoRows {
        return nil, repository.ErrUserNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("failed to find user: %w", err)
    }
    
    return user, nil
}

func (r *userRepository) FindAll() ([]*entity.User, error) {
    query := `
        SELECT id, name, email, created_at, updated_at
        FROM users
    `
    
    rows, err := r.db.Query(query)
    if err != nil {
        return nil, fmt.Errorf("failed to query users: %w", err)
    }
    defer rows.Close()
    
    var users []*entity.User
    for rows.Next() {
        user := &entity.User{}
        err := rows.Scan(
            &user.ID,
            &user.Name,
            &user.Email,
            &user.CreatedAt,
            &user.UpdatedAt,
        )
        if err != nil {
            return nil, fmt.Errorf("failed to scan user: %w", err)
        }
        users = append(users, user)
    }
    
    if err = rows.Err(); err != nil {
        return nil, fmt.Errorf

はい、もっと親しみやすい形でまとめ直してみました:

まとめ

いかがでしたでしょうか。
「クリーンアーキテクチャってよくわかっていない...」と感じている方も多いのではないでしょうか。

台所の例で説明したように、プログラムも整理整頓が大切です。
「あれ?このコード、どこにあったっけ?」「これ、変えたら他のところが動かなくなった!」
なんて経験、ありませんか?クリーンアーキテクチャは、そんな悩みを解決するための「お片付けの方法」です。

この記事では、日常の例を使って、できるだけわかりやすく解説してきました。
コードを「層」に分けて整理することで、まるできれいに整理された引き出しのように、必要なものがすぐに見つかる状態を目指します。

実際のコードを見ると「うわ、まだ難しい!」と感じるかもしれません。でも大丈夫です。完璧に理解できなくても、 「重要なものを真ん中に置いて、守ってあげる」 という基本的な考え方さえ掴んでもらえれば、それが第一歩です。

プログラミングの世界で「絶対的に正しい設計」というものはありません。この記事で紹介したクリーンアーキテクチャも、ひとつの指針として、みなさんのプロジェクトに合わせて、柔軟に取り入れていただければと思います。

Discussion