これなに

DDDのドメイン設計などにはここでは触れません ここでは、GoでDDDでアプリケーションを作って行く際に、どういうコードを書いていくとうまくいくかなーって考えて、書いていく感じにします。

そしてこの記事では、簡単なチャットアプリのリポジトリ を作ったので、こちらを元に書いていきます。

今回実装したレイヤ

  • ドメイン層
    • Entity
    • Repository
    • Specification
    • Service
  • インフラストラクチャ
  • アプリケーション層
    • Handler(コントローラ)※ついで

ドメイン

Entity

GITHUBのリポジトリには3種類の Entity を作っています。

Channel を元に書きます。

package user

import (
	"gitlab.com/shinofara/alpha/domain/data/type"
)

type User struct {
	ID   _type.UserID `firestore:"-"`
	Name string
}

func (u *User) SetID(id string) {
	u.ID = _type.UserID(id)
}

別の場所で。

type Entity interface {
	SetID(id string)
}

とEntityのインターフェースを定義している為、User には、SetID methodを作っています。 今後は func (u *User) Equal(u2 *User) bool methodも作って同一Entityであることを比較できる様にするかもしれませんが、今のところはこんな感じ。

SetID に関しては、後述します。インターフェースの為だったので Equal でもよかったかもですが。

Repository

Repository も3種類作ってます。

ここでは Message を元に書きます。

package message

import "gitlab.com/shinofara/alpha/domain/data/type"

type Repository interface {
	Set(key string, entity *Message) error
	Add(c *Message) (*Message, error)
	Find(id _type.MessageID) (*Message, error)
	FindAllByChannelID(id _type.ChannelID) ([]*Message, error)
}

Repository には実際の実装は記述せずに、インターフェースを定義しています。 もし実装を記述してしまうと DB とか、File とかインフラストラクチャ層の関心事に依存してしまうからです。このようにしておくことで実際は DB を使うけど、テストではMockを使うなどの切り分けが容易になります。

実際にインターフェース通りに実装している箇所は後述します。

Specification

次は、Specification(仕様) です。 仕様は、 domain/data/message/specification.go だけになります。

package message

type Specification interface {
	IsSatisfiedBy(mess *Message) bool
}

// PostSpecification 投稿する場所に応じた投稿仕様
type PostSpecification struct {
	MinLength int
	MaxLength int
}

// IsSatisfiedBy 投稿されたメッセージテキストがルールを準拠しているか確認
func (s *PostSpecification) IsSatisfiedBy(mess *Message) bool {
	if len([]rune(mess.Text)) < s.MinLength {
		return false
	}

	if len([]rune(mess.Text)) > s.MaxLength {
		return false
	}

	return true
}

こんな感じですね。Message の仕様をチェックしている感じです。 この例では、チャットのチャンネル毎にmin/maxを変えるなど、channel毎にメッセージの仕様を持てるようにと考えています。

Service

そして、今回書くドメイン層の最後 Service です。

Entity, Repository, Specification と違いパッケージの階層を変更した理由は、Service はデータではなく、ドメイン内でのふるまいを記述するからです。

Channel を元に書きます。

package channel

import (
	"gitlab.com/shinofara/alpha/domain/data/channel"
	"gitlab.com/shinofara/alpha/domain/data/message"
	"gitlab.com/shinofara/alpha/domain/data/type"
	"gitlab.com/shinofara/alpha/domain/data/user"
)

type Service struct {
	channelRepo channel.Repository
	userRepo    user.Repository
	messageRepo message.Repository
}

func New(
	channelRepo channel.Repository,
	userRepo user.Repository,
	messageRepo message.Repository) *Service {

	return &Service{
		channelRepo: channelRepo,
		userRepo:    userRepo,
		messageRepo: messageRepo,
	}
}

// Create 新しいチャンネルを作成
func (c *Service) Create(name string, owner *user.User) (*channel.Channel, error) {
	ch := &channel.Channel{
		Name:    name,
		OwnerID: owner.ID,
		Owner:   owner,
	}

	return c.channelRepo.Add(ch)
}

// InitialDisplay channelIDを元に、チャンネル表示に必要な情報をChannel Entityに集約して返す
func (c *Service) InitialDisplay(channelID _type.ChannelID) (*channel.Channel, error) {
	ch, err := c.channelRepo.Find(channelID)
	if err != nil {
		return nil, err
	}

	ch.Owner, err = c.userRepo.Find(ch.OwnerID)
	if err != nil {
		return nil, err
	}

	ch.Messages, err = c.messageRepo.FindAllByChannelID(channelID)
	if err != nil {
		return nil, err
	}

	return ch, nil
}

Repository のインターフェースを外部から受け取ります。これはテストなどの際にDBを使わないMockのRepositoryを渡したり、インフラストラクチャの変更をする際に、インターフェースをに変更がなければドメイン層に変更を加えなくても良いように、依存を作らないようにしているという理由もあります。

ここでは、InitialDisplay(初回の表示する) というふるまいを実装しています。 ※余談ですが、サービス名とふるまい名は変更したほうがいいなとは思ってたりもしますが、一旦このまま。

このふるまいは下記の処理を行います。

  1. 指定されたチャンネルIDの部屋情報を取得
  2. チャンネル情報を元にオーナ情報を取得
  3. チャンネルIDを元に、チャンネルに投稿されたメッセージを取得
  4. そしてRoot Entityの Channel に情報を集約して返却

インフラストラクチャ

インフラストラクチャ層は色々実装する物はあるのですが、今回は Repository のインターフェースの実装部分を書きます。

Repository のインタフェースでは、Message を書いたので、それの実装を書きます。

package message

type Repository struct {
	cli *firestore.Client
	ctx context.Context
}

const collection = "message"

func New(cli *firestore.Client, ctx context.Context) message.Repository {
	return &Repository{
		cli: cli,
		ctx: ctx,
	}
}

// Set アイテムを追加する
func (r *Repository) Set(key string, entity *message.Message) error {
	_, err := r.cli.Collection(collection).Doc(key).Set(r.ctx, entity)

	return err
}

// Add アイテムを追加するKeyは自動で振られる
func (r *Repository) Add(entity *message.Message) (*message.Message, error) {
	ref, _, err := r.cli.Collection(collection).Add(r.ctx, entity)
	if err != nil {
		return nil, err
	}
	m := *entity
	internal.SetID(&m, ref)
	return &m, nil
}

// Find IDを元にメッセージを取得
func (r *Repository) Find(id _type.MessageID) (*message.Message, error) {
	ref, err := r.cli.Collection(collection).Doc(string(id)).Get(r.ctx)
	if err != nil {
		return nil, err
	}

	c := new(message.Message)
	if err := internal.Convert(ref, c); err != nil {
		return nil, err
	}

	return c, nil
}

// FindAllByChannelID channelIDでチャンネル内のメッセージを取得
func (r *Repository) FindAllByChannelID(id _type.ChannelID) ([]*message.Message, error) {
	var messages []*message.Message

	m := r.cli.Collection(collection).Where("ChannelID", "==", id).Documents(r.ctx)
	docs, err := m.GetAll()
	if err != nil {
		return nil, err
	}

	for _, doc := range docs {
		c := new(message.Message)
		if err := internal.Convert(doc, c); err != nil {
			return nil, err
		}
		messages = append(messages, c)
	}

	return messages, nil
}

今回作ったチャットでは、FirebaseのFirestoreを使っているので、このような感じになってます。もしDBだった場合は、SQLを書いたりORMを使う感じになるかと思います。

アプリケーション

さてさて、最後にアプリケーション層からどの様にドメイン層を実行しているかですが、まだちゃんと書ききっていないので雑ですが、このようになっています。

opt := option.WithCredentialsFile("./serviceAccountKey.json")
	ctx := context.Background()
	app, _ := firebase.NewApp(ctx, nil, opt)
	client, _ := app.Firestore(ctx)
	defer client.Close()

	// action内で使用するrepositoryを初期化
	userRepo := infraUser.New(client, ctx)
	messRepo := infraMess.New(client, ctx)
	channelRepo := infraCh.New(client, ctx)

	// owner作成
	userService := user.NewService(userRepo)
	u, _ := userService.Register("shinofara")

	// channel新規作成
	chService := serviceCh.New(channelRepo, userRepo, messRepo)
	ch, _ := chService.Create("テスト", u)

	// channelに投稿
	messService := serviceMess.New(messRepo)
	messSpec := &message.PostSpecification{MinLength: 1, MaxLength: 100}

	mess, _ := messService.Post(ch.ID, u.ID, "初投稿", messSpec)

	// channel内のメッセージ全件取得
	currentCh, _ := chService.InitialDisplay(ch.ID)

アプリケーション層で、必要なインフラストラクチャを作って、ドメイン層に渡して使う感じです。

最後に

これが正解とは全然思えないですが、割りと自分の中では納得はできたかなーと思ってます。 インターフェースが増えると、その分実装するコード量が増えるので、避けたい気持ちもありつつ、抽象的にする事で、ある程度の背結合化ができて、テストや移行時に変更をすくなくできるメリットはあっていいなって感じ。

できたら、PRとかこうしたらもっといいかも!俺ならこう書くけど!みたいなのあったら嬉しいなと思ってます。