ドメイン駆動設計におけるバリデーション実装方法への一つの解答(Golang版)
ドメイン駆動設計(以降DDDと記載させていただきます)を実装する際、バリデーションをどこで、どのように行うべきか悩むことはありませんか?この記事では、バリデーションをエンティティや値オブジェクト内で行うことで、検証ロジックの重複を避け、一貫性を保つ方法について筆者の思いつく限りのバリデーションパターンを比較しながら現状採用している自作のvalidationcontext
パッケージを活用した方法まで解説していこうと思います。
実装する際の選択肢の一つとして参考にしてもらえるととても嬉しいです!
尚、指摘などがございましたらバシバシコメントいただきたいです!!
筆者がDDDに取り組む際に参考にさせていただいた書籍
まだDDDを知らない方向けにこれまで読んできたDDD関連の書籍について紹介させていただきます。
特に@little_hand_sさんの書籍は実装方法や実装に関する疑問点などがわかりやすく記載され、チーム内の必読書としても使わせていただいています✨
個人的にDDD初学者の方は今年発売されたドメイン駆動設計をはじめようがおすすめです!間違っても最初の1冊目にエバンス本を手に取らないように😢(いまだに読みきれていません…)
DDDでのバリデーションパターン
DDDで実装する際のバリデーションパターンとして下記を紹介します。
コントローラ層でのバリデーションパターン
コントローラ層のバリデーションは、Laravelなどのフレームワークでお馴染みのRequestクラスでの検証などのイメージがあり筆者としては一番身近なパターンです。
REST APIでのバリデーション
REST APIの場合、コントローラ層でリクエストを受け取った際にバリデーションを行うパターンがあると思います。(バリデーションとビジネスロジックを切り離すパターン)
コード例:
func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// コントローラ層でのバリデーション
if req.ProductID <= 0 {
http.Error(w, "ProductID must be greater than zero", http.StatusBadRequest)
return
}
if req.Quantity <= 0 || req.Quantity > 100 {
http.Error(w, "Quantity must be between 1 and 100", http.StatusBadRequest)
return
}
if len(req.PaymentMethodName) == 0 || len(req.PaymentMethodName) > 50 {
http.Error(w, "PaymentMethodName must be between 1 and 50 characters", http.StatusBadRequest)
return
}
if len(req.ShippingAddress) < 5 || len(req.ShippingAddress) > 100 {
http.Error(w, "ShippingAddress must be between 5 and 100 characters", http.StatusBadRequest)
return
}
if len(req.RecipientName) == 0 || len(req.RecipientName) > 50 {
http.Error(w, "RecipientName must be between 1 and 50 characters", http.StatusBadRequest)
return
}
// ユースケースの呼び出し
inputDTO, err := NewInputCreateOrderDTO(&req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := orderUseCase.CreateOrder(r.Context(), inputDTO); err != nil {
http.Error(w, "Failed to create order", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
GraphQLを使用したバリデーション
GraphQLでは、ディレクティブを使用してコントローラ層でバリデーションを行うことができます。これは、入力スキーマにバリデーションルールを直接組み込む方法で、インターフェースに近い層での検証を可能にします。(バリデーションとビジネスロジックを切り離すパターン)
コード例:
type Mutation {
createOrder(input: CreateOrderInput!): Order!
}
input CreateOrderInput {
productId: Int! @constraint(min: 1)
quantity: Int! @constraint(min: 1, max: 100)
paymentMethodName: String! @constraint(minLength: 1, maxLength: 50)
shippingAddress: String! @constraint(minLength: 5, maxLength: 100)
recipientName: String! @constraint(minLength: 1, maxLength: 50)
}
メリット
- 早期のエラー検知: コントローラ層で入力値を検証することで、不正なデータを早い段階で弾くことができます。これにより、無駄な処理を避けられ、クライアント側に迅速なフィードバックを返すことができ、ユーザーエクスペリエンスが向上します。
- シンプルな実装: バリデーションライブラリやフレームワークの機能(例えば、GraphQLのディレクティブ)を利用することで、バリデーションの実装が簡潔になります。
問題点
- 検証ロジックの重複:コントローラ層でのバリデーションと、ドメイン層(値オブジェクトやエンティティ)でのバリデーションが重複し、メンテナンス性が低下します。また、他のエントリポイント(バッチ処理や他のAPI経由のリクエストなど)からの入力に対してバリデーションの重複も考えられます。
- ドメイン知識の漏洩:コントローラ層にドメイン特有のルールが入り込み、層の責務が曖昧になります。
エンティティ生成時のバリデーションパターン
エンティティや値オブジェクトを生成した後でバリデーションを行うパターンです。
DDDの書籍はこのパターンが多いと筆者は感じています。
コード例:
func (u *OrderUseCase) CreateOrder(input CreateOrderInput) error {
// 支払い方法の登録
paymentMethod := NewPaymentMethod(input.PaymentMethodName)
if err := u.paymentMethodRepository.Save(paymentMethod); err != nil {
return err
}
// 発送先情報の登録
shippingAddress := NewShippingAddress(input.ShippingAddress, input.RecipientName)
if err := u.shippingAddressRepository.Save(shippingAddress); err != nil {
return err
}
// 注文エンティティの生成
order := NewOrder(input.ProductID, input.Quantity, paymentMethod.ID, shippingAddress.ID)
// エンティティ生成後のバリデーション
if err := order.Validate(); err != nil {
return err // ここでエラーが発生すると、既に登録したデータの整合性を保つのが難しい
}
// 注文の保存
if err := u.orderRepository.Save(order); err != nil {
return err
}
return nil
}
メリット
- ドメインロジックの集中: バリデーションをエンティティ内にまとめることで、ドメインロジックが一箇所に集約されます。またドメイン知識の漏洩も防げます。
- 再利用性の向上: エンティティ内のバリデーションロジックは他のユースケースでも再利用可能です。
デメリット
- エラー検知までのフローが長い:不要なデータベース操作が増え、効率が悪くなります。
- データの整合性:バリデーションエラーが遅れて発生すると、部分的に登録されたデータの扱いが複雑になります。
2024/09/24追記
ハトすけさんからコントローラー層のバリデーションとドメイン(クラス)層のバリデーションは役割がことなるため併用しているというコメントをいただき、それぞれの役割についての解説がとてもわかりやすかったので追記させていただきます!
筆者としても単純にバリデーションを一つのレイヤーに集約するのではなく、コントローラー層とドメイン(クラス)層に分けることでバリデーションごとの責務が明確になり、APIとしての利用者体験も上がると感じました!
コメント感謝します🙇
コントローラー層のバリデーション
インターネットという未知の信頼できない世界からきたリクエストが、本当に正しいリクエストなのかを保証することで、あとのレイヤーが安心してその値を使うことができる。DTOに詰め込むと同時にバリデーションするときも一応コントローラー層のバリデーションに当たる。
ドメイン(クラス)層のバリデーション
そのメソッドが引数に対して、ドメイン整合性(ビジネスルール)を担保できているかどうかをチェックする(例えばこのリソースを作るには事前に他のリソースが存在すべきなど)。もしここでビジネスルール以外に値のバリデーションを実行する場合(例えばnullチェックなど)は防御的プログラミングと呼ばれる。対象的に入力値が期待通りである前提で処理することを契約的プログラミングと呼ぶ。
validationcontext
パッケージの紹介と実装意図
筆者はDDDの実装経験がまだ浅いので上記2パターンしか思いつきませんでした。(もし他のパターンもありましたら教えていただきたいです)
上記の2パターンはそれぞれ対照的にトレードオフがあるように感じます。
どちらを採用するかは実装するサービスに依存すると思います。
どちらを実装するか悩んだ結果、より良い方法になればと第3の解答を考案したので以降はその方法を紹介していきたいと思います。
validationcontext
バリデーションを効率的かつ一貫性を持って行うために、自作のvalidationcontext
パッケージを活用します。このパッケージの主な目的は以下の通りです。
- エラーの集約:散り散りになった値オブジェクトなどの検証ロジックのエラーを、一つのユースケースの文脈での検証エラーとして一箇所に集約します。
- 再利用性の向上:共通のバリデーションロジックを事前値オブジェクトに用意し、コードの重複を避けます。
- 詳細な情報提供:どのフィールドでどのようなバリデーションエラーが発生したかが明確になるようなエラーメッセージの生成を行います
なぜ「validationcontext」という名前なのか
「validationcontext」という名前は、バリデーションの文脈(コンテキスト)を管理する役割を持つからです。このパッケージは、バリデーションの過程で発生するエラーや警告を集約し、その状態(コンテキスト)を保ちながらバリデーション処理を進めることができます。これにより、各値オブジェクトやエンティティ内で散在するバリデーションエラーを、一つのユースケースの文脈でまとめて扱えるようになり、エラー処理がシンプルになります。
パッケージの詳細
validationcontextメインロジック
package validationcontext
import (
"fmt"
"runtime"
"strings"
)
type ValidationError struct {
Field string
Message string
StackTrace string
}
type ValidationContext struct {
errors []ValidationError
}
// ValidationAggregateError is a custom error type that aggregates multiple validation errors,
// including their messages and stack traces.
type ValidationAggregateError struct {
Messages []string
StackTraces []string
}
// Error implements the error interface for ValidationAggregateError.
// It returns a formatted string of all validation error messages.
func (e *ValidationAggregateError) Error() string {
return fmt.Sprintf("Validation errors: %s", strings.Join(e.Messages, "; "))
}
// GetMessages returns the list of validation error messages.
func (e *ValidationAggregateError) GetMessages() []string {
return e.Messages
}
// GetStackTraces returns the list of stack traces associated with the validation errors.
func (e *ValidationAggregateError) GetStackTraces() []string {
return e.StackTraces
}
// GetMessagesAsString returns all stack traces as a single string.
func (e *ValidationAggregateError) GetMessagesAsString() string {
return strings.Join(e.Messages, "\n")
}
// GetStackTracesAsString returns all stack traces as a single string.
func (e *ValidationAggregateError) GetStackTracesAsString() string {
return strings.Join(e.StackTraces, "\n")
}
// NewValidationContext creates and returns a new ValidationContext instance.
func NewValidationContext() *ValidationContext {
return &ValidationContext{
errors: make([]ValidationError, 0),
}
}
// AddError adds a validation error to the context, including the field, error message,
// and captures the stack trace at the time the error occurred.
func (vc *ValidationContext) AddError(field, message string) {
stackTrace := vc.captureStackTrace()
vc.errors = append(vc.errors, ValidationError{Field: field, Message: message, StackTrace: stackTrace})
}
// Errors returns the list of validation errors that have been added to the context.
func (vc *ValidationContext) Errors() []ValidationError {
return vc.errors
}
// HasErrors returns true if there are any validation errors in the context, otherwise false.
func (vc *ValidationContext) HasErrors() bool {
return len(vc.errors) > 0
}
// FormatErrors returns a formatted string representation of all validation errors.
func (vc *ValidationContext) FormatErrors() string {
if !vc.HasErrors() {
return "No validation errors"
}
var sb strings.Builder
sb.WriteString("Validation errors:\n")
for _, err := range vc.Errors() {
sb.WriteString(fmt.Sprintf("Field: %s, Error: %s\n", err.Field, err.Message))
}
return sb.String()
}
// AggregateError creates and returns a ValidationAggregateError that contains
// all validation errors, including their messages and stack traces.
func (vc *ValidationContext) AggregateError() error {
if !vc.HasErrors() {
return nil
}
messages := make([]string, len(vc.errors))
stackTraces := make([]string, len(vc.errors))
for i, err := range vc.errors {
messages[i] = fmt.Sprintf("Field: %s, Error: %s", err.Field, err.Message)
stackTraces[i] = err.StackTrace
}
return &ValidationAggregateError{
Messages: messages,
StackTraces: stackTraces,
}
}
func (vc *ValidationContext) captureStackTrace() string {
stackBuf := make([]byte, 1024)
n := runtime.Stack(stackBuf, false)
return string(stackBuf[:n])
}
事前に用意されたバリデーションメソッド一覧
Method | Description | Example Usage |
---|---|---|
Required | Ensures a value is not empty or nil | vc.Required(value, "FieldName", "Field is required", false) |
ValidateMinLength | Checks if a string has at least a certain number of characters | vc.ValidateMinLength(value, "FieldName", 5, "Minimum length is 5") |
ValidateMaxLength | Checks if a string does not exceed a certain number of characters | vc.ValidateMaxLength(value, "FieldName", 10, "Maximum length is 10") |
ValidateEmail | Validates if a string is in a proper email format | vc.ValidateEmail(email, "Email", "Invalid email format") |
ValidateContainsSpecial | Ensures a string contains at least one special character | vc.ValidateContainsSpecial(value, "FieldName", "Must contain a special character") |
ValidateContainsNumber | Ensures a string contains at least one numeric character | vc.ValidateContainsNumber(value, "FieldName", "Must contain a number") |
ValidateContainsUppercase | Ensures a string contains at least one uppercase letter | vc.ValidateContainsUppercase(value, "FieldName", "Must contain an uppercase letter") |
ValidateContainsLowercase | Ensures a string contains at least one lowercase letter | vc.ValidateContainsLowercase(value, "FieldName", "Must contain a lowercase letter") |
ValidateURL | Checks if a string is a valid URL | vc.ValidateURL(value, "FieldName", "Invalid URL format") |
ValidateFilePath | Ensures the file path is valid | vc.ValidateFilePath(value, "FilePath", "Invalid file path") |
ValidateFileExtension | Checks if a file has a valid extension | vc.ValidateFileExtension(file, "FieldName", []string{".jpg", ".png"}, "") |
ValidateFileSize | Ensures the file size is within the specified limit | vc.ValidateFileSize(file, "FieldName", 2*1024*1024, "File size must be 2MB or less") |
ValidateUUID | Checks if a string is a valid UUID | vc.ValidateUUID(value, "FieldName", "Invalid UUID format") |
ValidateMinValue | Ensures a numeric value meets the minimum requirement | vc.ValidateMinValue(value, "FieldName", 1, "Value must be at least 1") |
ValidateMaxValue | Ensures a numeric value does not exceed the maximum limit | vc.ValidateMaxValue(value, "FieldName", 100, "Value must be 100 or less") |
ValidateDate | Ensures a string is a valid date in the format "2006-01-02" | vc.ValidateDate(value, "FieldName", "Invalid date format") |
ValidateYearMonth | Ensures a string is a valid year and month in the format "2006-01" | vc.ValidateYearMonth(value, "FieldName", "Invalid year-month format") |
ValidateYear | Ensures a string is a valid year | vc.ValidateYear(value, "FieldName", "Invalid year format") |
ValidateMonth | Ensures a string is a valid month | vc.ValidateMonth(value, "FieldName", "Invalid month format") |
ValidateDateTime | Ensures a string is a valid date and time in the format "2006-01-02 15:04:05" | vc.ValidateDateTime(value, "FieldName", "Invalid datetime format") |
ValidateTime | Ensures a string is a valid time in the format "15:04" | vc.ValidateTime(value, "FieldName", "Invalid time format") |
バリデーションの基本的な使用方法
validationcontext
パッケージを使ってバリデーションを行う基本的な流れは以下の通りです。
-
バリデーションコンテキストの生成:バリデーションを一括で行いたい箇所で、
validationcontext.NewValidationContext()
を呼び出して新しいコンテキストを生成します。vc := validationcontext.NewValidationContext()
-
値オブジェクトの生成時にコンテキストを渡す:各値オブジェクトの生成関数に、先ほど生成したコンテキストを引数として渡します。値オブジェクト内でこのコンテキストを使ってバリデーションを行い、エラーがあればコンテキストに追加します。
productID := valueobject.NewProductID(int(request.ProductID), vc) quantity := valueobject.NewQuantity(int(request.Quantity), vc)
-
バリデーション結果の確認:すべての値オブジェクトの生成が完了した後で、コンテキストにエラーが溜まっていないか確認します。
if vc.HasErrors() { return nil, vc.AggregateError() }
-
DTOやエンティティの生成:バリデーションが成功した場合は、DTOやエンティティを生成して次の処理に進みます。
実装例:DTOの作成
func NewInputCreateOrderDTO(request *CreateOrderRequest) (*InputCreateOrderDTO, error) {
// 1. バリデーションコンテキストの生成
vc := validationcontext.NewValidationContext()
// 2. 値オブジェクトの生成時にコンテキストを渡す
productID := valueobject.NewProductID(int(request.ProductID), vc)
quantity := valueobject.NewQuantity(int(request.Quantity), vc)
paymentMethodName := valueobject.NewPaymentMethodName(request.PaymentMethodName, vc)
shippingAddress := valueobject.NewShippingAddress(request.ShippingAddress, vc)
recipientName := valueobject.NewRecipientName(request.RecipientName, vc)
// 3. バリデーション結果の確認
if vc.HasErrors() {
return nil, vc.AggregateError()
}
// 4. DTOの生成
return &InputCreateOrderDTO{
ProductID: productID,
Quantity: quantity,
PaymentMethodName: paymentMethodName,
ShippingAddress: shippingAddress,
RecipientName: recipientName,
}, nil
}
実装例:値オブジェクトの作成
ProductID
の値オブジェクト
package valueobject
import (
"github.com/take0fit/validationcontext"
)
type ProductID struct {
value int
}
func NewProductID(i int, vc *validationcontext.ValidationContext) ProductID {
fieldName := "商品ID"
vc.Required(i, fieldName, "", true)// Requiredメソッドの第三引数について必須かどうかを選択できるように実装を悩み中
vc.ValidateMinValue(i, fieldName, 1, "")
return ProductID{value: i}
}
func (p *ProductID) Value() int {
return p.value
}
Quantity
の値オブジェクト
package valueobject
import (
"github.com/take0fit/validationcontext"
)
type Quantity struct {
value int
}
func NewQuantity(i int, vc *validationcontext.ValidationContext) Quantity {
fieldName := "数量"
vc.Required(i, fieldName, "", false)
vc.ValidateMinValue(i, fieldName, 1, "")// 数量は1以上で設定して下さい(デフォルトメッセージ)
vc.ValidateMaxValue(i, fieldName, 100, "数量は100以下で設定して下さい")// メッセージのカスタム可能
return Quantity{value: i}
}
func (q *Quantity) Value() int {
return q.value
}
PaymentMethodName
の値オブジェクト
package valueobject
import (
"github.com/take0fit/validationcontext"
)
type PaymentMethodName struct {
value string
}
func NewPaymentMethodName(name string, vc *validationcontext.ValidationContext) PaymentMethodName {
fieldName := "支払い方法名"
vc.Required(name, fieldName, "", false)
vc.ValidateMaxLength(name, fieldName, 50, "")
return PaymentMethodName{value: name}
}
func (p *PaymentMethodName) Value() string {
return p.value
}
ShippingAddress
の値オブジェクト
package valueobject
import (
"github.com/take0fit/validationcontext"
)
type ShippingAddress struct {
value string
}
func NewShippingAddress(address string, vc *validationcontext.ValidationContext) ShippingAddress {
fieldName := "配送先住所"
vc.Required(address, fieldName, "", false)
vc.ValidateMaxLength(address, fieldName, 100, "")
return ShippingAddress{value: address}
}
func (s *ShippingAddress) Value() string {
return s.value
}
RecipientName
の値オブジェクト
package valueobject
import (
"github.com/take0fit/validationcontext"
)
type RecipientName struct {
value string
}
func NewRecipientName(name string, vc *validationcontext.ValidationContext) RecipientName {
fieldName := "受取人名"
vc.Required(name, fieldName, "必須です", false)
vc.ValidateMaxLength(name, fieldName, 30, "")
// validationcontextで用意されていない検証ロジックについても下記のように実装することができます。
katakanaPattern := `^[ァ-ヶー ]*$`
if match, _ := regexp.MatchString(katakanaPattern, s); !match {
vc.AddError(fieldName, fmt.Sprintf("%sはカタカナで入力してください。", fieldName))
}
return RecipientName{value: name}
}
func (r *RecipientName) Value() string {
return r.value
}
ユースケースの実装
func (u *OrderUseCase) CreateOrder(ctx context.Context, input *InputCreateOrderDTO) error {
// バリデーションはDTO作成時に完了しているため、ここではエンティティの生成と保存を行います
// エンティティの生成
order := domain.NewOrder(input.ProductID, input.Quantity, input.PaymentMethodName, input.ShippingAddress, input.RecipientName)
// エンティティの保存
if err := u.orderRepository.Save(ctx, order); err != nil {
return err
}
return nil
}
バリデーションはDTO作成時に完了しているため、ユースケース内ではビジネスロジックに集中できます。
実装のポイント
- バリデーションコンテキストの共有:一つのコンテキストに複数の値オブジェクトに渡すことで、エラーを一括管理できます。各値オブジェクトで発生したエラーが同じコンテキストに集約されるため、後でまとめてエラー処理できます。
-
共通の検証メソッドの活用:
validationcontext
パッケージに用意された検証メソッドを使うことで、コードを簡潔に保てます。
まとめ
バリデーションをエンティティや値オブジェクト内で行い、validationcontext
パッケージを活用することで、検証ロジックの重複を避け、一貫性を保つことができます。特に、バリデーションコンテキストを使ってエラーを一箇所に集約することで、各値オブジェクトで発生したエラーを一つのユースケースの文脈でまとめて扱えるようになり、エラー処理がシンプルになります。
validationcontext
パッケージについてはエラーJSON形式でのエラー出力やバリデーションメソッドの拡充、必須でない場合のバリデーションロジックのスルーなどを今後実装していく予定です。
どのパターンを利用するにしてもトレードオフは存在します。コントローラ層でのバリデーションは早期のエラー検知が可能ですが、ドメイン知識の漏洩や検証ロジックの重複といった問題があります。一方、エンティティや値オブジェクト内でのバリデーションは一貫性を保てますが、検証をまとめて処理できなかったり、実装が複雑になるといった問題があります。
また、今回紹介したvalidationcontext
パッケージを活用したパターンでも追加の知識が必要になったり、検証内容がAPIインターフェイスから離れてしまいフロントエンド実装者から確認しづらくなるなどのデメリットも存在します。
最終的には、プロジェクトの文脈やチームの状況に応じて、最適な方法を選択することが大切です。ぜひメンバーで話し合い、より良い方法を一緒に探ってみてください。
Discussion
記事ありがとうございます^^
バリデーションのパターンなのですが、僕の理解している範囲では、どちらかを採用するものというより併用するイメージです。というのもその役割がことなるためです。
コントローラー層のバリデーション
インターネットという未知の信頼できない世界からきたリクエストが、本当に正しいリクエストなのかを保証することで、あとのレイヤーが安心してその値を使うことができる。DTOに詰め込むと同時にバリデーションするときも一応コントローラー層のバリデーションに当たる。
ドメイン(クラス)層のバリデーション
そのメソッドが引数に対して、ドメイン整合性(ビジネスルール)を担保できているかどうかをチェックする(例えばこのリソースを作るには事前に他のリソースが存在すべきなど)。もしここでビジネスルール以外に値のバリデーションを実行する場合(例えばnullチェックなど)は防御的プログラミングと呼ばれる。対象的に入力値が期待通りである前提で処理することを契約的プログラミングと呼ぶ。
とはいえ、例えば文字列のサイズが20文字など単にプログラミング言語の型以上のバリデーションをどちらで行うかは確かに悩ましいですね... DDD原理主義的に考えると、それはドメイン知識だとしてクラスに閉じ込めるのが正解かもしれません。ただ、僕個人の経験則的には整合性担保以外のバリデーションはコントローラー層によせると、まだ明確に言語化できてませんがAPI(RPC?)のインターフェイスをより賢く進化させることができたり、あとのレイヤーの作成ロジックが比較的シンプルになって作りやすいイメージです。
コメントありがとうございます!😊
バリデーションのパターンについてのご意見、非常に参考になります!
特にコントローラー層についての下記の解説は、とてもわかりやすい言い回しに感じ今後利用させていただこうと思います🙇
私もDDDに取り組まれている知人からバリデーションをコントローラー層に寄せることの有用性を説いていただいたことがあり、その選択が非常にシンプルでかつAPIとしても利用者体験が向上すると感じます。
ただ、ハトすけさんの仰る通りDDD原理主義の兼ね合いもあり現プロダクトではドメイン層のバリデーションロジックをDTO生成のタイミングで利用する方針を選択しています。
多くのアーキテクチャ思想がそうであるよう、この議題についてもプロダクトの文脈に沿ったトレードオフの選択が重要だと再認識しました!
貴重なご意見を共有いただき、ありがとうございました🙇
引き続き、ご意見やご提案がございましたら、ぜひお知らせください!