今年の初め頃から少しずつアーキテクチャやDDDの勉強を初め、プロジェクトの中で手探りで実践してきました。
そのまとめと振り返りを兼ねて、Go言語でDDDとクリーンアーキテクチャを意識した実装を行う場合の簡単な実装例を記載していきます。
GoはJava等と違い完全なOOPを実現するような言語ではないので、あまりガチガチに追求しすぎるよりも、
ある程度妥協しながらGoの良さを活かしつつ落とし所を見つけて行くのが良いのではないかと思います。
未だ試行錯誤しながら実装しているものですので、ご意見/ツッコミ大歓迎です。
Domain Object
エンティティや値オブジェクトといったドメインオブジェクトはデータと振る舞いから成るもので、
Goでは構造体とそれが持つプロパティ値、関数で表現します。
type Users struct {
id int
firstName string
familyName string
birthDay time.Time
address string
}
func NewUser(id int, firstName string, familyName string, birthDay time.Time, address string) (*Users, error) {
if birthDay.After(time.Now()) {
return nil, errors.New("Error: Incorrect birthday ")
}
return &Users{
id: id,
firstName: firstName,
familyName: familyName,
birthDay: birthDay,
address: address,
}, nil
}
func (u *Users) GetFullName() string {
return fmt.Sprintf("%s %s", u.firstName, u.familyName)
}
func (u *Users) GetAge() int {
return time.Now().Year() - u.birthDay.Year()
}
構造体/コンストラクタ/振る舞いを表現する関数、これらが基本的な構成になります。
構造体の各プロパティのスコープはプライベートにしておきます。
コンストラクタを通さずオブジェクトを生成したり、直接値を出し入れするようなことを防ぐためです。
生成されたオブジェクトは破棄されるまで状態を保持し続け(immutable)、変更がある場合には再度生成したオブジェクトで置き換えます。
ドメイン層は最も独立した層であるためユニットテストも容易です。
じゃんじゃんテストコード書きましょう。
UseCases
ドメインオブジェクトやアダプタを利用してデータをやり取りする一連のアプリケーションルールを表現します。
各アダプタ用のInterfaceもこのユースケース層に定義します。
アダプタ側にInterfaceを定義するのが一般的かもしれませんが、Go Code Review Comments の記載に従い、
ここは利用側でInterfaceを定義するGo Way
なやり方で行きましょう。
実際、実装の詳細と言える各アダプタ層を書く前にアプリケーションロジックを実装して検証する事が出来るためこのやり方は気に入っています。
type UsersInputPort interface {
GetUserAge(UsersDto) error
}
type UsersRepository interface {
GetUser(UsersDto) (*domain.Users, error)
}
type OuterSystemGateway interface {
Send(userID int) error
}
type UsersPresenter interface {
RespUserAge(int) error
}
type Users struct {
repo UsersRepository
gateway OuterSystemGateway
presenter UsersPresenter
}
func NewUser(repo UsersRepository, gateway OuterSystemGateway, presenter UsersPresenter) UsersInputPort {
return &Users{
repo: repo,
gateway: gateway,
presenter: presenter,
}
}
type UsersDto struct {
ID int
}
func (u *Users) GetUserAge(dto UsersDto) error {
user, err := u.repo.GetUser(dto)
if err != nil {
return err
}
if err := u.gateway.Send(dto.ID); err != nil {
return err
}
if err := u.presenter.RespUserAge(user.GetAge()); err != nil {
return err
}
return nil
}
様々なアダプタが入り込んでくるため、テストを描くのが面倒なユースケース層ですが、Interfaceに寄せることでテスタブルな実装にもなります。
テスト用のモックはやや量が多くなるかと思うので、手書きが面倒であれば gomock などのツールで生成してしまうと楽ができます。
Interface Adapters
システムの外側と内側の間を繋ぐレイヤーです。
APIのリクエストハンドラやレスポンス、データベースや外部システムとのやり取りなどを行います。
データベースや外部システムとのやり取りはRepositoryにまとめて書くケースもあると思いますが、
個人的には分けた方がどういった性質のものがどこにあるのかわかりやすくなると思うので
- Repository: 永続化
- Gateway: 外部システム連携
- Controller: リクエストハンドラ
- Presenter: レスポンス用アウトプットポート
の様にパッケージ分けしています。
基本的な構成は各パッケージ共に同じでユースケース層に定義したインターフェイスを実装する構造体とそのコンストラクタ、インターフェイスを満たす関数から成ります。
Repository
データベースなどの永続化関連の機能を取り扱います。
構造体にはデータベースとのコネクションを持つ事になりますが、ここをさらに抽象化してインターフェイスにします。
コネクション用のインターフェイスは例によって利用側であるアダプタ側に定義しておきます。
抽象化する事でテストを書く際にモックを当てたり、SQLiteで簡単にテストしたり、 dockertest でDockerでテスト用DBを用意したりと柔軟性が増すメリットもあります。
type usersRepository struct {
dbHandler DBHandler
}
type DBHandler interface {
Store(*domain.Users) error
Query() (*domain.Users, error)
ByID(int) DBHandler
ByName(string, string) DBHandler
}
func NewUsersRepository(dbHandler DBHandler) usecase.UsersRepository {
return &usersRepository{
// 抽象化したコネクション
dbHandler: dbHandler,
}
}
func (e *usersRepository) GetUser(dto usecase.UsersDto) (*domain.Users, error) {
user, err := e.dbHandler.ByID(dto.ID).Query()
if err != nil {
return nil, err
}
return user, nil
}
データベースが変わる事はないという様なプロジェクトであれば直接コネクションオブジェクトを持たせても良いと思います。
ただし、後者の場合依存の向きが外側の層に向くため、クリーンアーキテクチャの基本からは外れてしまう事は注意が必要です。
type usersRepository struct {
db *sql.DB
}
Gateway
外部システムとの接続などの機能を取り扱います。
構造体には外部システム接続とのクライアントオブジェクトなどを持たせる事になります。
ここも抽象化しても良いでしょうが、あんまり変わる事はないし変わる場合はインフラ層から
ガッツリ変わるのでそこまではやっていません。
type outerSystemGateway struct {
// 外部システム接続用のクライアントオブジェクトとか
outerClient *outer.Client
}
func NewOuterSystemGateway() usecase.OuterSystemGateway {
return &outerSystemGateway{}
}
func (u *outerSystemGateway) Send(userID int) error {
// Do Something
return nil
}
Presenter
ユーザーへのレスポンスを提供する口として処理結果の出力を行います。
利用しているフレームワークによってはここは実装しない場合もあるかもしれません。
(Controllerから直接返すなど)
type usersPresenter struct {
respWriter http.ResponseWriter
}
func NewUsersPresenter(w http.ResponseWriter) usecase.UsersPresenter {
return &usersPresenter{
respWriter: w,
}
}
type UserAge struct {
Age int
}
func (u *usersPresenter) RespUserAge(age int) error {
if err := Response(u.respWriter, http.StatusOK, UserAge{age}); err != nil {
return err
}
return nil
}
Controller
ここまで一通り実装し終えたら、処理の入り口となるコントローラ層を実装します。
APIであればリクエストハンドラの処理が置かれ、各アダプタのDIを行うのがこの層です。
type UserHandler struct {
dbHandler repository.DBHandler
}
func NewUser(dbHandler repository.DBHandler) *UserHandler {
return &UserHandler{
dbHandler: dbHandler,
}
}
func (u *UserHandler) GetUserAge(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.URL.Query().Get("id"))
if err != nil {
}
// 各アダプタへのDIを行いInputportを返す関数
inputPort, err := initInputPort(u.dbHandler, w)
if err != nil {
}
err = inputPort.GetUserAge(usecase.UsersDto{
ID: id,
})
if err != nil {
log.Printf("%v\n", err)
}
}
func initInputPort(dbHandler repository.DBHandler, w http.ResponseWriter) (usecase.UsersInputPort, error) {
usersRepository := repository.NewUsersRepository(dbHandler)
outerSystemGateway := gateway.NewOuterSystemGateway()
usersPresenter := presenter.NewUsersPresenter(w)
usersInputPort := usecase.NewUsers(usersRepository, outerSystemGateway, usersPresenter)
return usersInputPort, nil
}
DI部の実装を書くため、利用するアダプタが増えるほどコードが膨らんだり、インポート文が煩雑になり名前が被るなどどうしても辛くなりがちです。
DI部だけ切り出して辛いところを一つに集約したり、あまり面倒になるようであれば wire などのDIツールを使って省力化するのも良いと思います。
wireの場合、ここまで作っておけば下記のようなコードから一発生成してくれます。
func initInputPort(dbHandler repository.DBHandler, w http.ResponseWriter) (usecase.UsersInputPort, error) {
wire.Build(
usecase.NewUsers,
repository.NewUsersRepository,
gateway.NewOuterSystemGateway,
presenter.NewUsersPresenter,
)
return &usecase.Users{}, nil
}
実装時に発生する悩み
ドメインオブジェクトのカプセル化を保つため、不要なGetter/Setterは持たせないようにしますが、
Goの場合どうしても各レイヤ間のモデルへの詰め替えの実装が発生します。
例えばドメインモデルのデータを永続化モデルに詰め替える際には下記のように。
func (h *UsersHandler) Store(user *domain.Users) error {
m := &models.Users{
FirstName: user.GetFirstName(),
FamilyName: user.GetFamilyName(),
BirthDay: user.GetBirthDay(),
Address: user.GetAddress(),
}
if err := h.Save(m).Error; err != nil {
return err
}
return nil
}
同じような事がレスポンス用のモデルへの詰め替えなど各層で発生し、どうしても詰め替え用途のGetterを作る事になります。
ドメイン層で他層のモデルに詰め替えて渡すファクトリ関数を用意したとして、その場合にはドメイン層が外側の層の構造体に依存する事になります。
クリーンアーキテクチャに寄せてあくまでドメインモデルを外側に依存しない方法を取るか、
カプセル化の保護に寄せるか、悩ましいところです。
こういったこともあり、GoでDDDの実装を行う場合ある程度割り切りが必要になるのかなと感じています。
なんだか結局解決出来てないじゃないかという終わり方ですが、それでもこういった試行錯誤をしながら実装していく事には意味があるのではないかと思います(思いたい)し、今後も続けていきたいと思います。