LoginSignup
0
1

手を動かしながら学ぶクリーンアーキテクチャ

Last updated at Posted at 2023-09-30

こんにちは。

私は今、実務でクリーンアーキテクチャを採用しているサービスのバックエンド開発に携わっています。クリーンアーキテクチャについてはどれだけ記事を読んでも、分かったような分からないようなといった状態が続いていたのですが、実装をしてみることで理解が深まったような気がしています。

なので今回は自分と同じようにクリーンアーキテクチャについて理解に苦しんでいる人に向けて、手を動かしながらクリーンアーキテクチャを学べるような記事を書きました。

言語はGoを使用していますが、Goは文法がシンプルであるため、Goに親しみがない人でも実装内容は理解できるのではないかと思います。

プログラムはGitHubにて公開しているので、必要であれば見てもらえればと思います。

対象読者

  • クリーンアーキテクチャについて記事や書籍を読んでみたけど、いまいち理解できていない人
  • クリーンアーキテクチャの概念は理解しているけど、それをどう実装するのかに悩んでいる人
  • クリーンアーキテクチャを理解するだけでなく、実装できるようになりたい人

クリーンアーキテクチャ概要

クリーンアーキテクチャについて調べると必ずと言っても良いほど目にするのが次の画像です。

クリーンアーキテクチャで実現したいことは「関心の分離」です。

「関心の分離」によって、テストがしやすく、変化に柔軟に対応できるアプリケーションを構築することができます。

クリーンアーキテクチャでは

  1. レイヤーに分割する
  2. 依存関係にルールを持たせる

ことで、「関心の分離」を実現します。

レイヤーの分割に関しては抽象度別にレイヤーを分割します。依存関係のルールに関しては、具体的で抽象度の低い(下位の)レイヤーが抽象度の高い(上位の)レイヤーに依存するようにします。

図に沿って説明すると、一番外側のレイヤ―が最も抽象度が低く、一番内側が最も抽象度が高くなっており、外側から内側のレイヤーに向けての依存のみを許可するのがクリーンアーキテクチャです。

制作物について

今回は極めてシンプルなブログアプリケーションをクリーンアーキテクチャに沿って作成します。記事の作成、閲覧、更新、削除といった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で構成されています。portrepositoryは下位レイヤーのインターフェースになっています。具体的には、inputPortcontrolleroutputPortpresenterrepositorygatewayのインターフェースになっています。

まず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
}

interactoroutputPortrepositoryを使って、インターフェースである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
}

まとめ

今回は極めてシンプルなアプリケーションをクリーンアーキテクチャで構築しました。実務などではドメインの数も増えてもっと複雑になるかと思いますが、今回のハンズオンを通じて少しでもクリーンアーキテクチャでアプリケーションを構築するイメージが沸いていただければ幸いです。

冒頭でも述べましたがクリーンアーキテクチャで重要なのは「関心の分離」であり、その実現方法は明確に定義されていません。あくまで今回の実装はクリーンアーキテクチャの一例なので、もし読者の皆様がクリーンアーキテクチャを実装する際は、自分の作りたいソフトウェアに応じて最適な実装方法を模索して頂ければと思います。

その際に今回のサンプルが役に立てば私としては嬉しい限りです。

参考文献

記事

0
1
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
1