2024年12月24日
こんにちは。AI/BI部の塚本です。
最近、ドメイン駆動設計(DDD)のレイヤードアーキテクチャを学びはじめました。
前回の記事で、Remixとバックエンドの連携を記事にすると書きましたが、この勉強成果をいったんアウトプットしたくなったので、今回のブログのネタにしたいと思います。
私なりに勉強した内容を簡単にまとめた記事となります。
私と同じ立場(レイヤードアーキテクチャ初心者)の方にとって、最初に触れるとっかかりとして分かりやすいのではないかと思います。
少しずつ、段階を踏んで見ていきましょう。
DDDとは、Domain-Driven Design(ドメイン駆動設計)の略です。
ドメイン、つまりは業務知識を主軸において設計していくという思想です。
勿論、実装する際には、アプリケーションのインターフェースや処理そのもの(ユースケース)もとても重要ですが、設計においては、「ドメイン」が一番の鍵となります。
DDDについて調べていると、「ユビキタス言語」という言葉にたびたび出会います。
かっこよく名前を付けられていますが、目指していることはかなり愚直で地道なものです。
実際の業務をする方々とシステムを作る方々(システム設計者としての私たち)が業務について共通の認識を持っている状態にすること。そして、その共通認識をお互いが正確に理解しあえるドキュメント、図に落とし込むことです。
実際にどのようなドキュメントやモデル図を作成して共通認識を明文化すべきかは、ここでは触れません。
ですが、DDDにおいては、何よりもまずドメインが重要であるということ。これをまずは頭に入れてもらえればと思います。
レイヤードアーキテクチャは、その名の通り、層を形成していくような設計です。
今回はDDDを実現するための手段としてのレイヤードアーキテクチャについてを主軸に説明します。
まずは、全体を俯瞰する図を見てみます。
多くの偉大なエンジニアたちがレイヤードアーキテクチャの解説記事を投稿をしていますが、微妙に異なる名前の層で解説をしています。
私の説明では上記の図(Excalidraw(https://excalidraw.com)というツールで作りました!かわいくフローを作成できておすすめ!)を基に説明していきます。
図の矢印は依存関係を表しており、例えば、usecaseやinfrastructureはdomainに依存しています。
図の破線矢印は利用関係を表しています。これは、あくまで利用関係であり、依存しているわけではありません。domainというルールに依存しながら、usecase層とinfrastructure層はやり取りをしています。
矢印をたどっていくと、domain層を中心に依存関係が広がっているのが分かると思います。
それでは、domainから徐々に全体をたどっていきたいと思います。
domainは、業務の知識、ルールを定義する層です。
重要なのは、この層はあくまで知識、ルールの定義が目的であり具体的な処理をすることはないということです。このdomain層はソースコード内に展開されたシステムの仕様書のようなものです。usecase層はこの仕様書に従ってinfrastructure層を利用し、infrastructure層は仕様書に従った実際のデータ操作を記述します。仕様書そのものが業務を遂行しないように、domain層そのものはシステムの実際の処理は何もしません。
この、具体的な処理を記述しないという制約により、テストコードが書きやすくなるというメリットが出てきます。これについては、後ほど触れます。
domain層で定義するものは大きく分けてふたつです。
厳密には、データそのものといってもEntityやValue Objectという概念があるのですが、今回はGoでいう構造体を定義をするくらいに考えてもらってもいいと思います。
データの操作についての定義は、Goのinterfaceによって実装します。
Goのinterfaceは、Javaのinterfaceと少し趣が異なるので、少し説明しておきます。
下のソースコードはGoでinterfaceを実装しているサンプルコードです。
空の構造体のなのにポインタで扱ってるのは、今後の拡張を見据えてということにします。
package main
import "fmt"
// interfaceの実装
type Greeter interface {
Greeting(name string)
}
// リリース用構造体
type ProductGreeter struct{}
// リリース用構造体の疑似コンストラクタ
// interfaceを返すことでinterfaceの制約を守っていることを保証
func NewProductGreeter() Greeter {
return &ProductGreeter{}
}
// リリース用挨拶関数
func (pg *ProductGreeter) Greeting(name string) {
fmt.Printf("こんにちは! %sさん\n", name)
}
// テスト用構造体
type TestGreeter struct{}
// テスト用構造体の疑似コンストラクタ
// interfaceを返すことでinterfaceの制約を守っていることを保証
func NewTestGreeter() Greeter {
return &TestGreeter{}
}
// テスト用挨拶関数
func (tg *TestGreeter) Greeting(name string) {
fmt.Println("dummy greeting")
}
func main() {
// それぞれインスタンス化
pg := NewProductGreeter()
tg := NewTestGreeter()
// 利用するときは中身を意識する必要がない
name := "tsukamoto"
pg.Greeting(name)
tg.Greeting(name)
}
実行すると下のようになります。
> go run main.go
こんにちは! tsukamotoさん
dummy greeting
サンプルコードのように、Goのinterfaceは構造体の定義時(Javaでいうとクラスの定義時に近い)にinterfaceの継承を明示しません。
抽象の状態ではなく、具体となった際の制約としてinterfaceが適用されるイメージです。
interfaceを用いることで、構造体のふるまいを利用する際には、実装の中身を意識する必要がなくなります。
下のように、どちらもGreeting()という関数を利用できることだけが分かっていればいいのです。
func main() {
// それぞれインスタンス化
pg := NewProductGreeter()
tg := NewTestGreeter()
// 利用するときは中身を意識する必要がない
name := "tsukamoto"
pg.Greeting(name)
tg.Greeting(name)
}
さて、サンプルコードをしっかりと見ていただいた方ならお気づきかと思います。
このサンプルコードの仕組みのようにDDDのレイヤードアーキテクチャを実装することができます。
そして、実際のDBとの接続処理が未実装であっても、制約のもとダミーデータを返すモック関数を実装しておけば、usecase層は本番用の処理でそのモック関数を利用しても問題なく動作するはずです。
従って、この仕組みを徹底することで各層のユニットテストがしやすくなるのです。
infrastructureは、システム外部との連携を記述する層です。
データベースや外部APIを利用するシステムの場合、それらとの結合部分をこのinfrastructureに記述します。
データベースには、当然ながら業務に必要なデータが格納されていきます。従って、このinfrastructure層はdomainのルールに依存しながら処理をします。
domain層でデータの抽象的な定義をおこない、実際の処理をinfrastructure層で実装していきます。
domainで定義したinterfaceとしてのrepositoryを具体化するのがinfrastructureといっていいでしょう。上の図をもう少し詳しくしてみると下画像のようになります。
domain層で記述したinterfaceとしてのrepositoryをinfrastructure層でインスタンス化して実際の処理がなされます。
usecaseはユーザーの入力に対して、domain層で記述されたルールに基づいた処理を実施する層です。
ユースケースとは、ユーザーがシステムをどのように利用するかということです。
ユーザー登録したり、画像や文章をアップロードして他のユーザーと共有するというようなものです。
これらの実際の処理を記述するのがこのusecase層になります。
ユーザー登録であれば下記のような処理をします。
ただし、usecase層はinfrastucture層の詳細を把握する必要がありません。domain層を見て、そこに記述してある通りに、infrastracuture層を利用するのです。
presentationは、ユーザー等のシステムを利用するものがシステムと関わる表面部分の仕組みです。
Webアプリケーションで言えばUIがそうなりますし、今回のブログのメインテーマであるバックエンド開発ではAPIのエンドポイントなどがそれにあたります。
この層をinterface層として紹介する記事もありますが、GoのInterfaceと混じって分かりづらくなる可能性があるので、今回はpresentation層とします。
Goでは、handlerという名前でusecaseに処理を依頼するインスタンスを作成することが多そうです。
handlerはレストランのウェイターみたいなもので、お客さんからの要求を受け取り、シェフに注文内容を伝えるというようにイメージすると分かりやすいかもしれないです。
Dependency Injectionとは、各層の依存関係を成立させるための最後の仕上げ処理です。
依存関係として図示するならば、下図のような2パターンで実装ができます。
左がDependency Injectionのイメージ図で、右が入れ子構造(分かりやすくここではDependency Matryoshkaとしました)の依存関係です。どちらもBがAに依存している関係を表しています。
左は、AとBを別々に定義した後、後で依存関係を構築します。
右は、Bの定義の中でAを定義して…というように定義の中で定義を入れ子にしていきます。
左右の図の状況を言葉で表すのならば、どちらも「Aに依存しながら、Bを実行する」という関係です。
DIの方は、AとB自体は切り離されているため、モック用としてのBを作成しやすいという利点があります。図として表すと下図のようになります。それぞれ切り離されているため、モック用のBをそのままに、本番用のBの実装を進めることも容易になります。
このBがAに依存している状態を簡易的なサンプルコードで実装すると下のようになります。
もし仮にモックとしてのBを作成したら、そのbのコンストラクタにaを渡せば同じように利用できるというわけです。
package main
import "fmt"
// Aの定義
type A struct{}
func NewA() *A {
return &A{}
}
func (a *A) GetName() string {
return "A"
}
// Aに依存したBの定義
type B struct {
a *A
}
func NewB(a *A) *B {
return &B{a: a}
}
func (b *B) PrintAName() {
fmt.Println(b.a.GetName())
}
func main() {
// Aから順にインスタンス化し注入していく(Dependency Injection)
a := NewA()
b := NewB(a)
b.PrintAName()
}
実行結果は下のようになります。
最終的な実行者(main関数)はbのPrintAName()関数のみ知っているだけで、結果的にA構造体のGetNameを実行できているのがこの依存関係のカギとなります。
> go run main.go
A
domain層の説明に利用したサンプルコードをDDDを適用したレイヤードアーキテクチャに沿って再構成してみます。また、あいさつだけするプログラムを作ってみましょう。
ディレクトリ構成は分かりやすく下記のようにします。
C:.
│ go.mod // メインパッケージ名はgopra(go practiceの略にしてます)
│ main.go // 実行するgoファイル
│
├─domain
│ └─repository
│ greeter.go // 挨拶のinterface repository
│
├─infrastructure
│ greeter.go // 挨拶の実態を実行
│
├─presentation
│ greeter.go // 実際に処理するデータを受け取りusecaseに処理を依頼
│
└─usecase
greeter.go // infrastructureのrepositoryを利用
domain層の実装は下のようになります。
単純明快です。「このシステムでは、GreeterというリポジトリがGreetingという処理をするの/だ」というルールのみが記載されています。
/domain/repository/greeter.go
package repository
// interfaceの実装
type Greeter interface {
Greeting(name string) string
}
package infrastructure
import (
"gopra/domain/repository"
)
// リリース用構造体
type ProductGreeter struct{}
// リリース用構造体の疑似コンストラクタ
// interfaceを返すことでinterfaceの制約を守っていることを保証
func NewProductGreeter() repository.Greeter {
return &ProductGreeter{}
}
// リリース用挨拶関数
func (pg *ProductGreeter) Greeting(name string) string {
return "こんにちは! " + name + "さん\n"
}
usecase層の実装は下のようになります。
/usecase/greeter.go
package usecase
import "gopra/domain/repository"
// usecase層のinterface
type GreeterUseCase interface {
Greeting(name string) string
}
// usecaseの構造体
// 少し分かりずらいですが、domain層のrepository interfaceに依存しています
type greeterUseCase struct {
repository repository.Greeter
}
// ユースケースのコンストラクタ
// ユースケースでは、infrastructureの意識はしておらず、あくまでdomain層のrepository
// しかし、実際に引数として受け取るのは実態としてのrepositoryです
func NewGreeterUseCase(r repository.Greeter) GreeterUseCase {
return &greeterUseCase{repository: r}
}
// usecaseの処理実装
// repositroy interfaceが入出力を保証しているので、実態が何であろうとGreetingを利用できます
func (gu *greeterUseCase) Greeting(name string) string {
result := gu.repository.Greeting(name)
return result
}
presentation層の実装は下のようになります。
/presentation/greeter.go
package presentation
import (
"fmt"
"gopra/usecase"
)
// handlerのinterface
type GreeterHandler interface {
HandleGreeting(name string)
}
// handlerの構造体
// usecaseに依存しています
type greeterHandler struct {
usecase usecase.GreeterUseCase
}
// handlerのコンストラクタ
func NewGreeterHandler(u usecase.GreeterUseCase) GreeterHandler {
return &greeterHandler{usecase: u}
}
func (gh *greeterHandler) HandleGreeting(name string) {
result := gh.usecase.Greeting(name)
fmt.Println(result)
}
最後に、main.goの実装は下のようになります。
package main
import (
"gopra/infrastructure"
"gopra/presentation"
"gopra/usecase"
)
func main() {
// Dependency Injection (依存性の注入)
// repositoryの実体を生成
repository := infrastructure.NewProductGreeter()
// usecaseの生成
// repositoryの実態を引数として渡します
usecase := usecase.NewGreeterUseCase(repository)
// ハンドラーの生成
// usecaseを引数として渡します
handler := presentation.NewGreeterHandler(usecase)
// 実際のシステム開始
// 外部から利用する際にはhandlerにデータを渡すのみ
name := "tsukamoto"
handler.HandleGreeting(name)
}
実行してみた結果は下記のとおりです。
> go run main.go
こんにちは! tsukamotoさん
今回は、ドメイン駆動設計のDDDとレイヤードアーキテクチャについて説明しました。
私自身、この勉強を通して、Goについてもかなり勉強になりました。
Remixの勉強も面白かったですが、こういったアーキテクチャやバックエンドについての勉強もなかなか面白いものですね。
今回は、かなり小さなシステムでしか作成していないの上、テストコードも記載していないので無駄に複雑になったように見えます。ですが、システムが大きくなり、保守も考えるようになると恩恵が大きいのかもしれません。
とはいえ、アーキテクチャやプログラミング言語は、あくまで手段なので、使うことが目的となっては元も子もないですよね。
結果的によいシステムを構築する(ユーザーにより良い体験を提供する)という目的は忘れず、取り組んでいきたいものです。