😶‍🌫️

DDDのエラーハンドリング戦略(With Go)

2024/07/11に公開

GoでバックエンドをDDDで実装を行った。

DDD的に「エラーハンドリングこうしたらいいんちゃうの?」と思って書いたのが結構よかったので共有する。

まず復習。

何度も思いだしたい有名な図

オニオンアーキテクチャ図
出典:ドメイン駆動 + オニオンアーキテクチャ概略

エラーの種類

ここからすると、エラーの種類も自ずと決まってくる。

簡単に言えばドメイン層で起きるエラーはロジックに起因するエラーでありユーザーフィードバックが必要なものとなり、インフラストラクチャ層で起きるエラーはロジックではなくシステム起因によるものであるため、httpレスポンスであれば500サーバーエラーのような、ユーザーフィードバックはするべきではないが、システムのエラーログに残すべきエラーとなる。

もう少し実例を元にブレイクダウンする

ドメイン層で起きるエラー

これはいわゆる「ビジネスロジック」に起因するエラーとなる。

基本的にビジネスロジックを成り立たせることが出来なかった、というエラーであるため、ユーザーにフィードバックが必要なエラーとなる。

例えばそれは、

  • InvalidParameter的なもの

例:「ユーザーに対して名前の文字数がが少なすぎる」などの
「エンティティとしてインスタンス化したいけど、値が不十分もしくは不正なものであるからエラー」

  • UnPermitted的なもの

その操作を許されたユーザーやエンティティではない、などのエラー

  • その他AlreadyExistやNotFoundなどクエリデータに基づくエラー

のようなもの。

インフラストラクチャ層で起きるエラー

インフラストラクチャ層の役割というのは「ドメイン層の出来事を技術的に解決する」部分と言える。

ちなみにこの考え方に則って、例えばロガーなどもインフラストラクチャ層と言われている。

ただ基本的にはインフラ層の殆どの記述がレポジトリつまりデータの永続化と、クエリつまりデータの取得の作業で占められると思う。

若干余談だが、自分はDDDにおいては、コマンドクエリ分離パターンを基本的には使わせてもらっている。

CQRS図
出典:CQRS実践入門 [ドメイン駆動設計]

それぞれのモデルを分けるところまで正直やってないが、基本的な役割が異なり、また複雑な取得ロジックが必要なのはクエリの方であるため、分けた方が見通しが良い。

クエリは見つからなければNullや空の配列を返して、それに基づいてドメイン層側で判断したらよいので、いずれにしてもインフラ層から帰ってくるエラーはあくまでビジネスロジックを含まない、技術的な障害等由来のエラーであるハズである。

上記2つのドメイン層、インフラ層を意識したエラーの使い分けで、アプリケーションで起きるエラーの大方カバーできる。

ドメインエラー型

これらドメイン層でエラーを受け取って返すために「DomainError」というのを定義した。

DomainError
package errors

import "errors"

type DomainError struct {
	ErrType ErrorType
	err     error
}

type ErrorType int

const (
	InvalidParameter ErrorType = iota 
	UnPemitedOperation
	AlreadyExist
	RepositoryError
	QueryError
	QueryDataNotFoundError
	ErrorUnknown
)

func (e *DomainError) Error() string {
	if e == nil {
		return ""
	}
	return e.err.Error()
}

func (e *DomainError) Unwrap() error {
	if e == nil {
		return nil
	}
	return e.err
}

func (e *DomainError) GetType() ErrorType {
	if e == nil {
		return ErrorUnknown
	}
	return e.ErrType
}
func NewDomainError(errType ErrorType, message string) *DomainError {
	return &DomainError{ErrType: errType, err: errors.New(message)}
}

こちらで例えば下記のようなユースケースの実装を行った

クーポンをアタッチするユースケース

下記はユーザーにアタッチされたクーポンを使用するユースケース処理の一例。

package user

import (
	"context"

	"server/core/entity"
	"server/core/errors"
	queryservice "server/core/infra/queryService"
	"server/core/infra/repository"

	"github.com/google/uuid"
)

type UserAttachedCouponUsecase struct {
	usercouponRepository repository.IUserCouponRepository
	usercouponQuery      queryservice.IUserCouponQueryService
	transaction          repository.ITransaction
}

func NewUserAttachedCouponUsecase(usercouponRepository repository.IUserCouponRepository, usercouponQuery queryservice.IUserCouponQueryService,
	transaction repository.ITransaction,
) *UserAttachedCouponUsecase {
	return &UserAttachedCouponUsecase{
		usercouponRepository: usercouponRepository,
		usercouponQuery:      usercouponQuery,
		transaction:          transaction,
	}
}

func (u *UserAttachedCouponUsecase) UseMyCoupon(AuthUserID uuid.UUID, couponID uuid.UUID) *errors.DomainError {
	coupon, err := u.usercouponQuery.GetByID(AuthUserID, couponID)
	if err != nil {
		return errors.NewDomainError(errors.QueryError, err.Error())
	}
	if coupon == nil {
		return errors.NewDomainError(errors.QueryDataNotFoundError, "該当のクーポンIDが見つかりません。")
	}
	if AuthUserID != coupon.UserID {
		return errors.NewDomainError(errors.InvalidParameter, "ユーザー自身のクーポンではありません。")
	}
	if coupon.Status != entity.CouponIssued {
		return errors.NewDomainError(errors.UnPemitedOperation, "発行済ステータスのクーポンではありません。")
	}

	if coupon.UsedAt != nil {
		return errors.NewDomainError(errors.UnPemitedOperation, "クーポンはすでに使用済みです。")
	}

	usedCoupon := entity.UseUserAttachedCoupon(
		AuthUserID,
		coupon.Coupon,
	)
	ctx := context.Background()
	err = u.transaction.Begin(ctx)
	if err != nil {
		u.transaction.Rollback()
		return errors.NewDomainError(errors.RepositoryError, err.Error())
	}

	err = u.usercouponRepository.Save(u.transaction, usedCoupon)
	if err != nil {
		u.transaction.Rollback()
		return errors.NewDomainError(errors.RepositoryError, err.Error())
	}
	err = u.transaction.Commit()
	if err != nil {
		return errors.NewDomainError(errors.RepositoryError, err.Error())
	}

	return nil
}

これで返すエラーは全てDomainErrorでラップされ、クエリのエラーなのか、レポジトリエラーなのか、何かしらのロジックに基づくエラーなのかを、ドメイン層のユースケース内で定義している。

レスポンスのエラーハンドラーに通す

これでデータは基本すべてドメイン層を通り、エラーはDomainErrorでラップされている。

あとは最終的にコントローラーやルーター、ミドルウェアなどのレスポンスを返す層において、フレームワークや対応するHTTPレスポンスに詰め替える。

ちなみに今回connect-goを使った為、RPCエラーを拡張し、httpにも対応したようなレスポンスエラーが定義されていたので、それらに各種ドメインエラーを対応させる形をとった。

エラーハンドラーの例
package controller

import (
	"server/core/errors"
	"server/infrastructure/logger"

	"github.com/bufbuild/connect-go"
)

func ErrorHandler(domainErr *errors.DomainError) *connect.Error {

	switch domainErr.ErrType {
	case errors.InvalidParameter:
		return connect.NewError(connect.CodeInvalidArgument, domainErr)
	case errors.UnPemitedOperation:
		return connect.NewError(connect.CodePermissionDenied, domainErr)
	case errors.AlreadyExist:
		return connect.NewError(connect.CodeAlreadyExists, domainErr)
	case errors.RepositoryError:
		logger.Error(domainErr.Error())
		return connect.NewError(connect.CodeInternal, domainErr)
	case errors.QueryError:
		logger.Error(domainErr.Error())
		return connect.NewError(connect.CodeInternal, domainErr)
	case errors.QueryDataNotFoundError:
		logger.Error(domainErr.Error())
		return connect.NewError(connect.CodeNotFound, domainErr)
	case errors.ErrorUnknown:
		logger.Error(domainErr.Error())
		return connect.NewError(connect.CodeUnknown, domainErr)
	default:
		logger.Error(domainErr.Error())
		return connect.NewError(connect.CodeUnknown, domainErr)
	}
}

これらをコントローラー側(今回はconnect-goのService)で返す。

コントローラーの例
package user

import (
	"context"
	"errors"

	"server/api/v1/shared"
	"server/api/v1/user"
	userv1connect "server/api/v1/user/userconnect"
	"server/controller"
	"server/core/entity"
	usecase "server/core/usecase/user"
	"server/router"

	"google.golang.org/protobuf/types/known/timestamppb"

	"github.com/bufbuild/connect-go"
	"github.com/google/uuid"
	"google.golang.org/protobuf/types/known/emptypb"
)

type MyCouponController struct {
	couponUseCase usecase.UserAttachedCouponUsecase
}

var _ userv1connect.MyCouponControllerClient = &MyCouponController{}

func NewMyCouponController(couponUsecase *usecase.UserAttachedCouponUsecase) *MyCouponController {
	return &MyCouponController{
		couponUseCase: *couponUsecase,
	}
}

func (ac *MyCouponController) Use(ctx context.Context, req *connect.Request[user.CouponIDRequest]) (*connect.Response[emptypb.Empty], error) {
	id := req.Msg.ID
	couponID, err := uuid.Parse(id)
	if err != nil {
		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("UUIDが正しい形式ではありません。"))
	}

	if ctx.Value(router.UserIDKey) == nil {
		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("ユーザーIDが取得できませんでした。"))
	}

	userID, err := uuid.Parse(ctx.Value(router.UserIDKey).(string))
	if err != nil {
		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("ユーザーIDが取得できませんでした。UUIDの形式が不正です。"))
	}
	domaiErr := ac.couponUseCase.UseMyCoupon(userID, couponID)
	if domaiErr != nil {
		return nil, controller.ErrorHandler(domaiErr)
	}
	return connect.NewResponse(&emptypb.Empty{}), nil
}

func UserAttachedCouponToResponse(entities []*entity.UserAttachedCoupon) *user.MyCouponsResponse {
	var response []*shared.Coupon
	for _, coupon := range entities {
		var TargetStores []*shared.Store
		for _, store := range coupon.TargetStore {
			TargetStores = append(TargetStores, &shared.Store{
				ID:         store.ID.String(),
				Name:       store.Name,
				BranchName: store.BranchName,
			})
		}

		response = append(response, &shared.Coupon{
			ID:                coupon.ID.String(),
			Name:              coupon.Coupon.Name,
			CouponType:        shared.CouponType(coupon.Coupon.CouponType.ToInt()),
			DiscountAmount:    uint32(coupon.Coupon.DiscountAmount),
			ExpireAt:          timestamppb.New(coupon.Coupon.ExpireAt),
			IsCombinationable: coupon.Coupon.IsCombinationable,
			CreateAt:          timestamppb.New(coupon.Coupon.CreateAt),
			TargetStore:       TargetStores,
			Notices:           coupon.Coupon.Notices,
		})
	}

	return &user.MyCouponsResponse{
		Coupons: response,
	}
}

見にくいかもしれないが、要はこの辺でユースケースを叩いた時に帰ってきたエラーをハンドリング関数に通している。

	domaiErr := ac.couponUseCase.UseMyCoupon(userID, couponID)
	if domaiErr != nil {
		return nil, controller.ErrorHandler(domaiErr)
	}

インターフェース層で起きるエラー(おまけ)

少しオマケだが、インターフェース層でのエラーについても述べる。

バックエンドで言えばコントローラーとなるが、こちらはリクエストデータに対する、基本的なバリデーションを行ったエラーや、あとはユーザー認証が出来てない、などのInvalidやUnAuthrization的なエラーであることが殆ど。

ちなみに蛇足だが、恐らくDDDを実装しようとするプログラマーはコントローラーでバリデーションを掛けるか、ドメイン層で掛けるから、どこまでデータを通すか、そのバランスを1度は悩むと思う。

自分がDDDの実装コードや様々な記事を読んだ限りでは、コントローラーでも薄くはバリデーションをしよう、という考え方が多かったように思う。

そしてどこで読んだか忘れてしまったが「フロント/バックエンド/ドメインで、多少バリデーションが被ってもやむかたなし」という記述をどこかで見て、個人的にはそれに救われた。

なので例えばEmailの型であるとか、電話番号の型とか、数値であるべきとか、基本的でよくあるようなデータの種類の整合性を問うものはコントローラーなどでバリデーションを掛ける程度かと個人的には思っている。

この辺はフレームワークなどで用意されてる事がおおいし、コントローラーでは「データとしてはドメイン層に渡す前に何を確証しておくべきか」という点と「ドメインに所属すべきロジックではないか」といった点を考えれば、そこまで悩んだりすることも少ないとは思う。

あとがき

これでロジック的にも明確で、漏れの少ないエラーハンドリングをすることが出来た。

上記は無論一例であり、敢えて簡略化した部分もあるので、システム要件やプロジェクトによって考えていって頂ければと思う。

Discussion