はじめに
Clean Architecture とは DB やフレームワークなどの外部技術に依存せず、ソフトウェアの中核部分(ビジネスを表現する部分)の独立性を高めることで、外部技術の変更に強いソフトウェアを実現することを目的とする。
以下は Clean Architecture の概念図です。図を見ながらポイントを解説していきます。
「関心事の分離」を目的としたアーキテクチャ
- ソフトウェアをレイヤーに分割。各レイヤーの責務を明確にし、レイヤー間の依存関係を制限することで、関心事を分離する
- データベースとの連携を責務とするレイヤー、API 通信を責務とするレイヤー、ビジネスロジックを責務とするレイヤーなど
- 関心事が分離されることで、他レイヤーの変更による影響を最小限にし、変更に強いアーキテクチャを実現する
4 つのレイヤーで構成される(4 つである必要はない。依存方向のルールを遵守する事が重要)
- Frameworks & Drivers
- フレームワークやデータベースに依存する実装を担当
- Interface Adapters
- Frameworks & Drivers と Application Business Rules の間のフォーマット変換を担当
- 変換することで、Application Business Rules への外部技術の流入を防ぐ
- Application Business Rules
- ビジネスロジックを担当
- ソフトウェアで何が出来るかを実装する
- (例)人事管理システムの場合、「ユーザーを登録する」「ユーザーを削除する」などを表現する
- Enterprise Business Rules
- ソフトウェアの中核となる部分
- エンティティなど最重要ビジネスルールの実装がされる
- (例)人事管理システムの場合、「ユーザー」を表現するエンティティを実装する
レイヤー間の依存方向は外側(下位レイヤー)から内側(上位レイヤー)に向かう
- 上位レイヤーは下位レイヤーの実装に依存しない(下位レイヤーの関数や構造体を参照してはいけない)
- 上位レイヤーが下位レイヤーに依存すると、上位レイヤーの安定性が下がってしまうため
- 上位レイヤー:ビジネスを表現したソフトウェアの中核になるため、強固な安定性が求められる
- 下位レイヤー:外部の技術に依存する定義が多いため、変更が発生しやすい(変更が容易に出来る状態に保つ)
- ↓ コンポーネントレベルの依存関係図。下位レイヤーから上位レイヤーに依存方向が向かう、且つ、レイヤー間の連携は抽象化されたインターフェースを介して行われる(変化しやすい具象への依存を避ける)。
以降は、サンプルコードを交えながら、解説していきます。
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 の核となる考え方は踏襲しつつ、実装コストとのバランスを考慮しながら、よしなにアレンジするのが良さそうです。