はじめに
「Goで簡単なアプリケーションを作ってみたけれど、コードが複雑になり保守しにくくなった」という経験はありませんか?本記事では、シンプルなGoアプリケーションをクリーンアーキテクチャにリファクタリングする方法を解説します。クリーンアーキテクチャの思想とともに、コードの進化の過程を実際の構成で学びましょう。
クリーンアーキテクチャとは?
クリーンアーキテクチャは、以下のような目標を持ったソフトウェア設計のパターンです:
- 依存性の逆転:ビジネスロジック(ユースケース)がデータ層や外部フレームワークに依存しない。
- テストのしやすさ:独立した層ごとにテスト可能。
- 保守性:変更箇所を特定しやすい構造。
以下の層構造を持ちます:
- エンティティ(Domain):ビジネスルールを表現する中心部分。
- ユースケース(UseCase):ビジネスロジックを実行する層。
- インターフェースアダプタ(Adapter):外部データやフレームワークとの橋渡しをする層。
- フレームワークとドライバ(Infrastructure):データベースや外部サービスとの接続を管理。
アプリの初期構成
最初は以下のようなシンプルな構成からスタートしました:
├── backend
│ ├── Dockerfile
│ ├── database.go
│ ├── models.go
│ ├── ent
│ ├── go.mod
│ ├── go.sum
│ ├── handlers.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")
}
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
}
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"`
}
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"),
}
}
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
役割:ビジネスルールを表現する最小限の構造体とインターフェース。
背景:データベースやフレームワークに依存せず、アプリケーションの中心を形成。
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"`
}
をそのままディレクトリや名前を変更しました。
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
役割:ビジネスロジックを実行。リポジトリを通じてデータを操作。
背景:コントローラーが直接データ操作を行うと、責務が混在するため分離。
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リクエストを受け付けてユースケースを呼び出す。
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: データベース操作を抽象化。
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: ルーティングを定義。
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
役割:外部サービス(データベース、ファイルストレージなど)の接続管理。
背景:データベース接続情報をまとめ、将来的な変更に備えた柔軟性を確保。
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"),
}
}
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
}
}
共通の設定ファイル
環境変数の設定処理を専用の設定パッケージに切り出し、読み込みを行う部分で呼び出す
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
になります。
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リクエストを送信。
r.GET("/articles", articleController.GetArticles)
により、ルーターがリクエストをarticleController.GetArticles
にルーティング。
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)
}
の下記の部分により
ctrl.articleUseCase.GetArticles(c.Request.Context())
コントローラーはユースケースのGetArticles
を呼び出す
func (uc *articleUseCase) GetArticles(ctx context.Context) ([]*domain.Article, error) {
// リポジトリにデータを取得するよう依頼
return uc.articleRepository.GetArticles(ctx)
}
により、ユースケースはリポジトリ(ゲートウェイ)のGetArticles
を呼び出す
// 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
)に変換する。
articles, err := ctrl.articleUseCase.GetArticles(c.Request.Context())
articles
に先ほどのデータ入っており、
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に変更しても、一部の層の修正だけで対応可能。
- テストの容易さ:ユースケースやコントローラーをモックでテスト可能。
- 責務の明確化:役割ごとにコードが整理され、可読性が向上。
まとめ
クリーンアーキテクチャは、初期構築には時間がかかるかもしれません。しかし、長期的な保守性と拡張性を考えると、大きな効果を発揮します。本記事で紹介したサンプル構成を参考に、ぜひ自分のプロジェクトにも取り入れてみてください!