0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

クリーンアーキテクチャとは?

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

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

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

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

アプリの初期構成

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

├── backend
│   ├── Dockerfile
│   ├── database.go
│   ├── models.go
│   ├── ent
│   ├── go.mod
│   ├── go.sum
│   ├── handlers.go
│   └── main.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")
}

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
}
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"`
}
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"),
	}
}
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の構成を参考にしております。

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

domain

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

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"`
}

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

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

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

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リクエストを受け付けてユースケースを呼び出す。

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: データベース操作を抽象化。

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: ルーティングを定義。

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

役割:外部サービス(データベース、ファイルストレージなど)の接続管理。
背景:データベース接続情報をまとめ、将来的な変更に備えた柔軟性を確保。

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"),
    }
}
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
    }
}

共通の設定ファイル

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

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になります。

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リクエストを送信。

router/router.go
r.GET("/articles", articleController.GetArticles)

により、ルーターがリクエストをarticleController.GetArticlesにルーティング。

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)
}

の下記の部分により

adapter/controller/article.go
ctrl.articleUseCase.GetArticles(c.Request.Context())

コントローラーはユースケースのGetArticlesを呼び出す

usecase/article.go
func (uc *articleUseCase) GetArticles(ctx context.Context) ([]*domain.Article, error) {
    // リポジトリにデータを取得するよう依頼
    return uc.articleRepository.GetArticles(ctx)
}

により、ユースケースはリポジトリ(ゲートウェイ)のGetArticlesを呼び出す

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)に変換する。

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

articlesに先ほどのデータ入っており、

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に変更しても、一部の層の修正だけで対応可能。
  • テストの容易さ:ユースケースやコントローラーをモックでテスト可能。
  • 責務の明確化:役割ごとにコードが整理され、可読性が向上。

まとめ

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?