クリーンアーキテクチャ再学習
仕事で一からプロダクトを実装することになったので、この機会にクリーンアーキテクチャについてもう一度深く学んでみることにしました。
クリーンアーキテクチャは具体的な実装を読むと、「本当にこの複雑さは必要なのか?」と疑問を抱くことがしばしばあります。
そこで、具体的なコードを例に、クリーンアーキテクチャを使わない場合に生じる問題点を探ってみることにしました。
この記事は、クリーンアーキテクチャについてある程度知っているけれど、実際には手を動かして実装したことがない方々に向けて書きます。
クリーンアーキテクチャの基礎には触れず、実装における具体的な疑問点とその解決策に焦点を当てています。
クリーンアーキテクチャと Easy な実装の比較
まず、よくある Usecase/Interactor の実装を見てみます。ユーザー作成ロジックを例に挙げます。
type CreateUserUsecaseInput struct {
name string
email string
}
type CreateUserUsecaseOutput struct {}
type CreateUserUsecase interface {
Execute(ctx context.Context, input *CreateUserUsecaseInput) (*CreateUserUsecaseOutput, error)
}
type CreateUserInteractor struct {}
func NewCreateUserInteractor() *CreateUserInteractor {
return &CreateUserInteractor{}
}
func (interactor *CreateUserInteractor) Execute(ctx context.Context, input *CreateUserUsecaseInput) (*CreateUserUsecaseOutput, error) {
return nil, nil
}
DBアクセスの struct や実際のロジックは書いていないですが、既にコード量が多いです。
これを見ると、「本当にこの複雑さは必要なのか?」と思ってしまいます。("複雑" はコード量から受ける印象にすぎないので、細かいことは無視してください)
これを、以下のように実装すると何がいけないのでしょうか
func CreateUser(ctx context.Context, name string, email string) error {
return nil, nil
}
クリーンアーキテクチャに言わせれば、テストの難しさ、コードの拡張性の欠如、保守の複雑化などがあるそうです。
問題点の深掘り
正直、現状の単純な例だと、そんなに問題はないように見えます。
入力のformatなどを validate して、DBに保存して、unique制約にかかればエラーを返す、くらいならこれで十分です。
では、例えば email validation のロジックが非常に細かくなったとします。すると、
func CreateTmpUser(ctx context.Context, name string, email string) error {
// メールフォーマットのチェック
if !isValidEmailFormat(email) {
return errors.New("無効なメールフォーマット")
}
// 特定のドメインのみを許可
if !isAllowedDomain(email) {
return errors.New("許可されていないドメイン")
}
// メールアドレスの再利用チェック
if isRecentlyUsedEmail(ctx, email) {
return errors.New("メールアドレスは最近使用されています")
}
// ユーザー作成のロジック
// ...
return nil
}
これは CreateTmpUser の責務を超えるので、email validation logic を切り出したくなります
func validateEmail(email string) error {
if !isValidEmailFormat(email) {
return errors.New("無効なメールフォーマット")
}
if !isAllowedDomain(email) {
return errors.New("許可されていないドメイン")
}
return nil
}
func CreateTmpUser(ctx context.Context, name string, email string) error {
if err := validateEmail(email); err != nil {
return err
}
// ユーザー作成のロジック
// ...
return nil
}
しかし、このアプローチでもまだ問題が残ります。
単一責任の原則に反すると言って仕舞えば簡単ですが、この記事は具体的な話をします。
例えばこれまで許可されていなかったドメインを許可するようになったとします。 validateEmail のロジックに変更を加えます。validateEmail のテストも修正する必要があります。
ここで、もし影響があるデータをCreateTmpUser のテストデータとして使っていると、CreateTmpUser のテストデータも修正する必要が生まれます。
これを避けたいので、email validation は別の service として実装して、DI したくなります
type EmailValidator interface {
Validate(email string) error
}
type EmailValidationService struct {}
func (s *EmailValidationService) Validate(email string) error {
// メール検証ロジック
// ...
return nil
}
func CreateUser(ctx context.Context, name string, email string, emailValidator EmailValidator) error {
if err := emailValidator.Validate(email); err != nil {
return err
}
return nil, nil
}
これで emailValidator が true/false はtest の中で宣言的にコントロールできるようになりました。
emailValidator のロジックの変更がCreateUser のテストに影響を及ぼす可能性は低くなりました。(もちろん、emailValidator で弾かれるべきデータが弾かれずに DB でエラーになるケースなどもあり得るので、可能性はゼロではないです。)
さて、CreateUser 関数には既に怪しさがあります。nameValidator が必要になったら、それも引数に追加することになります。そもそも validator が引数にあるのはおかしいです
CreateUser の引数に渡す前に validate しておけば良いのでは、と思いますか?私は思いました。 しかし、それだと結局 CreateUser の呼び出し側で validator と CreateUser を繋げた時の挙動のテストが必要になります。 下手をすると layer が増えてしまいます。
ということで、結局 New して Execute するような感じになっていきます
type UserCreator struct {
emailValidator EmailValidator
}
func NewUserCreator(validator EmailValidator) *UserCreator {
return &UserCreator{
emailValidator: validator,
}
}
func (uc *UserCreator) CreateTmpUser(ctx context.Context, name string, email string) error {
if err := uc.emailValidator.Validate(email); err != nil {
return err
}
// ユーザー作成のロジック
// ...
return nil
}
また、引数に値を追加したい時も同様の思考をすると、Input struct を作りたくなります(例示は十分ですかね)
締め
具体例を書くと長くなってしまうので、書くのも読むのも大変ですね
なかなかこれだというリソースが見つからないのはその辺でSEO に勝てないからなのかもしれません。