Goで実践クリーンアーキテクチャ #1
こんにちは。スペースマーケットの齋藤です。これまでのブログを振り返るとコーディングに関することをまったく書いていないことに気づいてしまいました。そこで突然ですが当社でも使用している Go 言語でクリーンアーキテクチャを実装するとどうなるかを、数回に分けて書いてみようと思います。
クリーンアーキテクチャについて
クリーンアーキテクチャをご存知の方も多いと思います。アンクル・ボブことロバート・C・マーチン氏が提唱しているアーキテクチャで「Clean Architecture 達人に学ぶソフトウェアの構造と設計」という書籍に詳しくまとめられています。
クリーンアーキテクチャに限らず「エリック・エヴァンスのドメイン駆動設計」という書籍で紹介されているレイヤードアーキテクチャや「実践ドメイン駆動設計」で紹介されているヘキサゴナルアーキテクチャなど、様々なアーキテクチャがありますが、大きくは「関心事の分離」を目的として挙げられています。
いずれのアーキテクチャにおいても多少の違いはあれ、ソフトウェアを大きくビジネスルールのレイヤーとユーザーやシステムとのインターフェースとなるレイヤーに分割することで「関心事の分離」を実現しています。
これによりビジネスルールがフレームワークや UI、データベース、外部 API などに依存することを防ぐことができます。つまりビジネスルールに関わるコードを変更せずともフレームワークや UI、データベース、外部 API を置き換えることができたり、それらがなくともビジネスルール単体でテストすることができるようになります。
少し話がそれましたがクリーンアーキテクチャに戻します。アンクル・ボブは書籍の中で、上記のようなアーキテクチャを実行可能なアイデアに統合したものがクリーンアーキテクチャだと述べています。そしてクリーンアーキテクチャーではレイヤーを以下のように分割します。
Enterprise Business Rules
このレイヤーに含まれるのは、最重要ビジネスデータとそれを操作する最重要ビジネスルールを含むオブジェクトで、書籍の中ではエンティティと呼んでいます。例えばスペースの「予約」に当てはめると「利用料金の計算」が最重要ビジネスルールとなり、そのために必要な「時間単価」「開始時刻」「終了時刻」などが最重要ビジネスデータとなります。またここで扱うデータやルールはコンピューターで計算しようと手作業で計算しようと関係なく必要なものなので、意図的に最重要とつけているようです。
Application Business Rules
このレイヤーにはユースケースが含まれます。ユースケースとは自動化されたシステムを使用する方法を記述したもので、ユーザーからの入力を加工して出力する工程を規定しています。最重要ビジネスルールとは異なり、アプリケーション固有のビジネスルールなのでこの名称になっているようです。例えば当社のプラットフォームにて「スペースを予約する」ユースケースでは「利用期間」「利用人数」「利用用途」などを受け取り、データとしての妥当性を検証。その後に「予約スケジュール」を取得して空き状況を確認。空きがあれば「予約」を作成して「利用料金の計算」を行う、という工程になります。
Interface Adapters
このレイヤーにはユースケースやエンティティのフォーマットから、UIやデータベース、外部APIなどのフォーマットにデータを変換(その逆も然り)するアダプターが含まれます。データベースがRDBであればSQLはこのレイヤーに限定し、ユースケースやエンティティは何も知らないよう(非依存)にします。
Frameworks & Drivers
このレイヤーにはデータベースやWebフレームワークが含まれます。あまりコードが書かれることはなく、Interface Adaptersなどとやりとりするために必要最小限なコードを記述するぐらいとのこと。
Enterprise Business Rules レイヤーについて
かなり前置きが長くなってしまいました。今回は Enterprise Business Rules レイヤーを取り上げたいと思います。
また実装例を提示するにあたり例題を決めたいと思います。古くはペットショップから ToDo アプリなど様々な例題がありますが、前者だとやや複雑、後者だとシンプルすぎるので間を取って(?)ブログ API を例題にします。
ブログには何はともあれまず「記事」が必要です。そして記事に対して「コメント」できたりもしますね。また記事を書く「執筆者」や記事に対してコメントをする「閲覧者」もいますね。私が目指すブログでは執筆者が閲覧者となり、その逆も然りの双方向性を重視しようと思いますので執筆者と閲覧者を「利用者」に統合したいと思います。決して二種類扱うのが面倒なわけではありません。
これらを踏まえてエンティティを整理すると下図の通りになります。記事、コメント、利用者は同一の集約ではなく、それぞれ独立したエンティティと想定しています。
エンティティの実装例
ここからは実装例に移ります。エンティティ自体は最重要ビジネスルールや最重要ビジネスデータが表現されている以外、特に何の変哲もないオブジェクトになりますので、あまり面白くはないかもしれません。
// Article 記事のエンティティ
type Article struct {
id ArticleID
title string
body string
publishedAt *time.Time
userID UserID
}
// NewArticle 記事エンティティのインスタンスを作成
func NewArticle(
id ArticleID,
title string,
body string,
publishedAt *time.Time,
userID UserID,
) *Article {
return &Article{
id: id,
title: title,
body: body,
publishedAt: publishedAt,
userID: userID,
}
}
// ID 識別子
func (a *Article) ID() ArticleID {
return a.id
}
// Title タイトル
func (a *Article) Title() string {
return a.title
}
// Body 本文
func (a *Article) Body() string {
return a.body
}
// PublishedAt 公開日時
func (a *Article) PublishedAt() *time.Time {
return a.publishedAt
}
// UserID 利用者の識別子
func (a *Article) UserID() UserID {
return a.userID
}
// EditContents 内容を編集する
func (a *Article) EditContents(title string, body string) {
a.title = title
a.body = body
}
// Publish 公開する
func (a *Article) Publish() {
t := time.Now().UTC()
a.publishedAt = &t
}
// CancelPublication 公開を中止する
func (a *Article) CancelPublication() {
a.publishedAt = nil
}
こちらは記事を表すエンティティとなります。クラス図の通り、記事を一意に識別するための ID、ビジネスデータ(タイトル、本文、公開日時)、執筆者を識別するための利用者 ID を属性として定義し、内容の編集や公開制御に関わるビジネスルールを設けています。
次に利用者を表すエンティティとなります。
// User 利用者のエンティティ
type User struct {
id UserID
personalInfo PersonalInfo
profile Profile
}
// NewUser 利用者エンティティのインスタンスを作成
func NewUser(
id UserID,
personalInfo PersonalInfo,
profile Profile,
) *User {
return &User{
id: id,
personalInfo: personalInfo,
profile: profile,
}
}
// ID 識別子
func (u *User) ID() UserID {
return u.id
}
// PersonalInfo 個人情報
func (u *User) PersonalInfo() PersonalInfo {
return u.personalInfo
}
// Profile プロフィール
func (u *User) Profile() Profile {
return u.profile
}
// EditPersonalInfo 個人情報を編集する
func (u *User) EditPersonalInfo(personalInfo PersonalInfo) {
u.personalInfo = personalInfo
}
// RemovePersonalInfo 個人情報を削除する
func (u *User) RemovePersonalInfo() {
u.personalInfo = PersonalInfo{}
}
// EditProfile プロフィールを編集する
func (u *User) EditProfile(profile Profile) {
u.profile = profile
}
こちらも基本的には記事を表すエンティティと同様ですが、個人情報、プロフィールなどをバリューオブジェクトとして定義しています。
バリューオブジェクトの実装例
個人情報を表すバリューオブジェクトを例にします。クラス図には記載していませんが、氏名と電子メールアドレスを属性として定義しています。
// PersonalInfo 個人情報を表すバリューオブジェクト
type PersonalInfo struct {
familyName string
givenName string
emailAddress string
}
// NewPersonalInfo 個人情報のインスタンスを作成
func NewPersonalInfo(
familyName string,
givenName string,
emailAddress string,
) PersonalInfo {
return PersonalInfo{
familyName: familyName,
givenName: givenName,
emailAddress: emailAddress,
}
}
// FamilyName 姓
func (pi PersonalInfo) FamilyName() string {
return pi.familyName
}
// GivenName 名
func (pi PersonalInfo) GivenName() string {
return pi.givenName
}
// EmailAddress 電子メールアドレス
func (pi PersonalInfo) EmailAddress() string {
return pi.emailAddress
}
// Equal 個人情報が等しいか判定
func (pi PersonalInfo) Equal(other PersonalInfo) bool {
return pi.FamilyName() == other.FamilyName() &&
pi.GivenName() == other.GivenName() &&
pi.EmailAddress() == other.EmailAddress()
}
バリューオブジェクトに対して理解するにはドメイン駆動設計をぜひ読んでみてください。上記の実装ではバリューオブジェクトの特徴である不変性をアクセス制御で、等価性を Equal 関数で実現しています。
注意点としては、Go 言語にはパッケージレベルのアクセス制御しか備わっていないので、構造体に private な属性を定義しても同じパッケージ内では直接アクセスできてしまいます。より厳密にアクセス制御をしたいのであれば、以下のようにインターフェースを介してアクセスさせれば良いと思います。
type PersonalInfo interface {
FamilyName() string
GivenName() string
EmailAddress() string
Equal(PersonalInfo) bool
}
type personalInfo struct {
familyName string
givenName string
emailAddress string
}
func NewPersonalInfo(
familyName string,
givenName string,
emailAddress string,
) PersonalInfo {
// PersonalInfo インターフェースを実装する personalInfo 構造体のインスタンスを返す
// 同一パッケージのモジュールであっても PersonalInfo インターフェースを介在させれば
// personalInfo 構造体に直接アクセスできない、つまりフィールドに直接アクセスできなくなる
return &personalInfo{
familyName: familyName,
givenName: givenName,
emailAddress: emailAddress,
}
}
func (pi *personalInfo) FamilyName() string {
return pi.familyName
}
...省略...
バリデーションの実装例
最後にバリデーションの実装例をお示しします。今回はドメイン駆動設計で取り上げられている遅延バリデーションを参考に実装します。
エンティティにバリデーション処理を実装するのではなく、バリデーションを担うオブジェクト(以降はバリデーターとします)に分離します。これにより最重要ビジネスルールをシンプルに実装できます。
さらにバリデーション結果に対する処理を別のオブジェクトに委譲します。これにより他のバリデーターで行うバリデーションと結果に対する処理を統合できるメリットがあります。
まずはバリデーターの実装例です。
// UserValidator 利用者エンティティの妥当性確認を担う
type UserValidator struct {
target *User
handler ValidationNotificationHandler
}
// NewUserValidator 構造体のインスタンスを作成
func NewUserValidator(
target *User,
handler ValidationNotificationHandler,
) *UserValidator {
return &UserValidator{target: target, handler: handler}
}
// Validate 利用者エンティティの妥当性を確認
func (v *UserValidator) Validate() {
v.checkID()
v.checkFamilyName()
v.checkGivenName()
v.checkEmailAddress()
v.checkPhotoURL()
}
func (v *UserValidator) checkID() {
value := v.target.ID()
length := len(value)
if length == 0 {
v.handler.HandleError(ValidationError{
Source: "ID",
Type: MissingRequiredProperty,
Message: "required",
})
return
}
if length < 3 {
v.handler.HandleError(ValidationError{
Source: "ID",
Type: InvalidLengthSpecified,
Message: "length must be more than 3",
})
} else if length > 16 {
v.handler.HandleError(ValidationError{
Source: "ID",
Type: InvalidLengthSpecified,
Message: "length must be less than or equal 16",
})
}
if !govalidator.IsAlphanumeric(string(value)) {
v.handler.HandleError(ValidationError{
Source: "ID",
Type: InvalidFormatSpecified,
Message: "must contain alphabetic and numeric only",
})
}
}
func (v *UserValidator) checkGivenName() {
value := v.target.PersonalInfo().GivenName()
length := len(value)
if length == 0 {
v.handler.HandleError(ValidationError{
Source: "GivenName",
Type: MissingRequiredProperty,
Message: "required",
})
return
}
}
func (v *UserValidator) checkFamilyName() {
value := v.target.PersonalInfo().FamilyName()
length := len(value)
if length == 0 {
v.handler.HandleError(ValidationError{
Source: "FamilyName",
Type: MissingRequiredProperty,
Message: "required",
})
return
}
}
func (v *UserValidator) checkEmailAddress() {
value := v.target.PersonalInfo().EmailAddress()
length := len(value)
if length == 0 {
v.handler.HandleError(ValidationError{
Source: "EmailAddress",
Type: MissingRequiredProperty,
Message: "required",
})
return
}
if !govalidator.IsEmail(value) {
v.handler.HandleError(ValidationError{
Source: "EmailAddress",
Type: InvalidFormatSpecified,
Message: "must be a valid email address",
})
}
}
func (v *UserValidator) checkPhotoURL() {
value := v.target.Profile().PhotoURL()
length := len(value)
if length > 0 && !govalidator.IsURL(value) {
v.handler.HandleError(ValidationError{
Source: "PhotoURL",
Type: InvalidFormatSpecified,
Message: "must be a valid URL",
})
}
}
こちらは利用者のエンティティに対するバリデーターになります。エンティティは上記の通り、現実に存在する最重要ビジネスデータを扱うため、コンピューターシステムの介在に関わらず必要な確認を行います。ここでは必須項目の存在や利用者 ID やメールアドレス、URL のフォーマットの正しさなどを確認しています。
氏名について必須確認は行っていますが、文字数の確認は行っていません。なぜならば現実世界では氏名の文字数に上限はないためです。ただしコンピューターシステムで扱う以上、データベースやファイルなどいずれにおいてもデータ長を無制限にすることは現実的ではないため制限は必要です。このような制限はアプリケーション固有のルールとして扱うべきと考えるため、Application Business Rules レイヤーに実装することにします(Application Business Rules レイヤーで行うバリデーションについては次回以降で取り上げます)。
遅延バリデーションの説明において、バリデーション結果に対する処理を別のオブジェクトに委譲すると書きました。上記の利用者エンティティに対するバリデーターの実装例にて、妥当性確認時にエラーを検知した場合、「ValidationNotificationHandler」の「HandleError」という関数を呼び出していることが見て取れます。こちらがバリデーション結果に対する処理の委譲先になります。
// ValidationNotificationHandler 妥当性確認通知を捕捉・処理するためのインターフェース
type ValidationNotificationHandler interface {
HandleError(ValidationError)
HandleFatalError(error)
}
「ValidationNotificationHandler」は上記の通りインターフェースとして定義しています。バリデーション結果に対する処理は様々であり、特定の実装に依存してはいけないため当然の帰結と思います。
また補足になりますが「ValidationError」として定義している内容は、以下の通りとなります。
// ValidationErrorType 妥当性確認エラーの種別
type ValidationErrorType string
const (
// MissingRequiredProperty 必須項目が欠落している
MissingRequiredProperty = ValidationErrorType("MISSING_REQUIRED_PROPERTY")
// InvalidLengthSpecified 無効な長さが指定されている
InvalidLengthSpecified = ValidationErrorType("INVALID_LENGTH_SPECIFIED")
// InvalidFormatSpecified 無効な形式で指定されている
InvalidFormatSpecified = ValidationErrorType("INVALID_FORMAT_SPECIFIED")
// IdentityAlreadyExists 指定された識別子がすでに存在している
IdentityAlreadyExists = ValidationErrorType("IDENTITY_ALREADY_EXISTS")
// EntityNotExists 指定された識別子に対応するエンティティが存在しない
EntityNotExists = ValidationErrorType("ENTITY_NOT_EXISTS")
// InsufficientAccessPrivileges アクセス権限が不足
InsufficientAccessPrivileges = ValidationErrorType("INSUFFICIENT_ACCESS_PRIVILEGES")
)
// ValidationError 妥当性確認エラー
type ValidationError struct {
Source string
Type ValidationErrorType
Message string
}
エラー情報をデータとして取り扱いやすくするため、単なるテキストではなく、何の項目がどのような状態になっているかがわかりやすくなるように構造化しています。
最後に
Go言語によるクリーンアーキテクチャの実装をお示しするために、クリーンアーキテクチャの簡単な説明と、Enterprise Business Rules レイヤーの実装例を書きました。
このレイヤーについては様々なアーキテクチャで述べられており、クリーンアーキテクチャならではの内容とは言えませんが、重要であることには変わりないため改めて取り上げました。
次回から扱う予定の Application Business Rules レイヤーはクリーンアーキテクチャの肝になると考えているため、ぜひ一読いただければ幸いです。
またスペースマーケットでは只今バックエンドエンジニアを募集しています。クリーンアーキテクチャに限らず、その時々の状況に応じて様々な技術を活用しながら、一緒になってより良いプロダクトを創り上げてくれる方を募集しています。もしご興味いただけましたら、以下のリンクから内容をご確認ください。