Go言語によるクリーンアーキテクチャの実装例紹介
はじめに
CastingONEでバックエンドエンジニアをやっている清水です。
この記事ではクリーンアーキテクチャについて学んだけど具体的にどのように実装すれば良いのかという悩みがあったので実装例をまとめてみた記事になります。
クリーンアーキテクチャで実装されたサンプル実装のうちGitHubのスター数が多いリポジトリをピックアップして、設計内容を紹介していきます。
具体的にどこにどんな実装をするべきなのかも含めて紹介していきます。
処理を一部省略して紹介するため実際の処理内容を確認したい場合はGitHubでご確認お願いします。
クリーンアーキテクチャとは
クリーンアーキテクチャは、ソフトウェア設計の原則を適用して、依存性の方向性を逆転させ、ビジネスロジックから詳細(フレームワークやデータベース)を分離するアーキテクチャパターンです。これにより、テストしやすく、メンテナンス性が高く、柔軟性のあるシステムを実現することができます。
紹介するリポジトリ
golang-clean-architecture-banking-transfer
概要
銀行業務に関するシステムの実装例です。
口座の作成、口座の一覧表示、特定の口座の残高確認、口座間の送金の容易化、送金記録の作成など、さまざまな銀行業務向けに設計されたものです。
ディレクトリ構成
後ほど紹介する送金APIに関連するところに絞った上でディレクトリ構成をまとめました。
go-bank-transfer
├── adapter
│ ├── api
│ │ ├── action
│ │ │ ├── create_transfer.go
│ │ │ ├── create_transfer_test.go
│ │ ├── logging
│ │ │ ├── error.go
│ │ │ └── info.go
│ │ ├── middleware
│ │ │ └── logger.go
│ │ └── response
│ │ ├── error.go
│ │ └── success.go
│ ├── logger
│ │ └── logger.go
│ ├── presenter
│ │ ├── create_transfer.go
│ │ ├── create_transfer_test.go
│ ├── repository
│ │ ├── account_mongodb.go
│ │ ├── account_postgres.go
│ │ ├── nosql.go
│ │ ├── sql.go
│ │ ├── transfer_mongodb.go
│ │ └── transfer_postgres.go
│ └── validator
│ └── validator.go
├── domain
│ ├── account.go
│ ├── account_test.go
│ ├── money.go
│ ├── transfer.go
│ └── uuid.go
├── infrastructure
│ ├── database
│ │ ├── config.go
│ │ ├── factory_nosql.go
│ │ ├── factory_sql.go
│ │ ├── mongo_handler.go
│ │ ├── mongo_handler_deprecated.go
│ │ └── postgres_handler.go
│ ├── http_server.go
│ ├── log
│ │ ├── factory.go
│ │ ├── logger_mock.go
│ │ ├── logrus.go
│ │ └── zap.go
│ ├── router
│ │ ├── factory.go
│ │ ├── gin.go
│ │ └── gorilla_mux.go
│ └── validation
│ ├── factory.go
│ └── go_playground.go
├── main.go
└── usecase
├── create_transfer.go
└── create_transfer_test.go
アーキテクチャ図
ディレクトリごとにやっていること
ドメイン層
役割: この層は、ビジネスルールやドメインの主要なエンティティ(この場合、Transfer)を定義しています。また、ビジネスロジックやエンティティの状態の変更ロジックを持ちます。
アプリケーション層
役割: この層は、ドメイン層のエンティティやビジネスロジックを使用して、具体的なユースケース(この場合、送金の作成)を定義します。ここには、アプリケーションの主要なビジネスロジックが含まれ、外部とのインターフェース(データベース、外部APIなど)と直接のやり取りは行いません。
アダプタ層
役割: この層は、アプリケーション層と外部の世界(例: データベース、ウェブサーバーなど)との橋渡しを行います。具体的には、APIのリクエストハンドリング、データベースとのやり取り、他の外部サービスとの通信などがこの層で行われます。
インフラストラクチャ層
役割: この層は、具体的な技術の詳細や外部のライブラリ、フレームワークの実装を担当します。例えば、具体的なデータベースの接続詳細や、バリデーションライブラリの使用方法、外部APIとの接続方法などがここで実装されます。この例の中では、go_playground.goはバリデーションの具体的な実装を、postgres_handler.goやsql.goはデータベースとの具体的なやり取りを担当しています。
処理のフローを確認するエンドポイント
createTransfer(POST: 'http://localhost:3001/v1/transfers')
のエンドポイントを例に処理のフローを確認していきます。
このエンドポイントは銀行業務の送金処理に該当するAPIになります。
- リクエスト例
curl -i --request POST 'http://localhost:3001/v1/transfers' \
--header 'Content-Type: application/json' \
--data-raw '{
"account_origin_id": "'"$account_id1"'",
"account_destination_id": "'"$account_id2"'",
"amount": 1
}'
- レスポンス例
{
"id": "ce28a9bb-0c26-46c1-8fb2-8a76a20db8a5",
"account_origin_id": "67233d99-2ba1-4114-af90-a442b2b9c15b",
"account_destination_id": "0e52e3dc-6c76-4362-9ea3-6399829e2aa8",
"amount": 0.01,
"created_at": "2023-10-23T08:42:47Z"
}
処理のフロー
ここでは送金APIが叩かれてからDBにデータを登録してレスポンスを返すまでの一連の流れを実装方針と実装内容の利点を踏まえて追っていきます。
main.go
func main() {
var app = infrastructure.NewConfig().
Name(os.Getenv("APP_NAME")).
ContextTimeout(10 * time.Second).
Logger(log.InstanceLogrusLogger).
Validator(validation.InstanceGoPlayground).
DbSQL(database.InstancePostgres).
DbNoSQL(database.InstanceMongoDB)
app.WebServerPort(os.Getenv("APP_PORT")).
WebServer(router.InstanceGorillaMux).
Start()
}
- 実装方針
- エントリーポイントとして、APIサーバーを起動。
- ルーターはgorillaMux、RDBMSはPostgreSQL、NoSQLはMongoDBを利用
infrastructure/http_server.go & infrastructure/router/factory.go
func (c *config) Start() {
c.webServer.Listen()
}
type Server interface {
Listen()
}
- 実装方針
- factory.goのServerインターフェースはサーバの振る舞いを抽象化して定義。http_server.goはこの抽象に依存してサーバを起動。
- http_server.goは具体的なサーバ実装ではなく、Serverインターフェースに依存。これにより高レベルと低レベルのモジュールが抽象に依存。
- 利点
- 拡張性: 新しいサーバの種類や実装を追加しやすい。
- 再利用性: サーバの起動ロジックと振る舞いが独立しているため再利用が容易。
- 交換可能性: 異なるサーバ実装間での切り替えが簡単。
- テスト容易性: Serverインターフェースに基づくテストが容易。
infrastructure/router/gorilla_mux.go
func (g gorillaMux) Listen() {
g.setAppHandlers(g.router)
g.middleware.UseHandler(g.router)
server := &http.Server{
ReadTimeout: 5 * time.Second,
WriteTimeout: 15 * time.Second,
Addr: fmt.Sprintf(":%d", g.port),
Handler: g.middleware,
}
go func() {
if err := server.ListenAndServe(); err != nil {
g.log.WithError(err).Fatalln("Error starting HTTP server")
}
}()
if err := server.Shutdown(ctx); err != nil {
g.log.WithError(err).Fatalln("Server Shutdown Failed")
}
}
func (g gorillaMux) setAppHandlers(router *mux.Router) {
api := router.PathPrefix("/v1").Subrouter()
api.Handle("/transfers", g.buildCreateTransferAction()).Methods(http.MethodPost)
}
func (g gorillaMux) buildCreateTransferAction() *negroni.Negroni {
var handler http.HandlerFunc = func(res http.ResponseWriter, req *http.Request) {
var (
uc = usecase.NewCreateTransferInteractor(
repository.NewTransferSQL(g.db),
repository.NewAccountSQL(g.db),
presenter.NewCreateTransferPresenter(),
g.ctxTimeout,
)
act = action.NewCreateTransferAction(uc, g.log, g.validator)
)
act.Execute(res, req)
}
return negroni.New(
negroni.HandlerFunc(middleware.NewLogger(g.log).Execute),
negroni.NewRecovery(),
negroni.Wrap(handler),
)
}
- 実装方針
- ServerインターフェースのListenメソッドの実装
- サーバーの設定と起動。
- ルートの設定。
- エンドポイントのアクション生成。
- ServerインターフェースのListenメソッドの実装
- 利点
- 再利用性: ミドルウェアやアクションロジックの再利用が簡単。
- テスト容易性: 分離されたロジックにより、テストがしやすい。
- 拡張性: 既存のコードを大きく変更せずに新機能を追加可能。
adapter/api/action/create_transfer.go
type CreateTransferAction struct {
log logger.Logger
uc usecase.CreateTransferUseCase
validator validator.Validator
logKey, logMsg string
}
func NewCreateTransferAction(uc usecase.CreateTransferUseCase, log logger.Logger, v validator.Validator) CreateTransferAction {
return CreateTransferAction{
uc: uc,
log: log,
validator: v,
logKey: "create_transfer",
logMsg: "creating a new transfer",
}
}
func (t CreateTransferAction) Execute(w http.ResponseWriter, r *http.Request) {
var input usecase.CreateTransferInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
logging.NewError(
t.log,
err,
t.logKey,
http.StatusBadRequest,
).Log(t.logMsg)
response.NewError(err, http.StatusBadRequest).Send(w)
return
}
defer r.Body.Close()
if errs := t.validateInput(input); len(errs) > 0 {
logging.NewError(
t.log,
response.ErrInvalidInput,
t.logKey,
http.StatusBadRequest,
).Log(t.logMsg)
output, err := t.uc.Execute(r.Context(), input)
if err != nil {
t.handleErr(w, err)
return
}
}
func (t CreateTransferAction) validateInput(input usecase.CreateTransferInput) []string {
var (
msgs []string
errAccountsEquals = errors.New("account origin equals destination account")
accountIsEquals = input.AccountOriginID == input.AccountDestinationID
accountsIsEmpty = input.AccountOriginID == "" && input.AccountDestinationID == ""
)
err := t.validator.Validate(input)
if err != nil {
for _, msg := range t.validator.Messages() {
msgs = append(msgs, msg)
}
}
return msgs
}
- 実装方針
-
Transfer
のCreate処理の具体的なアクションのロジックが集約されている。 - 入力内容の検証とアクションの実行が分けられている。
- Executeメソッドは具体的な実装ではなくusecase/create_transfer.goのExecuteインターフェースに依存。
-
- 利点
- 再利用性: 異なる状況でのアクションの再利用が簡単。
- 保守性: 役割が明確で、変更やデバッグが容易。
usecase/create_transfer.go
type (
// CreateTransferUseCase input port
CreateTransferUseCase interface {
Execute(context.Context, CreateTransferInput) (CreateTransferOutput, error)
}
// CreateTransferInput input data
CreateTransferInput struct {
AccountOriginID string `json:"account_origin_id" validate:"required,uuid4"`
AccountDestinationID string `json:"account_destination_id" validate:"required,uuid4"`
Amount int64 `json:"amount" validate:"gt=0,required"`
}
// CreateTransferPresenter output port
CreateTransferPresenter interface {
Output(domain.Transfer) CreateTransferOutput
}
)
func (t createTransferInteractor) Execute(ctx context.Context, input CreateTransferInput) (CreateTransferOutput, error) {
err = t.transferRepo.WithTransaction(ctx, func(ctxTx context.Context) error {
transfer = domain.NewTransfer(
domain.TransferID(domain.NewUUID()),
domain.AccountID(input.AccountOriginID),
domain.AccountID(input.AccountDestinationID),
domain.Money(input.Amount),
time.Now(),
)
transfer, err = t.transferRepo.Create(ctxTx, transfer)
return nil
})
return t.presenter.Output(transfer), nil
}
- 実装方針
- domainに定義されたメソッドを呼び出してユースケースを組み立てる、資金移動の具体的なロジックがこの部分に集約されている。
- CreateTransferUseCaseインターフェースはExecuteの振る舞いを抽象化して定義。Executeメソッドはこの抽象に依存して送金作成のメソッドを実行。
- Createメソッドは具体的な実装ではなくdomain層のtransfer.goのCreateインターフェースに依存。
- 利点
- 柔軟性: インターフェースを介して動作するため、実装の変更が容易。
- 再利用性: 独立した構造により、他の部分やシステムでの再利用が可能。
domain/transfer.go & adapter/repository/transfer_postgres.go
type (
TransferRepository interface {
Create(context.Context, Transfer) (Transfer, error)
FindAll(context.Context) ([]Transfer, error)
WithTransaction(context.Context, func(context.Context) error) error
}
Transfer struct {
id TransferID
accountOriginID AccountID
accountDestinationID AccountID
amount Money
createdAt time.Time
}
)
func NewTransfer(
ID TransferID,
accountOriginID AccountID,
accountDestinationID AccountID,
amount Money,
createdAt time.Time,
) Transfer {
return Transfer{
id: ID,
accountOriginID: accountOriginID,
accountDestinationID: accountDestinationID,
amount: amount,
createdAt: createdAt,
}
}
func (t TransferSQL) Create(ctx context.Context, transfer domain.Transfer) (domain.Transfer, error) {
tx, ok := ctx.Value("TransactionContextKey").(Tx)
if !ok {
var err error
tx, err = t.db.BeginTx(ctx)
if err != nil {
return domain.Transfer{}, errors.Wrap(err, "error creating transfer")
}
}
var query = `
INSERT INTO
transfers (id, account_origin_id, account_destination_id, amount, created_at)
VALUES
($1, $2, $3, $4, $5)
`
if err := tx.ExecuteContext(
ctx,
query,
transfer.ID(),
transfer.AccountOriginID(),
transfer.AccountDestinationID(),
transfer.Amount(),
transfer.CreatedAt(),
); err != nil {
return domain.Transfer{}, errors.Wrap(err, "error creating transfer")
}
return transfer, nil
}
- 実装方針
- ドメインロジック(Transfer)とデータ操作のインターフェース(TransferRepository)を定義。
- PostgreSQL用にTransferRepositoryの具体的なデータベース操作を実装。
- ExecuteContextメソッドは具体的な実装ではなくsql.goのExecuteContextインターフェースに依存。
- 利点
- 疎結合: ビジネスロジックとデータベース操作が分離されているため、データベースの変更が容易。
- 拡張性: ビジネスルールの変更に対応しやすく、データ操作方法を変更する際も影響が少ない。
infrastructure/database/postgres_handler.go & adapter/repository/sql.go
type Tx interface {
ExecuteContext(context.Context, string, ...interface{}) error
QueryContext(context.Context, string, ...interface{}) (Rows, error)
QueryRowContext(context.Context, string, ...interface{}) Row
Commit() error
Rollback() error
}
func (p postgresTx) ExecuteContext(ctx context.Context, query string, args ...interface{}) error {
_, err := p.tx.ExecContext(ctx, query, args...)
if err != nil {
return err
}
return nil
}
- 実装方針
- データベース操作の抽象的なインターフェース(Tx)を定義。
- TxのPostgreSQL実装。
- 利点
- 独立性: Txを使用することで、データベース技術からの依存を減少。
- 再利用性: 他のデータベースに対応する際、同じTxインターフェースを使用して簡単に実装可能。
infrastructure/validation/go_playground.go
func (g *goPlayground) Validate(i interface{}) error {
if len(g.msg) > 0 {
g.msg = nil
}
g.err = g.validator.Struct(i)
if g.err != nil {
return g.err
}
return nil
}
- 実装方針
- goPlaygroundのValidateメソッドは、オブジェクトのバリデーションを実行する役割を果たす。
- 利点
- 汎用性: 様々なオブジェクトのバリデーションが可能。
おわりに
コードを読む中で、以前は完全には理解できなかったことがクリアになり、自社以外の実装例に触れる良い機会となりました。
プロジェクトの規模によっては、責務をどれだけ分けるかが悩ましいポイントで、過剰に分けることもあれば、さらに分けるべき場面もあると感じました。
これとは別に、internalディレクトリを作成し、特定のコードを外部からアクセスできなくするのも良いのでは思いました。ただ、これだとプロジェクトの状況によっては参照したいケースもありそうなので逆効果になることもあるなと感じました。結局はプロジェクトや組織の状況次第だなと思ったので、模試設計することがあれば今の点も考慮に入れてやっていこうと思います。
弊社ではいっしょに働いてくれるエンジニアを募集中です。社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、気軽にご連絡ください!
Discussion