こんにちは。
私は今、実務でクリーンアーキテクチャを採用しているサービスのバックエンド開発に携わっています。クリーンアーキテクチャについてはどれだけ記事を読んでも、分かったような分からないようなといった状態が続いていたのですが、実装をしてみることで理解が深まったような気がしています。
なので今回は自分と同じようにクリーンアーキテクチャについて理解に苦しんでいる人に向けて、手を動かしながらクリーンアーキテクチャを学べるような記事を書きました。
言語はGoを使用していますが、Goは文法がシンプルであるため、Goに親しみがない人でも実装内容は理解できるのではないかと思います。
プログラムはGitHubにて公開しているので、必要であれば見てもらえればと思います。
対象読者
- クリーンアーキテクチャについて記事や書籍を読んでみたけど、いまいち理解できていない人
- クリーンアーキテクチャの概念は理解しているけど、それをどう実装するのかに悩んでいる人
- クリーンアーキテクチャを理解するだけでなく、実装できるようになりたい人
クリーンアーキテクチャ概要
クリーンアーキテクチャについて調べると必ずと言っても良いほど目にするのが次の画像です。
クリーンアーキテクチャで実現したいことは「関心の分離」です。
「関心の分離」によって、テストがしやすく、変化に柔軟に対応できるアプリケーションを構築することができます。
クリーンアーキテクチャでは
- レイヤーに分割する
- 依存関係にルールを持たせる
ことで、「関心の分離」を実現します。
レイヤーの分割に関しては抽象度別にレイヤーを分割します。依存関係のルールに関しては、具体的で抽象度の低い(下位の)レイヤーが抽象度の高い(上位の)レイヤーに依存するようにします。
図に沿って説明すると、一番外側のレイヤ―が最も抽象度が低く、一番内側が最も抽象度が高くなっており、外側から内側のレイヤーに向けての依存のみを許可するのがクリーンアーキテクチャです。
制作物について
今回は極めてシンプルなブログアプリケーションをクリーンアーキテクチャに沿って作成します。記事の作成、閲覧、更新、削除といったCRUD処理を実装したいと思います。
プロジェクトのファイル・ディレクトリ構成は以下の通りです。
C:.
│ .gitignore
│ compose.override.yml
│ compose.override.yml.sample
│ compose.yml
│ Dockerfile
│ go.mod
│ go.sum
│ LICENSE
│ main.go
│ Makefile
│ README.md
│ schema.sql
│ sqlboiler.toml
├─.github
│ │ dependabot.yml
│ └─workflows
│ lint.yml
│ test.yml
├─adapter
│ ├─controller
│ │ article.go
│ ├─gateway
│ │ article.go
│ └─presenter
│ article.go
├─driver
│ db.go
│ router.go
├─entities
│ articles.go
│ boil_queries.go
│ boil_table_names.go
│ boil_types.go
│ boil_view_names.go
│ mysql_upsert.go
└─usecase
├─interactor
│ article.go
├─port
│ article.go
└─repository
article.go
まずアプリケーションの動作を確認したいという方は、
> git clone https://github.com/JunNishimura/clean-architecture-with-go
> cd ./clean-architecture-with-go
> make init
> make up
でアプリケーションを動かした後、curlコマンドを使ってAPIを叩いてもらえればと思います。
> curl -i -XPOST -d '{"title": "test", "body": "this is a test article"}' localhost:8080/articles
> curl -i -XGET localhost:8080/articles
> curl -i -XGET localhost:8080/articles/1
> curl -i -XPUT -d '{"title": "updated title", "body": "this article is updated"}' localhost:8080/articles/1
> curl -i -XDELETE localhost:8080/articles/1
Entities
一番内側のEnterprise Business Rulesと書かれているこのレイヤーではコアとなるビジネスロジックを実装します。
このレイヤーは図から見て取れるように、他のどのレイヤーにも依存してはいけません。他のレイヤーに依存しなくなることで、このレイヤーの安定性が増します。
今回はSQL BoilerというORMライブラリを利用して、コードを生成しています。
MySQLコンテナを立ち上げた後、次のSQLを実行してテーブルを作成します。
CREATE TABLE `articles` (
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`body` text NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
その後に以下のsqlboilerコマンドを実行するとコードが自動生成されます。
> sqlboiler mysql -c sqlboiler.toml -o entities -p entities --no-tests --wipe
コマンドに出てくるsqlboiler.tomlの中身は以下のようになっています。
[mysql]
dbname = "db"
host = "localhost"
port = "3306"
user = "user"
pass = "password"
sslmode = "false"
Usecases
ビジネスロジックのAPIを記述することで、このソフトウェアは何を実現するのかを表現するのがこのレイヤーです。
Usecaseはinteractor
, port
, repository
で構成されています。port
とrepository
は下位レイヤーのインターフェースになっています。具体的には、inputPort
がcontroller
、outputPort
がpresenter
、repository
がgateway
のインターフェースになっています。
まずport
で入力と出力のインターフェースを定義します。
package port
import (
"context"
"github.com/JunNishimura/clean-architecture-with-go/entities"
)
type ArticleInput interface {
FindAll(context.Context)
FindByID(context.Context, int64)
Create(context.Context, *entities.Article)
Update(ctx context.Context, articleID int64, title, body *string)
Delete(context.Context, int64)
}
type ArticleOutput interface {
Render(ctx context.Context, body any, status int)
}
repository
では一つ下のレイヤーのgateway
のインターフェースを定義します。
package repository
import (
"context"
"github.com/JunNishimura/clean-architecture-with-go/entities"
)
type Article interface {
FindAll(context.Context) ([]*entities.Article, error)
FindByID(context.Context, int64) (*entities.Article, error)
Create(context.Context, *entities.Article) error
Update(ctx context.Context, articleID int64, title, body *string) error
Delete(context.Context, int64) error
}
interactor
はoutputPort
とrepository
を使って、インターフェースであるinputPort
を満たすように実装します。
package interactor
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"github.com/JunNishimura/clean-architecture-with-go/entities"
"github.com/JunNishimura/clean-architecture-with-go/usecase/port"
"github.com/JunNishimura/clean-architecture-with-go/usecase/repository"
)
type Article struct {
outputPort port.ArticleOutput
repository repository.Article
}
func NewArticle(outputPort port.ArticleOutput, repository repository.Article) port.ArticleInput {
return &Article{
outputPort: outputPort,
repository: repository,
}
}
type ErrResponse struct {
Message string `json:"message"`
Details []string `json:"details,omitempty"`
}
func (a *Article) FindAll(ctx context.Context) {
articles, err := a.repository.FindAll(ctx)
if err != nil {
a.outputPort.Render(ctx, &ErrResponse{
Message: err.Error(),
}, http.StatusInternalServerError)
return
}
a.outputPort.Render(ctx, articles, http.StatusOK)
}
func (a *Article) FindByID(ctx context.Context, articleID int64) {
article, err := a.repository.FindByID(ctx, articleID)
if err != nil {
var status int
if errors.Is(err, sql.ErrNoRows) {
status = http.StatusNotFound
} else {
status = http.StatusInternalServerError
}
a.outputPort.Render(ctx, &ErrResponse{
Message: fmt.Sprintf("could not find article by '%d'", articleID),
}, status)
return
}
a.outputPort.Render(ctx, article, http.StatusOK)
}
func (a *Article) Create(ctx context.Context, newArticle *entities.Article) {
if err := a.repository.Create(ctx, newArticle); err != nil {
a.outputPort.Render(ctx, &ErrResponse{
Message: err.Error(),
}, http.StatusInternalServerError)
return
}
rsp := struct {
ID int64 `json:"id"`
}{ID: newArticle.ID}
a.outputPort.Render(ctx, rsp, http.StatusOK)
}
func (a *Article) Update(ctx context.Context, articleID int64, title, body *string) {
if err := a.repository.Update(ctx, articleID, title, body); err != nil {
a.outputPort.Render(ctx, &ErrResponse{
Message: err.Error(),
}, http.StatusInternalServerError)
return
}
a.outputPort.Render(ctx, struct{}{}, http.StatusOK)
}
func (a *Article) Delete(ctx context.Context, articleID int64) {
if err := a.repository.Delete(ctx, articleID); err != nil {
a.outputPort.Render(ctx, &ErrResponse{
Message: err.Error(),
}, http.StatusInternalServerError)
return
}
a.outputPort.Render(ctx, struct{}{}, http.StatusOK)
}
Interface & Adapters
入力(controller)、出力(presenter)、データ永続化処理(gateway)を記述するレイヤーです。
controllerはゲームのコントローラーと同じようにユーザーからの入力を解釈・変換しユースケースに伝えます。各メソッドはhttp.HandlerFunc
型を満たすように引数として、ResponseWriter
と*Request
を受け付けています。
package controller
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/JunNishimura/clean-architecture-with-go/adapter/gateway"
"github.com/JunNishimura/clean-architecture-with-go/adapter/presenter"
"github.com/JunNishimura/clean-architecture-with-go/entities"
"github.com/JunNishimura/clean-architecture-with-go/usecase/interactor"
"github.com/go-chi/chi/v5"
)
type article struct {
db *sql.DB
}
func NewArticle(db *sql.DB) *article {
return &article{
db: db,
}
}
func (a *article) FindAll(w http.ResponseWriter, r *http.Request) {
outputPort := presenter.NewArticle(w)
repository := gateway.NewArticleRepository(a.db)
inputPort := interactor.NewArticle(outputPort, repository)
inputPort.FindAll(r.Context())
}
func (a *article) FindByID(w http.ResponseWriter, r *http.Request) {
outputPort := presenter.NewArticle(w)
repository := gateway.NewArticleRepository(a.db)
inputPort := interactor.NewArticle(outputPort, repository)
strArticleID := chi.URLParam(r, "articleID")
articleID, err := strconv.ParseInt(strArticleID, 10, 64)
if err != nil {
outputPort.Render(r.Context(), &presenter.ErrResponse{
Message: fmt.Sprintf("invalid article id '%s'", strArticleID),
}, http.StatusBadRequest)
return
}
inputPort.FindByID(r.Context(), articleID)
}
func (a *article) Create(w http.ResponseWriter, r *http.Request) {
outputPort := presenter.NewArticle(w)
repository := gateway.NewArticleRepository(a.db)
inputPort := interactor.NewArticle(outputPort, repository)
var b struct {
Title string `json:"title"`
Body string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
outputPort.Render(r.Context(), &presenter.ErrResponse{
Message: err.Error(),
}, http.StatusInternalServerError)
return
}
newArticle := &entities.Article{
Title: b.Title,
Body: b.Body,
}
inputPort.Create(r.Context(), newArticle)
}
func (a *article) Update(w http.ResponseWriter, r *http.Request) {
outputPort := presenter.NewArticle(w)
repository := gateway.NewArticleRepository(a.db)
inputPort := interactor.NewArticle(outputPort, repository)
strArticleID := chi.URLParam(r, "articleID")
articleID, err := strconv.ParseInt(strArticleID, 10, 64)
if err != nil {
outputPort.Render(r.Context(), &presenter.ErrResponse{
Message: fmt.Sprintf("invalid article id '%s'", strArticleID),
}, http.StatusBadRequest)
return
}
var b struct {
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
outputPort.Render(r.Context(), &presenter.ErrResponse{
Message: err.Error(),
}, http.StatusInternalServerError)
return
}
inputPort.Update(r.Context(), articleID, b.Title, b.Body)
}
func (a *article) Delete(w http.ResponseWriter, r *http.Request) {
outputPort := presenter.NewArticle(w)
repository := gateway.NewArticleRepository(a.db)
inputPort := interactor.NewArticle(outputPort, repository)
strArticleID := chi.URLParam(r, "articleID")
articleID, err := strconv.ParseInt(strArticleID, 10, 64)
if err != nil {
outputPort.Render(r.Context(), &presenter.ErrResponse{
Message: fmt.Sprintf("invalid article id '%s'", strArticleID),
}, http.StatusBadRequest)
return
}
inputPort.Delete(r.Context(), articleID)
}
gatewayではDBとのやり取りを記述します。
package gateway
import (
"context"
"database/sql"
"github.com/JunNishimura/clean-architecture-with-go/entities"
"github.com/JunNishimura/clean-architecture-with-go/usecase/repository"
"github.com/volatiletech/sqlboiler/v4/boil"
)
type articleRepository struct {
db *sql.DB
}
func NewArticleRepository(db *sql.DB) repository.Article {
return &articleRepository{
db: db,
}
}
func (r *articleRepository) FindAll(ctx context.Context) ([]*entities.Article, error) {
return entities.Articles().All(ctx, r.db)
}
func (r *articleRepository) FindByID(ctx context.Context, articleID int64) (*entities.Article, error) {
return entities.FindArticle(ctx, r.db, articleID)
}
func (r *articleRepository) Create(ctx context.Context, newArticle *entities.Article) error {
return newArticle.Insert(ctx, r.db, boil.Infer())
}
func (r *articleRepository) Update(ctx context.Context, articleID int64, title, body *string) error {
article, err := entities.FindArticle(ctx, r.db, articleID)
if err != nil {
return err
}
if title != nil {
article.Title = *title
}
if body != nil {
article.Body = *body
}
if _, err := article.Update(ctx, r.db, boil.Infer()); err != nil {
return err
}
return nil
}
func (r *articleRepository) Delete(ctx context.Context, articleID int64) error {
article, err := entities.FindArticle(ctx, r.db, articleID)
if err != nil {
return err
}
if _, err := article.Delete(ctx, r.db); err != nil {
return err
}
return nil
}
presenterでは出力に関する定義を記述します。ここではレスポンスをJSON形式に変換して出力しています。
package presenter
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/JunNishimura/clean-architecture-with-go/usecase/port"
)
type article struct {
w http.ResponseWriter
}
func NewArticle(w http.ResponseWriter) port.ArticleOutput {
return &article{
w: w,
}
}
type ErrResponse struct {
Message string `json:"message"`
Details []string `json:"details,omitempty"`
}
func (a *article) Render(ctx context.Context, body any, status int) {
a.w.Header().Set("Content-Type", "applications/json; charset=utf-8")
bodyBytes, err := json.Marshal(body)
if err != nil {
a.w.WriteHeader(http.StatusInternalServerError)
rsp := ErrResponse{
Message: http.StatusText(http.StatusInternalServerError),
}
if err := json.NewEncoder(a.w).Encode(rsp); err != nil {
log.Printf("fail to write response error: %v", err)
}
return
}
a.w.WriteHeader(status)
if _, err := fmt.Fprintf(a.w, "%s", bodyBytes); err != nil {
log.Printf("fail to write response: %v", err)
}
}
Frameworks & Drivers
このレイヤーではDBのコネクション確立やルーティングの設定、フレームワークの利用を記述します。
DBにはMySQLを、ルーティングにはchiというライブラリを使用しています。
package driver
import (
"database/sql"
"fmt"
"os"
_ "github.com/go-sql-driver/mysql"
)
func connectDB() (*sql.DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s",
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_HOST"),
os.Getenv("DB_PORT"),
os.Getenv("DB_NAME"))
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("fail to connect DB: %v", err)
}
return db, nil
}
package driver
import (
"fmt"
"net/http"
"github.com/JunNishimura/clean-architecture-with-go/adapter/controller"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func Run() error {
r := chi.NewRouter()
r.Use(middleware.Logger)
db, err := connectDB()
if err != nil {
return err
}
articleController := controller.NewArticle(db)
r.Route("/articles", func(r chi.Router) {
r.Post("/", articleController.Create)
r.Get("/", articleController.FindAll)
r.Route("/{articleID}", func(r chi.Router) {
r.Get("/", articleController.FindByID)
r.Put("/", articleController.Update)
r.Delete("/", articleController.Delete)
})
})
if err := http.ListenAndServe(":80", r); err != nil {
return fmt.Errorf("fail to listen and serve: %v", err)
}
return nil
}
まとめ
今回は極めてシンプルなアプリケーションをクリーンアーキテクチャで構築しました。実務などではドメインの数も増えてもっと複雑になるかと思いますが、今回のハンズオンを通じて少しでもクリーンアーキテクチャでアプリケーションを構築するイメージが沸いていただければ幸いです。
冒頭でも述べましたがクリーンアーキテクチャで重要なのは「関心の分離」であり、その実現方法は明確に定義されていません。あくまで今回の実装はクリーンアーキテクチャの一例なので、もし読者の皆様がクリーンアーキテクチャを実装する際は、自分の作りたいソフトウェアに応じて最適な実装方法を模索して頂ければと思います。
その際に今回のサンプルが役に立てば私としては嬉しい限りです。
参考文献
記事