はじめに
この記事は以下の続きです。まだご覧になっていない方はまずこちらお目通しください。
今回も以下書籍を参考にさせていただいております。
前回の復習
- ドメインとはソフトウェアを使う人が解決したい課題のこと。
- ドメインモデルとはその課題の登場人物のこと。
- エンティティとはドメインモデルをコードで表現したもの。
- エンティティ = ドメインモデルでとりあえずOK。
- 値オブジェクトとはエンティティの一部のプロパティを切り出して知識を持たせたもの。
オニオンアーキテクチャについて
- DDDの話をする際、オニオンアーキテクチャについて知っておくと理解が凄く捗るなと思います。
interface層(adaptor層と言われたりもする)、application層(usecase層と言われたりもする)、domain層、infrastruncture層の4層について理解しておくと分かり良いかも知れません。
スーパーざっくりですが、各層の役割は以下のとおりです。
- 抽象的な話なので、オニオンアーキテクチャを具体的に玉ねぎを使って説明する
ドメインサービスについて
- さて前回ドメインモデルとして以下のように実装しました。
domain/model/user_model.go
type user struct {
Name string
Age int
Mail string
}
func NewUser(name string, age int, mail string) (user, error) {
// 引数のバリデーションを行う
if name == "" {
return user{}, errors.New("名前が空です")
}
if age < 0 {
return user{}, errors.New("年齢が負の値です")
}
if mail == "" {
return user{}, errors.New("メールアドレスが空です")
}
// バリデーション完了したので、インスタンス化して返却
user := user{name, age, mail}
return user, nil
}
- ここでメールアドレスをユーザーのIDとして利用したいと考えていたとします。なので、既に登録されているユーザーとメールアドレスの重複がないかを確認したいと考えています。素直にNewUserメソッドの中に書いてみましょう。
domain/model/user_model.go
type user struct {
Name string
Age int
Mail string
}
func NewUser(name string, age int, mail string) (user, error) {
// 引数のバリデーションを行う
if name == "" {
return user{}, errors.New("名前が空です")
}
if age < 0 {
return user{}, errors.New("年齢が負の値です")
}
if mail == "" {
return user{}, errors.New("メールアドレスが空です")
}
// 模擬的なコードなので、Dbはどこから来たんだとかは一旦目を瞑ってください。
sql := `SELECT id FROM user WHERE id = ?`
result, err := Db.Exec(sql, mail)
if err != nil {
return user{}, err
}
if len(result) != 0 {
return user{}, errors.New("既に会員登録されているメールアドレスです")
}
// バリデーション完了したので、インスタンス化して返却
user := user{name, age, mail}
return user, nil
}
- ドメイン層にSQLを書いてしまっていることも当然マズいのですが、ここでご説明したいことは、あるユーザーが他のユーザーの情報にアクセスしてしまっていることのマズさです。
- 前回エンティティと値オブジェクト編でご説明したとおり、DDDの嬉しさの一つとして、ドメインの知識(=つまり仕様)をドメインモデルに詰めることによって、知識が集約されることでした。何か仕様変更が起きてもドメインモデルさえ修正すればOKになり、可読性および保守改修の容易さが生まれるのでした。
- ですが、今回「既に登録されているユーザー情報」をイチユーザーが知っているのは知りすぎです。
- では適当にメールアドレスの重複を確認するような関数を作りましょうか?でもそれはどこのファイルに置きますか?日付の文字列を良い感じにパースしてくれるようなメソッドと一緒に置くのでしょうか?答えはもちろん否です。ドメインに関する関数はドメインサービスに定義します。
domain/model/user_model.go
sql := "SELECT id FROM user WHERE id = ?"
result, err := Db.Exec(sql, mail)
if err != nil {
return user{}, err
}
// 他のユーザーのことを知っているのはおかしくない?
if len(result) != 0 {
return user{}, errors.New("既に会員登録されているメールアドレスです")
}
- ドメインサービスはドメインモデルについての話だけど、ドメインモデル自身が知りようのないことを定義します。
- そしてusecase層でこのメソッドを呼んでくるイメージです。
- 変な話、何でもドメインサービスに書こうと思えば書けてしまいます。例えばドメインモデル内に記載しているバリデーション処理をこちらにvalidate関数作ってもなんか良さそうに見えます。ですが、あくまでドメインモデルのことはドメインモデル内に記述をし、なるべくドメインサービスの記述は減らすように心がけましょう。(用法・用量を守ろう)
domain/service/user_service.go
func checkIsMailExists(mail string) error {
// ドメイン層にsql記述するのは良くないですが、本題でないので一旦飛ばします。
sql := "SELECT id FROM user WHERE id = ?"
result, err := Db.Exec(sql, mail)
if err != nil {
return err
}
if len(result) != 0 {
return errors.New("既に会員登録されているメールアドレスです")
}
return nil
}
- DDDを勉強し始めて感じたことですが、domain層がしっかり作れていると、usecase層はまるで英文を読むかのようなコードになります。非常に可読性が高いですし、DDDの恩恵が早くも感じられるなと思いました。
- オニオンアーキテクチャのおかげのような気もしますが、オニオンアーキテクチャとDDDがちゃんと頭で分けられていない気もする・・・。そもそもDDDやるならオニオンアーキテクチャやクリーンアーキテクチャ等が前提な気がしてきた。
usecase/auth/register.go
func register(name string, age int, mail string) {
// メールの重複確認しているんだなと分かる。
err := service.checkIsMailExists(mail)
if err != nil {
return nil
}
// 新しいユーザーだとすぐに分かる。バリデーションの処理は書かなくて良い。
user, err := model.newUser(name, age, mail)
if err != nil {
return err
}
// 何か登録の処理
}
リポジトリについて
- さてここまでドメインの主人公であるドメインモデル(≒エンティティ)、その一部である値オブジェクト、ドメインモデルが知りようのないロジックを書くドメインサービスについて触れてきました。ここではそのドメインモデルを永続化する処理について触れたいと思います。永続化というと全然イメージが付かないかも知れませんが、Webサービスで言えばDBへ保存したり、DBの値を変更したり、または削除したりすることです。いわゆるCRUD(Create、Read、Update、Delete)処理のことですね。
- 会員登録したユーザーをDBに保存する処理はどこに書きましょうか。まずドメインモデルに書いてみます。
domain/model/user_model.go
type user struct {
Name string
Age int
Mail string
}
func NewUser(name string, age int, mail string) (user, error) {
// 引数のバリデーションを行う
if name == "" {
return user{}, errors.New("名前が空です")
}
if age < 0 {
return user{}, errors.New("年齢が負の値です")
}
if mail == "" {
return user{}, errors.New("メールアドレスが空です")
}
// バリデーション完了したので、インスタンス化して返却
user := user{name, age, mail}
return user, nil
}
func(*u user)Save()(sql.Result, error) {
sql := `INSERT INTO user(name, age, mail) VALUES(?, ?, ?)`
// 先ほど同様模擬的コードなので、Dbがどこからやってきたのか等は一旦無視してください。
result, err := Db.Exec(sql, u.Name, u.Age, u.Mail)
if err != nil {
return result, err
}
return result, nil
}
- 悪くない気もしてきましたが、ドメインの主人公であるドメインモデルがデータの永続化という責務を負ってしまっています。冒頭のオニオンアーキテクチャの例で言うと、玉ねぎが自分自身をスライスしているような感じです。プログラムだと違和感感じないかも知れませんが、例を出すとヤバいことしているのが分かるかと思います。
- こういったデータの永続化はリポジトリに任せます。リポジトリとは貯蔵庫という意味で、GitHubのリモートリポジトリを思い浮かべると分かり良いでしょう。繰り返しますが、データの永続化というと途端にイメージわかないと思うので、CRUD処理をやるところととりあえず理解すれば良いかと思います。
domain/repository/user_repository.go
// ドメインモデルを直接importして、引数をUser型にした方が良いのか・・・?本題ではないので深めませんが、コメント求む。
func Save(name string, age int, mail string)(sql.Result, error) {
sql := `INSERT INTO user(name, age, mail) VALUES(?, ?, ?)`
result, err := Db.Exec(sql, u.Name, u.Age, u.Mail)
if err != nil {
return result, err
}
return result, nil
}
- usecaseを更新してみます。英文を読むかの如く見やすくですね。
- 本題ではないので、
_
で逃げてますが、repository
で何かしらメッセージを返却するべきかと思っています。
- 本題ではないので、
usecase/auth/register.go
func register(name string, age int, mail string)(error) {
err := service.checkIsMailExists(mail)
if err != nil {
return nil
}
user, err := model.newUser(name, age, mail)
if err != nil {
return err
}
_, err := repository.save(user.name, user.age, user.mail)
if err != nil {
return err
}
}
- ちょっと発展編ですが、ドメイン層でSQL書いてしまっているのは避けたいところです。冒頭にある通り、ドメイン層の
repository
はinterface
にしておいて、インフラ層から注入したいところです。 - ややこしいですが、以下の4ステップです。
- ドメイン層にリポジトリのinterfaceを定義する。
- インフラ層に上記interfaceの実装を記述する。リポジトリのinterface型を返却するファクトリメソッドを記述する。
- ユースケース層でリポジトリを注入するファクトリメソッドを記述する。
- インターフェース層でインフラ層のファクトリメソッドを呼んでリポジトリのインスタンスを作成、ユースケース層のファクトリメソッドの引数に入れる。
- こうすることでドメイン層に記述されたinterfaceを介して、インフラ層にあるinterfaceの実装をユースケースに注入することができます。
- ちなみに何が嬉しいかと言うとユニットテストのしやすさが挙げられると思います。なるべくメソッドは外部に依存せず、引数に受け取ったもののみを内部で処理し、きっちり値を返却するように設計しておきたいわけですね。
domain/repository/i_user_repository.go
// interfaceは頭に大文字のIを付ける
interface IUserRepository {
Save(name string, age int, mail string)(string, error)
}
infra/repository/user_reposigory.go
type userRepository struct {
}
func (u *UserRepository)Save(name string, age int, mail string)(string, err) {
sql := `INSERT INTO user(name, age, mail) VALUES(?, ?, ?)`
_, err := Db.Exec(sql, u.Name, u.Age, u.Mail)
if err != nil {
return result, err
}
return "ユーザー登録が完了しました", nil
}
// 戻り値がuserRepository型ではなく、IUserRepositoryになっているところが肝
func NewUserRepository()(IUserRepository) {
userRepository := userRepository{}
return userRepository
}
usecase/auth/register.go
type RegisterUseCase struct {
userRepository IUserRepository
}
func NewRegisterUseCase(userRepository IUserRepository)RegisterUseCase {
return RegisterUseCase{userRepository}
}
func (ru RegisterUseCase)register(name string, age int, mail string)(string, error) {
err := service.checkIsMailExists(mail)
if err != nil {
return "", nil
}
user, err := model.newUser(name, age, mail)
if err != nil {
return "", err
}
message, err := ru.Save(user.name, user.age, user.mail)
if err != nil {
return "", err
}
return message, nil
}
interface/controller/user_controller.go
// registerの処理はuser/registerというエンドポイントだとする
func UserController() {
// user/registerの時の処理が記述されていたとする
// infra層に記述しておいたuserRepositoryをインスタンス化(戻り値はinterfaceの型)
userRepository := repository.NewUserRepository()
// userRepositoryの実態をusecase層に記述しておいたregisterUseCaseをファクトリメソッドを通して注入
registerUseCase := auth.NewRegisterUseCase(userRepository)
// メソッドを呼び出す
registerUseCase.Save()
}
おわりに
次はアプリケーションサービス(usecaseのことです。散々出てきてますがw)と集約のお話です。
一旦次回で最終回になる予定です。