🏛️

クリーンアーキテクチャに入門して分かった利点と弱点

に公開3

2025年の最も大きなチャレンジはクリーンアーキテクチャへの入門でした。
この記事では、実体験をベースに

  • クリーンアーキテクチャを採用して何が良かったのか
  • 逆にどこが難点だったか
    を整理して書いていこうと思います。

記事要約

  • 不確定要素や複雑性を抱えたプログラムを書くためにクリーンアーキテクチャに入門しました
  • 実際に作成してみて実感した利点
    • プログラムの出力側の仕様変更に即時で対応できた
    • 並行処理を簡単に導入できた
    • テストが書きやすかった
  • 実際に作成してみて実感した弱点
    • コードが冗長。ディレクトリ構成や基本的な設計方針を理解していないとコードリーディングが大変
    • 手続き的な処理を軸にするのではなくエンティティを軸に考える思考法が必要
    • エンティティに関する設計が固まっていないとつらい

なぜクリーンアーキテクチャに入門したのか

様々なシステムで使用しているユーザー管理DBがあります。こちらから取得したデータをクラウドサービスへとアップロードするプログラムを作成することになりました。

一見、単純なアップローダーのように見えますが、以下のような不確定要素を抱えていました。

  • ユーザー管理DBのテーブル定義やデータの意味合いはユーザー管理DBを使用するシステム変更に伴って調整された過去がある
    • 今後も同一なテーブルを使い続ける保証が無い
  • プログラム作成時点でアップロード先のサービスの選定が完了していない
  • クラウドサービスへアップロードしたデータを取得して処理するのは別の部署

当初、これらを場当たり的に実装で吸収し続けていましたが、プログラムの説得と作成に限界を感じました。
そこでクリーンアーキテクチャという構造を使用することで、この問題を解決できるのではないかと思い、クリーンアーキテクチャを採用しました。

サンプルコードについて

この記事で説明するためのサンプルコードを作成しました。

ユーザー管理DBのデータは以下のようなものです。

CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,   -- ユーザーID
    name VARCHAR(255) NOT NULL,           -- ユーザー名
    email VARCHAR(255) NOT NULL UNIQUE,   -- メールアドレス
    status_code INT NOT NULL              -- 0: 利用中, 1: 退会済み
);

このようなデータをユーザー管理DBから検索してクラウドサービスへとアップロードします。
また、アップロード元のDBはPostgreSQL、アップロード先はAWS S3と仮定します。
S3へは次の形のJSONファイルでアップロードします。

{
  "id": 1,
  "name": "John Doe",
  "email": "jone@example.com"
  "status_code": 0
}

https://github.com/mitsu-yuki/example-clean-architecture-go/blob/1e07be5a65906a0c3d35e7f9e438e7cb59f672be/main.go

実際に作成してみて実感した利点

急な仕様変更に対応できた

ある日、ユーザー管理DBのレコードを調査すると、事前に定義済みであった通常稼働の0と停止の1以外に数字が登録されていました。
ユーザー管理DBを管理している担当者に聞いてみると、実は以下のようなマッピングになっていました。

  • 0: 利用中(active)
  • 1: 退会済み(deleted)
  • 2: 一時ロック中(locked)
  • 3: 利用停止(suspended)
  • 4: 本人確認待ち(pending_verification)
  • 5: 退会手続き中(pending_deletion)

これの内容を別部署に伝えると、次のような要望が挙がりました。
「マジックナンバーは廃止してください。フィールド名はstatus_codeからstatusに変更して、マジックナンバーからステータス名に変更してください。できれば早くアップロードし直してほしいです」

この要望に対する変更はこちらです。

 type S3User struct {
-	Id         int    `json:"id"`
-	Name       string `json:"name"`
-	Email      string `json:"email"`
-	StatusCode int    `json:"status_code"`
+	Id     int    `json:"id"`
+	Name   string `json:"name"`
+	Email  string `json:"email"`
+	Status string `json:"status"`
+}
+
+func mapStatusCodeToStateString(code int) string {
+	switch code {
+	case 0:
+		return "active"
+	case 1:
+		return "deleted"
+	case 2:
+		return "locked"
+	case 3:
+		return "suspended"
+	case 4:
+		return "pending_verification"
+	case 5:
+		return "pending_deletion"
+	default:
+		return "unknown"
+	}
 }
 
 func (r S3UploadUserRepository) Upload(ctx context.Context, user *User) error {
+
+	status := mapStatusCodeToStateString(user.StatusCode())
 	s3User := S3User{
-		Id:         user.ID(),
-		Name:       user.Name(),
-		Email:      user.Email(),
-		StatusCode: user.StatusCode(),
+		Id:     user.ID(),
+		Name:   user.Name(),
+		Email:  user.Email(),
+		Status: status,
 	}
 	data, err := json.MarshalIndent(s3User, "", "  ")
 	if err != nil {

この変更では、S3に出力する構造体のフィールド名を変更し、数値のステータスコードを文字列に変換する処理を追加しています。
この程度の変更であればクリーンアーキテクチャを採用していなくても実装は可能です。
しかしクリーンアーキテクチャでは、外部サービス都合のフォーマット変更を、インフラストラクチャー層といった出力側の層だけに閉じ込めることができます。
その結果、同様の変更が起きた際でも、変更箇所を局所化できるため、変更箇所の見通しが良くなります。

並行処理の導入が簡単だった

main関数の中で、取得したユーザーを全件アップロードするという処理があります。

func main() {
    (省略)
    pgRepo := NewPostgresFindUserRepository(db)
    s3Repo := NewS3UploadUserRepository(client, "company", "system/user")
    
    findAllUC := NewFindAllUserUseCase(pgRepo)
    uploadUC := NewUploadUserUseCase(s3Repo)

    // ここで取得
    dtos, err := findAllUC.Run(ctx)
    if err != nil {
        panic(err)
    }
    for _, dto := range dtos {
        // ここでアップロード
        if err := uploadUC.Run(ctx, dto); err != nil {
            panic(err)
        }
    }
}

ユーザー管理DBのレコード数が非常に多く、この実装ではアップロード処理に約3時間かかることが分かりました。

ここで並行処理を導入し、アップロード時間の高速化を図りました。

    findAllUC := NewFindAllUserUseCase(pgRepo)
    uploadUC := NewUploadUserUseCase(s3Repo)
+
+       maxConcurrent := int64(12)
+       g, egCtx := errgroup.WithContext(ctx)
+       sem := semaphore.NewWeighted(maxConcurrent)
+
        for _, dto := range dtos {
-               if err := uploadUC.Run(ctx, dto); err != nil {
-                       panic(err)
-               }
+               g.Go(func() error {
+                       if err := sem.Acquire(egCtx, 1); err != nil {
+                               return err
+                       }
+                       defer sem.Release(1)
+                       return uploadUC.Run(egCtx, dto)
+               })
        }
 }

Go言語の並行処理の容易さも相まって、ユースケースの呼び出し方を変更するだけで並行処理を導入できました。
その結果、処理が約15分で終了するようになりました。
ユースケースが独立している設計のおかげで「どう実行するか」という関心事を後から柔軟に変更できた点は、クリーンアーキテクチャの大きなメリットだと感じています。

テストコードが書きやすい

DBやS3などを使ったSQLのテストを行うには通常DBやS3などの環境が必要です。
このDBをモック化することで、本物のDBを使う必要が無くなります。
ここではDBのモックを作成するgo-sqlmockパッケージを使用してテストコードを書いてみましょう。

テストコード全文
func TestPostgresFindUserRepository_FindAll(t *testing.T) {
	db, mock, err := sqlmock.New()
	assert.NoError(t, err)
	defer db.Close()

	sqlxDB := sqlx.NewDb(db, "postgres")

	repo := PostgresFindUserRepository{
		db: sqlxDB,
	}

	rows := sqlmock.NewRows([]string{
		"id", "name", "email", "status_code",
	}).
		AddRow(1, "Alice", "alice@example.com", 0).
		AddRow(2, "Bob", "bob@example.com", 1)

	mock.ExpectQuery(
		`SELECT id, name, email, status_code FROM app.user`,
	).WillReturnRows(rows)

	ctx := context.Background()
	users, err := repo.FindAll(ctx)

	assert.NoError(t, err)
	assert.Len(t, users, 2)
	assert.Equal(t, 1, users[0].Id())
	assert.Equal(t, "Alice", users[0].Name())
	assert.Equal(t, "alice@example.com", users[0].Email())

	assert.NoError(t, mock.ExpectationsWereMet())
}

クリーンアーキテクチャでは、接続先の情報などは依存性注入(DI)の形で外部から注入します。
以下のコードはsqlmockでDBのモックを作成し、PostgresFindUserRepositoryを作成してFindAll()を実行する部分を抜粋したテストコードです。

    db, mock, err := sqlmock.New()
    assert.NoError(t, err)
    defer db.Close()
    
    sqlxDB := sqlx.NewDb(db, "postgres")
    
    repo := NewPostgresFindUserRepository(sqlxDB)
    // やってることは以下と一緒
    // repo := PostgresFindUserRepository{
    //     db: sqlxDB,
    // }

    ctx := context.Background()
    users, err := repo.FindAll(ctx)

NewPostgresFindUserRepository(sqlxDB)モックDBへの接続をセットアップします。 FindAll(ctx)`の内部ではDBへのコネクション情報を値レシーバーを経由して使用します。

func (r PostgresFindUserRepository) FindAll(ctx context.Context) ([]*User, error) {
	query := `SELECT id, name, email, status_code FROM system.user`
	var pgUsers []PostgresUser
    // 値レシーバー経由で`sqlxDB`を触っている
	if err := r.db.SelectContext(ctx, &pgUsers, query); err != nil {
		return nil, err
	}
    (省略)
}

従って、テスト用にFindAll()内部で分岐させるといった処理の必要がなく、本番で使用するコードと同一のコードをそのままテストすることができます。

実際に作成してみて実感した弱点

コードが冗長

サンプルコードと同等のことを愚直にやろうとすると65行程度のコードで済みます。
一方、サンプルコードは214行あります。

同等なことを行うコード
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq"
)

func main() {
	ctx := context.Background()

	db, err := sqlx.Connect("postgres", "postgres://user@postgres.example.com/company")
	if err != nil {
		panic(err)
	}
	defer db.Close()

	cfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		panic(err)
	}
	s3Client := s3.NewFromConfig(cfg)

	type User struct {
		ID         int    `db:"id" json:"id"`
		Name       string `db:"name" json:"name"`
		Email      string `db:"email" json:"email"`
		StatusCode int    `db:"status_code" json:"status_code"`
	}

	var users []User
	err = db.SelectContext(ctx, &users, `SELECT id, name, email, status_code FROM system.user`)
	if err != nil {
		panic(err)
	}

	bucket := "company"
	prefix := "system/user"

	for _, u := range users {
		data, err := json.MarshalIndent(u, "", "  ")
		if err != nil {
			panic(err)
		}

		key := fmt.Sprintf("%s/user-%d.json", prefix, u.ID)

		_, err = s3Client.PutObject(ctx, &s3.PutObjectInput{
			Bucket:      aws.String(bucket),
			Key:         aws.String(key),
			Body:        bytes.NewReader(data),
			ContentType: aws.String("application/json"),
		})
		if err != nil {
			panic(err)
		}
	}
}

クリーンアーキテクチャは変更に強い設計にするため、処理の抽象化や関心の分離をかなり行います。

func (uc *UploadUserUseCase) Run(ctx context.Context, dto *UserDTO) error {
	u, err := dtoToUser(dto)
	if err != nil {
		return err
	}
	return uc.repo.Upload(ctx, u)
}

このコードがまさにそうなのですが、クリーンアーキテクチャの依存注入方法のセオリーを知らないとuc.repo.Upload(ctx, u)を、この部分だけで理解しろと言われても難しいです。
また、似たような名前のユースケース構造体が大量に作られるため、処理の本質でないコードがとにかく増えます。

プログラムを書くときの思考法を変える必要がある

クリーンアーキテクチャに入門する前は次のような考え方でコードを書いていました。

  1. 外部ライブラリを使ってデータを取得できるようにする
  2. 取得したデータをプログラム内部で扱いやすい形(エンティティ)に変換する
  3. エンティティを次の処理に流し込む

つまり、データ取得→データの変換→処理という流れです。

ところがクリーンアーキテクチャを前提としたプログラムを書く際は、次のような思考のフローになっていると感じました。

  1. プログラムが必要するデータを集約したエンティティのクラスを作成する
  2. エンティティのクラスに対してメソッドを追加して値を取得できるようにする
  3. エンティティが持っているフィールドだけで判断できるロジックはビジネスロジックとして持たせる
  4. このエンティティのデータは永続化した場所から持ってくるのでエンティティに関連したリポジトリを作成する
  5. リポジトリで宣言するインターフェースの関数はリポジトリからどうやってデータを取得するかを考えて作成する
  6. リポジトリのインターフェースを満たす実装を行う
  7. リポジトリを操作するユースケースを作る

考え方が見事に真逆なのが分かりますか?
つまり「どうやって取得するか」からではなく、「そのデータで何をするか」から考える必要があります。
慣れてしまえばこちらの方が手段そのものをあまり考えなくてよくなるので楽なのですが、この思考の転換には慣れが必要だと感じました。

このように、エンティティの設計、リポジトリのメソッド設計、ユースケースの設計と、行いたい振る舞いなどが想定できていないとクリーンアーキテクチャのコードを書くのが難しいです。

エンティティに関する設計が固まっていないと書きづらい

開発途中でドメイン知識が増えるケースもあるかと思います。
クリーンアーキテクチャはエンティティ層に全てが依存しているため、エンティティ層に変更が入るとプログラムの全体に修正が入ります。

`User`というエンティティに`birthday`が必要になったので追加したら大変なことになったdiff
diff --git a/main.go b/main.go
index f058de7..97bd38d 100644
--- a/main.go
+++ b/main.go
@@ -9,2 +9,3 @@ import (
 	"net/mail"
+	"time"
 
@@ -22,2 +23,3 @@ type User struct {
 	email      string
+	birthday   time.Time
 	statusCode int
@@ -25,3 +27,3 @@ type User struct {
 
-func NewUser(id int, name string, email string, statusCode int) (*User, error) {
+func NewUser(id int, name string, email string, birthday time.Time, statusCode int) (*User, error) {
 	if id < 1 {
@@ -35,2 +37,5 @@ func NewUser(id int, name string, email string, statusCode int) (*User, error) {
 	}
+	if birthday.IsZero() {
+		return nil, errors.New("time must not zero value")
+	}
 	return &User{
@@ -43,6 +48,7 @@ func NewUser(id int, name string, email string, statusCode int) (*User, error) {
 
-func (u User) ID() int         { return u.id }
-func (u User) Name() string    { return u.name }
-func (u User) Email() string   { return u.email }
-func (u User) StatusCode() int { return u.statusCode }
+func (u User) ID() int             { return u.id }
+func (u User) Name() string        { return u.name }
+func (u User) Email() string       { return u.email }
+func (u User) Birthday() time.Time { return u.birthday }
+func (u User) StatusCode() int     { return u.statusCode }
 
@@ -66,6 +72,7 @@ func NewPostgresFindUserRepository(db *sqlx.DB) FindUserRepository {
 type PostgresUser struct {
-	Id         int    `db:"id"`
-	Name       string `db:"name"`
-	Email      string `db:"email"`
-	StatusCode int    `db:"status_code"`
+	Id         int       `db:"id"`
+	Name       string    `db:"name"`
+	Email      string    `db:"email"`
+	Birthday   time.Time `db:"birthday"`
+	StatusCode int       `db:"status_code"`
 }
@@ -80,3 +87,3 @@ func (r PostgresFindUserRepository) FindAll(ctx context.Context) ([]*User, error
 	for _, pgUser := range pgUsers {
-		user, err := NewUser(pgUser.Id, pgUser.Name, pgUser.Email, pgUser.StatusCode)
+		user, err := NewUser(pgUser.Id, pgUser.Name, pgUser.Email, pgUser.Birthday, pgUser.StatusCode)
 		if err != nil {
@@ -103,2 +110,3 @@ type S3User struct {
 	Email      string `json:"email"`
+	Birthday   string `json:"birthday"`
 	StatusCode int    `json:"status_code"`
@@ -111,2 +119,3 @@ func (r S3UploadUserRepository) Upload(ctx context.Context, user *User) error {
 		Email:      user.Email(),
+		Birthday:   user.Birthday().Format(time.RFC3339),
 		StatusCode: user.StatusCode(),
@@ -135,2 +144,3 @@ type UserDTO struct {
 	Email      string
+	Birthday   time.Time
 	StatusCode int
@@ -143,2 +153,3 @@ func userToDTO(u *User) *UserDTO {
 		Email:      u.Email(),
+		Birthday:   u.Birthday(),
 		StatusCode: u.StatusCode(),
@@ -148,3 +159,3 @@ func userToDTO(u *User) *UserDTO {
 func dtoToUser(dto *UserDTO) (*User, error) {
-	return NewUser(dto.ID, dto.Name, dto.Email, dto.StatusCode)
+	return NewUser(dto.ID, dto.Name, dto.Email, dto.Birthday, dto.StatusCode)
 }

従って、最初からクリーンアーキテクチャとしてのコードを書くことは難しいと感じました。
PoC開発などを行って、ある程度設計の見通しを立ててから導入する方が結果的に効率が良いと思いました。

まとめと所感

クリーンアーキテクチャの利点としてよく挙がる、変更に強い点、テストが書きやすい点などを、身を持って理解しました。
関心の分離、依存性注入などは強力な武器だと思います。

ところがその武器がかえって仇になっているとも感じました。
サクッとものを作るという時にはあまり向いていない設計方法ですが、長期間の付き合いをしていく必要のあるプロダクトコードなんかには向いていると思いました。

個人的には設計の見通しがよく、変更に強いコードができあがるので 「もうクリーンアーキテクチャに全部賭けていいのではないか?」 と思っております。

皆さんも利点と弱点を理解した上で、クリーンアーキテクチャを始めてみてはいかがでしょうか?

Discussion

たくやらわをん@Devたくやらわをん@Dev

単純なCRUD機能だけを持ったシステムにも適用するべきだと思いますか?
私も最近「クリーンアーキテクチャ」を勉強したり、調べたりしているんですが、「この規模だと冗長なのでは…」と思うことがあり、他の方がどう考えているのか気になりました。(冗長でも書くのは楽しいんですけどね…!)

mitsumitsu

長期間の運用が見込まれるのであれば導入したいと思いますね。あとは自分が知らない部分で変更が入りそうなインフラを使う場合は導入したいですね。(クラウドサービスのインフラを使っている場合とか)
個人的にはクリーンアーキテクチャでものを作るのが楽しいので、冗長になるとかは一旦無視して、クリーンアーキテクチャで書きがちです!

1
たくやらわをん@Devたくやらわをん@Dev

お返事ありがとうございます!

確かに、クラウドサービスのインフラとかはやった方が良さそうですね…!
クリーンアーキテクチャでモノを作るの楽しいのめちゃくちゃわかります!
私もガンガン書いていこうと思います!
お返事ありがとうございました!!

1