LoginSignup
0
0

Go で Clean Architecture 入門

Last updated at Posted at 2023-08-28

はじめに

Clean Architecture とは DB やフレームワークなどの外部技術に依存せず、ソフトウェアの中核部分(ビジネスを表現する部分)の独立性を高めることで、外部技術の変更に強いソフトウェアを実現することを目的とする。

以下は Clean Architecture の概念図です。図を見ながらポイントを解説していきます。

clean-architecture.jpg

「関心事の分離」を目的としたアーキテクチャ

  • ソフトウェアをレイヤーに分割。各レイヤーの責務を明確にし、レイヤー間の依存関係を制限することで、関心事を分離する
  • データベースとの連携を責務とするレイヤー、API 通信を責務とするレイヤー、ビジネスロジックを責務とするレイヤーなど
  • 関心事が分離されることで、他レイヤーの変更による影響を最小限にし、変更に強いアーキテクチャを実現する

4 つのレイヤーで構成される(4 つである必要はない。依存方向のルールを遵守する事が重要)

  • Frameworks & Drivers
    • フレームワークやデータベースに依存する実装を担当
  • Interface Adapters
    • Frameworks & Drivers と Application Business Rules の間のフォーマット変換を担当
    • 変換することで、Application Business Rules への外部技術の流入を防ぐ
  • Application Business Rules
    • ビジネスロジックを担当
    • ソフトウェアで何が出来るかを実装する
    • (例)人事管理システムの場合、「ユーザーを登録する」「ユーザーを削除する」などを表現する
  • Enterprise Business Rules
    • ソフトウェアの中核となる部分
    • エンティティなど最重要ビジネスルールの実装がされる
    • (例)人事管理システムの場合、「ユーザー」を表現するエンティティを実装する

レイヤー間の依存方向は外側(下位レイヤー)から内側(上位レイヤー)に向かう

  • 上位レイヤーは下位レイヤーの実装に依存しない(下位レイヤーの関数や構造体を参照してはいけない)
  • 上位レイヤーが下位レイヤーに依存すると、上位レイヤーの安定性が下がってしまうため
    • 上位レイヤー:ビジネスを表現したソフトウェアの中核になるため、強固な安定性が求められる
    • 下位レイヤー:外部の技術に依存する定義が多いため、変更が発生しやすい(変更が容易に出来る状態に保つ)
  • ↓ コンポーネントレベルの依存関係図。下位レイヤーから上位レイヤーに依存方向が向かう、且つ、レイヤー間の連携は抽象化されたインターフェースを介して行われる(変化しやすい具象への依存を避ける)。
    Sequence

以降は、サンプルコードを交えながら、解説していきます。

Architecture

今回は、以下の構成で実装していきます。

architecture

.
├── adapter
│   ├── controller
│   │   ├── user_handler.go
│   │   └── user_handler_mapper.go
│   └── gateway
│       ├── user_repository_impl.go
│       └── user_repository_mapper.go
├── domain
│   └── entity
│       └── user.go
├── infrastructure
│   └── db
│       └── xxx.go
└── usecase
    ├── user_repository_port.go
    ├── user_usecase_impl.go
    ├── user_usecase_mapper.go
    └── user_usecase_port.go

Infrastructure

フレームワークやデータベースに依存する実装を担当する。
データベース接続やマイグレーション、REST API の仕様定義など。

// db

type DBEnv struct {
	Name      string `env:"MYSQL_DATABASE"`
	User      string `env:"MYSQL_USER"`
	Password  string `env:"MYSQL_PASSWORD"`
	Address   string `env:"MYSQL_HOST"`
	Collation string `env:"MYSQL_COLLATION"`
}

func NewDB() *bun.DB {
	cfg := DBEnv{}
	if err := env.Parse(&cfg); err != nil {
		panic(err)
	}

	c := mysql.Config{
		User:                 cfg.User,
		Passwd:               cfg.Password,
		Net:                  "tcp",
		Addr:                 cfg.Address,
		DBName:               cfg.Name,
		Collation:            cfg.Collation,
		ParseTime:            true,
		AllowNativePasswords: true,
	}

	_db, err := sql.Open("mysql", c.FormatDSN())
	if err != nil {
		panic(err)
	}
	if err = _db.Ping(); err != nil {
		panic(err)
	}

	db := bun.NewDB(_db, mysqldialect.New())
	return db
}

Adapter

Web API やデータベースなど外部とのやり取りを担当する。
外部フォーマット ⇔ 上位レイヤーフォーマットの変換もこのレイヤーで行う。

外部とのやり取り・仕様に関する詳細を本レイヤーに隠蔽し、外部仕様変更に伴う上位レイヤーへの影響を最小限にする事が責務。
本アーキテクチャでは、Controller が Web API とのやり取りを担当し、Gateway がデータベースとのやり取りを担当する。

Controller

Web API とのやり取りを担当する。
リクエストデータを Use Case で扱うフォーマットに変換の上、Use Case レイヤーに連携する。レスポンスでは、Use Case レイヤーの出力データを Web API フォーマットに変換して返却する。
Use Case レイヤーとの連携は、Use Case Port (インターフェース)を介して行う。

※ クリーンアーキテクチャの概念図(Flow of control の部分)やコンポーネントレベルの依存関係図に従うと、Use Case からの出力は Presenter で処理するべきですが、今回は Controller で処理しています。Presenter は UI を意識したフォーマット変換を行うイメージであり、今回の Web API レスポンスとは責務が異なると考えたため。

// user_handler.go

func (h *UserHandler) AddUser(w http.ResponseWriter, r *http.Request) {
	req, err := ToDTO(r.Body) // Web API フォーマットから Use Case フォーマットへの変換
	if err != nil {
		HttpError(w, err)
		return
	}

	result, err := h.usecase.AddUser(r.Context(), req)
	if err != nil {
		HttpError(w, err)
		return
	}

	h.HandleOK(w, FromDTO(result)) // Use Case フォーマットから Web API フォーマットへの変換
}

Web API フォーマットと Use Case フォーマットの変換は、Controller Mapper を参照する。

Controller Mapper

Web API フォーマットと Use Case フォーマットの変換を行う。
アプリケーションエラーから HTTP エラーへの変換も行う。(詳細は Error Strategy を参照)

※ 詳細省きますが、API レスポンスは OpenAPI で生成したモデルを参照しています

// user_handler_mapper.go

// リクエストを Use Case フォーマットに変換
func ToDTO(
	body io.ReadCloser,
) (*usecase.User, *pkgErr.ApplicationError) {
	var dto usecase.User
	if err := json.NewDecoder(body).Decode(&dto); err != nil {
		return nil, pkgErr.NewApplicationError(err.Error(), pkgErr.LevelWarn, pkgErr.CodeBadRequest)
	}
	return &usecase.User{
		ID:        dto.ID,
		FirstName: dto.FirstName,
		LastName:  dto.LastName,
		Age:       dto.Age,
	}, nil
}

// Use Case フォーマットを API レスポンスに変換
func FromDTO(
	dto *usecase.User,
) *User {
	return &User{
		Id:        dto.ID,
		FirstName: dto.FirstName,
		LastName:  dto.LastName,
		Age:       int(dto.Age),
	}
}

Gateway

データベースとのやり取りを担当する。Repository Port(インターフェース)の実装。
Use Case レイヤーから連携された Entity を ORM モデルに変換し、データベースとのやり取りを行う。反対に、Use Case レイヤーへの返却時は、ORM モデルを Entity に変換して返却する。

// user_repository_impl.go

func (u *UserRepositoryImpl) Save(ctx context.Context, entity *entity.User) (*entity.User, *pkgErr.ApplicationError) {
	tx := ctx.Value(TX_KEY).(*bun.Tx)

	user := FromEntity(entity) // Entity を ORM モデルに変換
	if _, err := tx.NewInsert().Model(user).Exec(ctx); err != nil {
		return nil, RepositoryError(err)
	}
	return user.ToEntity(), nil // ORM モデルを Entity に変換
}

ORM モデルと Entity の変換は、Gateway Mapper を参照する。

Gateway Mapper

ORM モデルと Entity の変換を行う。
データベースエラーからアプリケーションエラーへの変換も行う。(詳細は Error Strategy を参照)

// user_repository_mapper.go

// ORM モデル
type User struct {
	ID        string    `bun:"id,pk,type:uuid,default:gen_random_uuid()"`
	FirstName string    `bun:"first_name,notnull"`
	LastName  string    `bun:"last_name,notnull"`
	Age       int32     `bun:"age,notnull"`
	CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"`
	UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"`
}


// ORM モデルを Entity に変換
func (u *User) ToEntity() *entity.User {
	return &entity.User{
		ID:        u.ID,
		FirstName: u.FirstName,
		LastName:  u.LastName,
		Age:       u.Age,
		CreatedAt: u.CreatedAt,
		UpdatedAt: u.UpdatedAt,
	}
}


// Entity を ORM モデルに変換
func FromEntity(
	entity *entity.User,
) *User {
	return &User{
		ID:        entity.ID,
		FirstName: entity.FirstName,
		LastName:  entity.LastName,
		Age:       entity.Age,
		CreatedAt: entity.CreatedAt,
		UpdatedAt: entity.UpdatedAt,
	}
}

Use Case

ビジネスロジック(ソフトウェアで何が出来るかの表現)を担当する。Use Case Port(インターフェース)の実装。
Use Case フォーマットから Entity への変換を行い、Repository に渡す。Repository との連携は、Repository Port を介して行う。

// user_usecase_port.go

type UserUsecase interface {
	AddUser(ctx context.Context, dto *User) (*User, *pkgErr.ApplicationError)
}
// user_usecase_impl.go

func (u *UserUsecaseImpl) AddUser(
	ctx context.Context,
	dto *User,
) (*User, *pkgErr.ApplicationError) {
	entity, err := u.userRepository.Save(ctx, dto.ToEntity()) // Use Case フォーマットを Entity に変換
	if err != nil {
		return nil, err
	}
	return FromEntity(entity), nil // Entity を Use Case フォーマットに変換
}

Use Case フォーマットと Entity の変換は、Use Case Mapper を参照する。

Use Case Mapper

Use Case フォーマット と Entity の変換を行う。

// user_usecase_mapper.go

// Use Case フォーマット
type User struct {
	ID        string
	FirstName string
	LastName  string
	Age       int32
}

// Use Case フォーマットを Entity に変換
func (u *User) ToEntity() *entity.User {
	return &entity.User{
		FirstName: u.FirstName,
		LastName:  u.LastName,
		Age:       u.Age,
	}
}

// Entity を Use Case フォーマットに変換
func FromEntity(
	entity *entity.User,
) *User {
	return &User{
		ID:        entity.ID,
		FirstName: entity.FirstName,
		LastName:  entity.LastName,
		Age:       entity.Age,
	}
}

レイヤー間の依存方向は外側(下位レイヤー)から内側(上位レイヤー)に向かう」で述べた通り、依存方向を下位レイヤー → 上位レイヤーに限定する必要があるため、Repository Port は (Gateway 側ではなく)Use Case 側に定義。

// user_repository_port.go

type UserRepository interface {
	Save(ctx context.Context, e *entity.User) (*entity.User, *pkgErr.ApplicationError)
}

Domain

ビジネスルールを表現し、どのレイヤーにも依存しないソフトウェアの中核となる部分。

Entity

// entity/user.go

// Entity
type User struct {
	ID        string
	FirstName string
	LastName  string
	Age       int32
	CreatedAt time.Time
	UpdatedAt time.Time
}

Error Strategy

アプリケーション独自のカスタムエラーを定義。外部エラーをカスタムエラーにマッピングすることで、外部技術の詳細を隠蔽する方針。
メッセージ、レベル、コードの 3 要素で構成され、レベルとコードによってエラーの重要度を表現し、よしなにハンドリングする。

データベースエラーをカスタムエラーに変換し、上位レイヤーへの詳細の流入を防いだり、カスタムエラーを HTTP エラーに変換し、適切なレスポンスを返却する用途で利用する。

// Custom Error

type ErrorLevel int8

const (
	_ ErrorLevel = iota
	LevelInfo
	LevelWarn
	LevelError
)

type ErrorCode int

const (
	_ ErrorCode = iota
	CodeBadRequest
	CodeNotFound
	CodeDuplicate
	CodeInternalServerError
)

type ApplicationError struct {
	message string
	level   ErrorLevel
	code    ErrorCode
}

func (e *ApplicationError) Error() string {
	return e.message
}

func (e *ApplicationError) Level() ErrorLevel {
	return e.level
}

func (e *ApplicationError) Code() ErrorCode {
	return e.code
}

func NewApplicationError(message string, level ErrorLevel, code ErrorCode) *ApplicationError {
	return &ApplicationError{
		message: message,
		level:   level,
		code:    code,
	}
}

Database Error Mapping

// user_repository_mapper.go

func RepositoryError(err error) *pkgErr.ApplicationError {
	switch err {
	case sql.ErrNoRows:
		return pkgErr.NewApplicationError(err.Error(), pkgErr.LevelWarn, pkgErr.CodeNotFound)
	default:
		return pkgErr.NewApplicationError(err.Error(), pkgErr.LevelError, pkgErr.CodeInternalServerError)
	}
}

Usage

// user_repository_impl.go

func (u *UserRepositoryImpl) Save(ctx context.Context, entity *entity.User) (*entity.User, *pkgErr.ApplicationError) {
	tx := ctx.Value(TX_KEY).(*bun.Tx)

	user := FromEntity(entity)
	if _, err := tx.NewInsert().Model(user).Exec(ctx); err != nil {
		return nil, RepositoryError(err) // データベースエラーをカスタムエラーに変換
	}
	return user.ToEntity(), nil
}

HTTP Error Mapping

// user_handler_mapper.go

func NotFoundError(w http.ResponseWriter, err *pkgErr.ApplicationError) {
	setHeaderContentType(w)
	w.WriteHeader(http.StatusNotFound)
	json.NewEncoder(w).Encode(Error{
		Code:    http.StatusNotFound,
		Message: err.Error(),
	})
}

func InternalServerError(w http.ResponseWriter, err *pkgErr.ApplicationError) {
	setHeaderContentType(w)
	w.WriteHeader(http.StatusInternalServerError)
	json.NewEncoder(w).Encode(Error{
		Code:    http.StatusInternalServerError,
		Message: err.Error(),
	})
}

func HttpError(w http.ResponseWriter, err *pkgErr.ApplicationError) {
	switch err.Code() {
	case pkgErr.CodeNotFound:
		NotFoundError(w, err)
	default:
		InternalServerError(w, err)
	}
}

Usage

// user_handler.go

func (h *UserHandler) AddUser(w http.ResponseWriter, r *http.Request) {
	req, err := ToDTO(r.Body)
	if err != nil {
		HttpError(w, err) // カスタムエラーを HTTP エラーに変換
		return
	}

	result, err := h.usecase.AddUser(r.Context(), req)
	if err != nil {
		HttpError(w, err) // カスタムエラーを HTTP エラーに変換
		return
	}

	h.HandleOK(w, FromDTO(result))
}

まとめ

外部技術への依存やレイヤー間の依存を制限し、関心事を分離することで、ソフトウェアの中核部分(ビジネスを表現する部分)の独立性が高まる。その結果、変更に強いソフトウェアを実現できる点は、とても魅力的だと感じました。新しく外部との連携を行う場合(例えば、gRPC の口を増やしたり)や、外部技術を別のものに置き換える場合にも、その影響範囲を限定できるのは、変更容易性の観点からも良いと思いました。
ただ、その反面、多少冗長的になり、実装コストが増加することも否めないのかなと。

Clean Architecture の核となる考え方は踏襲しつつ、実装コストとのバランスを考慮しながら、よしなにアレンジするのが良さそうです。

参考

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