Goで学ぶクリーンアーキテクチャ：単純なアプリを高品質な設計に進化させる

## はじめに
「Goで簡単なアプリケーションを作ってみたけれど、コードが複雑になり保守しにくくなった」という経験はありませんか？本記事では、シンプルなGoアプリケーションをクリーンアーキテクチャにリファクタリングする方法を解説します。クリーンアーキテクチャの思想とともに、コードの進化の過程を実際の構成で学びましょう。

## クリーンアーキテクチャとは？
クリーンアーキテクチャは、以下のような目標を持ったソフトウェア設計のパターンです：

- **依存性の逆転**：ビジネスロジック（ユースケース）がデータ層や外部フレームワークに依存しない。
- **テストのしやすさ**：独立した層ごとにテスト可能。
- **保守性**：変更箇所を特定しやすい構造。


以下の層構造を持ちます：

- **エンティティ**（Domain）：ビジネスルールを表現する中心部分。
- **ユースケース**（UseCase）：ビジネスロジックを実行する層。
- **インターフェースアダプタ**（Adapter）：外部データやフレームワークとの橋渡しをする層。
- **フレームワークとドライバ**（Infrastructure）：データベースや外部サービスとの接続を管理。

## アプリの初期構成
最初は以下のようなシンプルな構成からスタートしました：

```
├── backend
│   ├── Dockerfile
│   ├── database.go
│   ├── models.go
│   ├── ent
│   ├── go.mod
│   ├── go.sum
│   ├── handlers.go
│   └── main.go
```
ファイルの中身になります。

```go:main.go
package main

import (
	"log"
	"flag"
	"os"

	"github.com/gin-gonic/gin"
	"github.com/joho/godotenv"
)

func main() {
	// GIN_MODE 環境変数を設定
    ginMode := os.Getenv("GIN_MODE")
    if ginMode == "" {
        ginMode = "debug" // デフォルトは "debug"
		// .envファイルを読み込む
		err := godotenv.Load()
		if err != nil {
			log.Printf("Error loading .env file: %v", err)
		}
    }
    gin.SetMode(ginMode)

	// データベース接続を初期化
    client, err := initDB()
    if err != nil {
        log.Fatalf("failed opening connection to postgres: %v", err)
    }
	defer client.Close()

    router := gin.Default()
  	router.GET("/", func(c *gin.Context) {
		c.JSON(200, gin.H{
      		"message": "Hello, World!",
		})
  	})

	router.GET("/articles", func(c *gin.Context) {
        getArticlesHandler(c, client)
    })

  	router.Run(":8080")
}

```

```go:database.go
package main

import (
    "os"
    "context"

    "github.com/jijimama/newspaper-app/ent"
    _ "github.com/lib/pq"
)

func initDB() (*ent.Client, error) {
    dbHost := os.Getenv("DB_HOST")
    dbPort := os.Getenv("DB_PORT")
    dbUser := os.Getenv("DB_USER")
    dbName := os.Getenv("DB_NAME")
    dbPassword := os.Getenv("DB_PASSWORD")
    // 接続文字列を作成
    dsn := "host=" + dbHost + " port=" + dbPort + " user=" + dbUser + " dbname=" + dbName + " password=" + dbPassword + " sslmode=disable"
    // PostgreSQLに接続
    client, err := ent.Open("postgres", dsn)
    if err != nil {
        return nil, err
    }

    // スキーマを作成
    if err := client.Schema.Create(context.Background()); err != nil {
        return nil, err
    }

    return client, nil
}
```
```go:models.go
package main

type Article struct {
    Year       int    `json:"year"`
    Month      int    `json:"month"`
    Day        int    `json:"day"`
    Content    string `json:"content"`
    Newspaper  string `json:"newspaper"`
    ColumnName string `json:"column_name"`
}
```

```go:infrastructure/database/config.go
package database

import (
	"os"
	"time"
)

type config struct {
	host     string
	database string
	port     string
	driver   string
	user     string
	password string

	ctxTimeout time.Duration
}

func newConfigMongoDB() *config {
	return &config{
		host:       os.Getenv("MONGODB_HOST"),
		database:   os.Getenv("MONGODB_HOST"),
		password:   os.Getenv("MONGODB_ROOT_PASSWORD"),
		user:       os.Getenv("MONGODB_ROOT_USER"),
		ctxTimeout: 60 * time.Second,
	}
}

func newConfigPostgres() *config {
	return &config{
		host:     os.Getenv("POSTGRES_HOST"),
		database: os.Getenv("POSTGRES_DATABASE"),
		port:     os.Getenv("POSTGRES_PORT"),
		driver:   os.Getenv("POSTGRES_DRIVER"),
		user:     os.Getenv("POSTGRES_USER"),
		password: os.Getenv("POSTGRES_PASSWORD"),
	}
}
```
```go:handlers.go
package main

import (
    "net/http"
    "context"

    "github.com/gin-gonic/gin"
    "github.com/jijimama/newspaper-app/ent"
)

func getArticlesHandler(c *gin.Context, client *ent.Client) {
    articles, err := getArticles(client)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "failed querying articles"})
        return
    }
    c.JSON(http.StatusOK, articles)
}

func getArticles(client *ent.Client) ([]Article, error) {
    articles, err := client.Article.Query().WithNewspaper().All(context.Background())
    if err != nil {
        return nil, err
    }

    result := make([]Article, len(articles))
    for i, a := range articles {
        result[i] = Article{
            Year:       a.Year,
            Month:      a.Month,
            Day:        a.Day,
            Content:    a.Content,
            Newspaper:  a.Edges.Newspaper.Name,
            ColumnName: a.Edges.Newspaper.ColumnName,
        }
    }

    return result, nil
}
```
しかし、この構成には次の課題がありました：

- **責務が集中**：```main.go```にルーティング、データベース初期化、ハンドラーが詰め込まれている。
- **変更に弱い**：例えばデータベースを変更したい場合、複数箇所を編集する必要がある。
- **テストが困難**：ビジネスロジックと外部依存が密結合しているため、単体テストが難しい。

## クリーンアーキテクチャへの進化

最終構成：
```
├── Dockerfile
├── adapter
│   ├── controller
│   │   └── article.go
│   ├── gateway
│   │   └── article.go
│   └── router
│       └── router.go
├── config
│   └── config.go
├── domain
│   └── article.go
├── ent
├── go.mod
├── go.sum
├── infrastructure
│   └── database
│       ├── config.go
│       └── factory.go
├── main.go
└── usecase
    └── article.go
```

こちらのGitHubの構成を参考にしております。

https://github.com/GSabadini/go-clean-architecture

## ディレクトリごとの役割と解説

### domain
**役割**：ビジネスルールを表現する最小限の構造体とインターフェース。
**背景**：データベースやフレームワークに依存せず、アプリケーションの中心を形成。

```go:models.go
package main

type Article struct {
    Year       int    `json:"year"`
    Month      int    `json:"month"`
    Day        int    `json:"day"`
    Content    string `json:"content"`
    Newspaper  string `json:"newspaper"`
    ColumnName string `json:"column_name"`
}
```

をそのままディレクトリや名前を変更しました。

```go:domain/article.go
package domain

type Article struct {
    Year       int    `json:"year"`
    Month      int    `json:"month"`
    Day        int    `json:"day"`
    Content    string `json:"content"`
    Newspaper  string `json:"newspaper"`
    ColumnName string `json:"column_name"`
}

```

### usecase
**役割**：ビジネスロジックを実行。リポジトリを通じてデータを操作。
**背景**：コントローラーが直接データ操作を行うと、責務が混在するため分離。

```go:usecase/article.go
package usecase

import (
    "context"
    "github.com/jijimama/newspaper-app/adapter/gateway"
    "github.com/jijimama/newspaper-app/domain"
)

// ArticleUseCase はデータアクセスのためのインターフェース。
// データベースや外部APIから情報を取得するための方法を定義。
type ArticleUseCase interface {
    // 記事一覧を取得するメソッド
    GetArticles(ctx context.Context) ([]*domain.Article, error)
}

// articleUseCase は記事関連のビジネスロジックを管理する構造体。
// データを取得するためにリポジトリを使用。
type articleUseCase struct {
    articleRepository gateway.ArticleRepository // 記事データを取得するためのリポジトリ
}

// NewArticleUseCase は articleUseCase を作成するファクトリ関数。
// リポジトリを引数として渡します。
// ユースケースがリポジトリ（データ操作）を利用できるようにするため
func NewArticleUseCase(articleRepository gateway.ArticleRepository) *articleUseCase {
    return &articleUseCase{
        articleRepository: articleRepository,
    }
}

// GetArticles はリポジトリを使って記事一覧を取得。
// コントローラーから呼び出され、実際にデータを取得する役割を担う。
func (uc *articleUseCase) GetArticles(ctx context.Context) ([]*domain.Article, error) {
    // リポジトリにデータを取得するよう依頼
    return uc.articleRepository.GetArticles(ctx)
}
```
### adapter
**controller**: HTTPリクエストを受け付けてユースケースを呼び出す。

```go:adapter/controller/article.go
package controller

import (
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/jijimama/newspaper-app/usecase"
)

// ArticleController は記事関連のHTTPリクエストを処理するコントローラー。
// ユースケースに処理を依頼し、その結果を整形して返す。
type ArticleController struct {
    articleUseCase usecase.ArticleUseCase // ユースケースを通じてビジネスロジックを実行
}

// NewArticleController は ArticleController を作成するファクトリ関数。
func NewArticleController(articleUseCase usecase.ArticleUseCase) *ArticleController {
    return &ArticleController{
		articleUseCase: articleUseCase,
	}
}

// GetArticles は /articles エンドポイントへのGETリクエストを処理。
// コントローラーは、ユースケースを使ってビジネスロジックを実行し、結果をレスポンスとして返す。
func (ctrl *ArticleController) GetArticles(c *gin.Context) {
    // ユースケースを呼び出して記事一覧を取得
    articles, err := ctrl.articleUseCase.GetArticles(c.Request.Context())
    if err != nil {
        // エラーが発生した場合は500エラーを返す
        c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch articles"})
        return
    }

    c.JSON(http.StatusOK, articles)
}
```
**gateway**: データベース操作を抽象化。


```go:adapter/gateway/article.go
package gateway

import (
    "context"

    "github.com/jijimama/newspaper-app/domain"
    "github.com/jijimama/newspaper-app/ent"
)

// ArticleRepository は記事データをデータベースから取得するリポジトリインターフェース。
type ArticleRepository interface {
    GetArticles(ctx context.Context) ([]*domain.Article, error)
}

type articleRepository struct {
    client *ent.Client // Ent クライアントを使用してデータベースと通信
}

// NewArticleRepository は クライアントをフィールドに持つ ArticleRepository を返す。
func NewArticleRepository(client *ent.Client) ArticleRepository {
    return &articleRepository{client: client}
}

// GetArticles はデータベースから記事一覧を取得。
func (r *articleRepository) GetArticles(ctx context.Context) ([]*domain.Article, error) {
    // データベースから記事をクエリ
    articles, err := r.client.Article.Query().WithNewspaper().All(ctx)
    if err != nil {
        return nil, err // エラーが発生した場合は上位に返す
    }

    // データベースの結果をドメインモデルに変換
    result := make([]*domain.Article, len(articles))
    for i, a := range articles {
        result[i] = &domain.Article{
            Year:       a.Year,
            Month:      a.Month,
            Day:        a.Day,
            Content:    a.Content,
            Newspaper:  a.Edges.Newspaper.Name,
            ColumnName: a.Edges.Newspaper.ColumnName,
        }
    }

    return result, nil // ドメインモデルを返す
}
```

**router**: ルーティングを定義。

```go:adapter/router/router.go
package router

import (
    "github.com/gin-gonic/gin"
    "github.com/jijimama/newspaper-app/adapter/controller"
)

// NewRouter はアプリケーションのルーティングを設定
func NewRouter(articleController *controller.ArticleController) *gin.Engine {
    r := gin.Default()

    // ヘルスチェック用エンドポイント
    r.GET("/", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "message": "Hello, World!",
        })
    })

    // 記事一覧のエンドポイント
    r.GET("/articles", articleController.GetArticles)

    return r
}
```
### infrastructure
**役割**：外部サービス（データベース、ファイルストレージなど）の接続管理。
**背景**：データベース接続情報をまとめ、将来的な変更に備えた柔軟性を確保。

```go:infrastructure/database/config.go
package database

import (
    "os"
)

type Config struct {
    Host     string
    Port     string
    User     string
	Name	 string
    Password string
}

func NewConfigPostgres() *Config {
    return &Config{
        Host:     os.Getenv("DB_HOST"),
        Port:     os.Getenv("DB_PORT"),
        User:     os.Getenv("DB_USER"),
		Name:     os.Getenv("DB_NAME"),
        Password: os.Getenv("DB_PASSWORD"),
    }
}
```
```go:infrastructure/database/factory.go
package database

import (
    "context"
    "errors"
    "fmt"

    "github.com/jijimama/newspaper-app/ent"
    _ "github.com/lib/pq"
)

const (
    InstancePostgres int = iota
)

var (
    errInvalidSQLDatabaseInstance = errors.New("invalid sql db instance")
)

func NewDatabaseSQLFactory(instance int) (*ent.Client, error) {
    switch instance {
    case InstancePostgres:
        config := NewConfigPostgres()
        dsn := fmt.Sprintf("host=%s port=%s user=%s dbname=%s password=%s sslmode=disable",
            config.Host, config.Port, config.User, config.Name, config.Password)
        client, err := ent.Open("postgres", dsn)
        if err != nil {
            return nil, err
        }
        if err := client.Schema.Create(context.Background()); err != nil {
            return nil, err
        }
        return client, nil
    default:
        return nil, errInvalidSQLDatabaseInstance
    }
}
```

### 共通の設定ファイル
環境変数の設定処理を専用の設定パッケージに切り出し、読み込みを行う部分で呼び出す

```go:config/config.go
package config

import (
    "log"
    "os"

    "github.com/joho/godotenv"
)

// Config はアプリケーション全体の設定を格納する構造体
type Config struct {
    GinMode string
}

// LoadConfig は設定を初期化して返す
func LoadConfig() *Config {
    ginMode := getEnv("GIN_MODE", "debug")

    // GIN_MODE が release でない場合に .env ファイルを読み込む
    if ginMode != "release" {
        err := godotenv.Load()
        if err != nil {
            log.Printf("Error loading .env file: %v", err)
        }
    }

    return &Config{
        GinMode: ginMode,
    }
}

// getEnv は環境変数の値を取得し、デフォルト値を返す
func getEnv(key string, defaultValue string) string {
    value := os.Getenv(key)
    if value == "" {
        return defaultValue
    }
    return value
}
```


そして、```main.go```になります。


```go:main.go
package main

import (
	"log"

	"github.com/gin-gonic/gin"
	"github.com/jijimama/newspaper-app/config"
	"github.com/jijimama/newspaper-app/infrastructure/database"
	"github.com/jijimama/newspaper-app/adapter/controller"
    "github.com/jijimama/newspaper-app/adapter/gateway"
	"github.com/jijimama/newspaper-app/adapter/router"
    "github.com/jijimama/newspaper-app/usecase"
)

func main() {
	// 設定を読み込む
    cfg := config.LoadConfig()
	// GIN_MODE を設定
    gin.SetMode(cfg.GinMode)
	// データベース接続を初期化
    client, err := database.NewDatabaseSQLFactory(database.InstancePostgres)
    if err != nil {
        log.Fatalf("failed opening connection to database: %v", err)
    }
    defer client.Close()

	// ゲートウェイ、ユースケース、コントローラーを初期化
    articleRepository := gateway.NewArticleRepository(client)
    articleUsecase := usecase.NewArticleUseCase(articleRepository)
    articleController := controller.NewArticleController(articleUsecase)

	// ルーターを設定
    r := router.NewRouter(articleController)

	// サーバーを起動
	r.Run(":8080")
}
```

## 処理フロー: /articlesエンドポイント

クライアントが```/articles```にGETリクエストを送信。
```go:router/router.go
r.GET("/articles", articleController.GetArticles)
```
により、ルーターがリクエストを```articleController.GetArticles```にルーティング。
```go:adapter/controller/article.go
func (ctrl *ArticleController) GetArticles(c *gin.Context) {
    // ユースケースを呼び出して記事一覧を取得
    articles, err := ctrl.articleUseCase.GetArticles(c.Request.Context())
    if err != nil {
        // エラーが発生した場合は500エラーを返す
        c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch articles"})
        return
    }

    c.JSON(http.StatusOK, articles)
}
```
の下記の部分により
```go:adapter/controller/article.go
ctrl.articleUseCase.GetArticles(c.Request.Context())
```
コントローラーはユースケースの```GetArticles```を呼び出す

```go:usecase/article.go
func (uc *articleUseCase) GetArticles(ctx context.Context) ([]*domain.Article, error) {
    // リポジトリにデータを取得するよう依頼
    return uc.articleRepository.GetArticles(ctx)
}
```
により、ユースケースはリポジトリ（ゲートウェイ）の```GetArticles```を呼び出す

```go:adapter/gateway/article.go
// GetArticles はデータベースから記事一覧を取得。
func (r *articleRepository) GetArticles(ctx context.Context) ([]*domain.Article, error) {
    // データベースから記事をクエリ
    articles, err := r.client.Article.Query().WithNewspaper().All(ctx)
    if err != nil {
        return nil, err // エラーが発生した場合は上位に返す
    }

    // データベースの結果をドメインモデルに変換
    result := make([]*domain.Article, len(articles))
    for i, a := range articles {
        result[i] = &domain.Article{
            Year:       a.Year,
            Month:      a.Month,
            Day:        a.Day,
            Content:    a.Content,
            Newspaper:  a.Edges.Newspaper.Name,
            ColumnName: a.Edges.Newspaper.ColumnName,
        }
    }

    return result, nil // ドメインモデルを返す
}
```
ゲートウェイが```Ent(client)```を用いてデータベースクエリを実行。データを取得してドメインモデル（```domain.Article```）に変換する。


```go:adapter/controller/article.go
articles, err := ctrl.articleUseCase.GetArticles(c.Request.Context())
```

```articles```に先ほどのデータ入っており、
```go:adapter/controller/article.go
func (ctrl *ArticleController) GetArticles(c *gin.Context) {
    // ユースケースを呼び出して記事一覧を取得
    articles, err := ctrl.articleUseCase.GetArticles(c.Request.Context())
    if err != nil {
        // エラーが発生した場合は500エラーを返す
        c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch articles"})
        return
    }

    c.JSON(http.StatusOK, articles)
}
```

JSON形式でクライアントに返却する。

## クリーンアーキテクチャの効果
- **変更への強さ**：例えばデータベースをPostgreSQLからMongoDBに変更しても、一部の層の修正だけで対応可能。
- **テストの容易さ**：ユースケースやコントローラーをモックでテスト可能。
- **責務の明確化**：役割ごとにコードが整理され、可読性が向上。

## まとめ
クリーンアーキテクチャは、初期構築には時間がかかるかもしれません。しかし、長期的な保守性と拡張性を考えると、大きな効果を発揮します。本記事で紹介したサンプル構成を参考に、ぜひ自分のプロジェクトにも取り入れてみてください！