Go×受託×小中大規模案件×AIコーディングで理想のコードベースを設計した話:DDD × クリーンアーキテクチャ
株式会社DigeonでPM兼開発者の伊藤誠也です。
はじめに
昨今、バイブコーディングにより、AIによってコーディングコストが激減しました。
一方、情報量の爆増とその読みにくさという新たな困難が発生しました。
今回は、それらの解決に挑んだ弊社の業務システム向けアーキテクチャをご紹介します。
結論としては、タイトル通りのDDDとクリーンアーキテクチャの両輪軸により困難の解消を図りました。
背景やあるあるの経験、そこから具体的なソースコードの変化の例までを詳細に説明いたしますので、一助になれば幸いです。
背景:ビジネスの性質とクリーンアーキテクチャの適用で起きていた2つの問題
弊社では、かねてよりクリーンアーキテクチャを採用しています。
API実装のためのアーキテクチャとして、以下の条件における「速度」「予算感」などのあらゆるビジネス的側面と「保守性」「拡張性」などのあらゆる技術的側面を同時に満たす構造を模索し続けてきました。
- 受託開発である
- 小、中、大規模のシステムを納期内にさまざまに作り続ける
- 外注エンジニアの入れ替わりがある
- 多様なAIエージェントによるAIコーディングも行われる
- Backend API(Go) + SPA(React)構成
なおここでは、システム規模ごとのアーキテクチャをそもそも変えるべきである、や、API + SPA構成のコストを減らしてrailsを採用すれば速度、予算を…などは議論の対象にせず、上記の制約を原理として考えます。
そんな中で、この環境下におけるクリーンアーキテクチャベースでの開発において、関連の記事でも多く見られるような以下の問題が弊社でも発生しました。
domain / usecase の責務が曖昧になるなどのビジネス層の課題
必ず起こる問いがこれでした。
domain・usecase層は存在するが、
- ビジネスロジックって domain に書く? usecase に書く?
- 例外・err の定義や出し分けってどこでどのようにやる?
- 更新条件(どのデータがどの条件で更新できるか)をどこで守る?
複数人(+ AI)がコーディングをすると、書きぶりの好み、くせの違い、アーキテクチャへの理解の差などの様々な要因で成果物が揺れます。
ある時点におけるentityの完全性を保証するvalidation一つとっても、別のusecaseでの登録・更新条件と必ずしも整合しないなどはよくあることで、弊社では現実解として「domain は型だけ、ロジックは usecase に全部書く」に寄っていきました。
ただ、これだとusecaseのコードを読めばやっていることは理解できるのですが、冗長で崩れやすく難解となり、ドメイン知識、ビジネスロジックが分散された実装も見受けられました。
しかし、それが弊社の妥協点でした。
結局大きなロジックは散らかるから、その妥協が許容できないレベルとなる日はいつか来るわけです。
repositoryの関数の分岐点のあいまいさなどの永続化層の課題
ユーザー情報 + 職歴のデータを例にとりましょう。
すると、クリーンアーキテクチャでは以下の点に対する指摘や議論が散見されます。
弊社も同様にその課題は発生しました。
DB設計とrepo の切り分け
1対多の永続化責務をどうするかという点について、UserRepo と WorkHistoryRepo を分ける or 分けない という観点
引数の型をどうするか・entityベースを許容するか
domainで定義された型の値をどのようにrepositoryに渡すかという点について、DTOの定義をどこまで細かく切り分けるべきか、型の定義をどこに置くのが良いのか etc… という観点
更新や検索関数が増殖
UpdateXxxByYyyAndZzz や FindByID / FindByEmail / FindBy... が増殖する観点
(Go固有) Create, Update の引数の構造体のnil, 0値をrepositoryでどう取り扱うか
例えばGoのgormは以下の癖があり、引数の値から技術者のちょっとした勘違いで、レビューをすり抜けるバグを発生させること
- Create関数 + structのゼロ値は、DBにnull
- Update関数 + structはゼロ値更新しない
- Update関数 + mapはゼロ値更新する
- etc… ORMの仕様に翻弄される
コストへの跳ね返り
受託開発において、「案件 / 人 / 規模」が「増える/減る/入れ替わる」を繰り返すほど、それらがコスト化しました。
それらを解決するのが今回の新アーキテクチャになります。
以降では、今到達した設計がどうであるかを精密に説明していきたいと思います。
これまでの、それらを解決するためのあらゆる試行錯誤や、そのたびに出る課題、トレードオフを語りたい衝動を抑えて…
方針:DDDの“データ境界”だけ入れる
上記の問題の共通部分に着目すると、
- 層の行き来における型の定義そのものが揺れる
- ある型の値のバリデーションの管理場所が揺れる
- repositoryの肥大化と意味の隠蔽や実装の分離を両立できない、また、最適化できない
- domain, usecase, repositoryの各層の存在そのものが時にドメイン知識やビジネスロジックを分散させる
であると言えます。
このことから、DDDのデータ境界の概念を採用しました。
中でも以下の方針を処方箋としました。
- Domain層における ValueObject / Create / Update の型定義と Policyの定義
- usecase(interactor) を「一直線の手順書」に寄せる: if 分岐を domain policy 側に吸収してusecaseをシンプル化
- repository を「関数を増やさない」形にする: marker + 型 + type switch で吸収する
なお蛇足ですが、リファクタリングに踏み出せた背景として、AI時代であるからこその以下の点が挙げられます。
- ValueObjectに始まるDDD関連のコーディング・学習コストがAIによって非常に低くなった
- Goのinterfaceを最適に活用した実装方針をAIで素早く壁打ち・構築できる環境だった
結果として、上記を含む既存のアーキテクチャの再構成とリファクタリングのフィードバックを2日で回しきることができました。
それでは次に、具体的なコードや構成を見ていきましょう。
設計:domain層 を “ドメイン単位” で切り、境界を深く置いた
パッケージ構成
もともとはdomainパッケージにentityを定義していましたが、新たな設計としてdomain 配下を概念(Entity)単位で切りました。
例として user はこうです。
-
domain/user:Entity(業務データ + 判定) -
domain/user/value:ValueObject / enum / ドメイン定数 -
domain/user/create:作成入力境界 -
domain/user/update:更新入力境界 -
domain/user/policy:判断・秘匿・整合性
Entity:User は「VOで表現された業務データ」+「純粋なドメイン知識」
// domain/user/entity.go
type User struct {
UserID uservalue.UserID
Name uservalue.Name
Email uservalue.Email
HashedPassword uservalue.HashedPassword
UserType uservalue.UserTypeObject
}
func (u User) IsSystemAdmin() bool {
return u.UserType.Value() == uservalue.SystemAdmin.Value()
}
「更新できる条件」「秘匿」「重複判定」などは、後述の policy と入力境界に寄せます。
ValueObject:最小限の型安全 + validation.Errors
例として Emailは以下の通り、典型的なDDDのValueObjectです。
// domain/user/value/email.go
type Email struct{ v string }
func NewEmail(raw string) (Email, error) {
s := strings.TrimSpace(raw)
if err := validation.Validate(s,
validation.Required,
validation.RuneLength(1, 255),
is.Email,
); err != nil {
return Email{}, domain.WrapValidationError("Email", err)
}
return Email{v: s}, nil
}
func (e Email) Value() string { return e.v }
ここで domain.WrapValidationError を使い、単項目エラーでも validation.Errors に揃えます。
Create / Update:永続化したい形を“型”で固定した
CreateUser(作成入力境界)
// domain/user/create/create_user.go
type CreateUser struct {
UserID uservalue.UserID
Name uservalue.Name
Email uservalue.Email
HashedPassword uservalue.HashedPassword
UserType uservalue.UserTypeObject
}
func NewCreateUser(userID, name, email string, hashedPassword uservalue.HashedPassword, userType uservalue.UserType) (_ CreateUser, err error) {
id, errUserID := uservalue.NewUserID(userID)
n, errName := uservalue.NewName(name)
e, errEmail := uservalue.NewEmail(email)
ut, errUserType := uservalue.NewUserTypeObject(userType)
errHashedPassword := validation.Validate(hashedPassword.Value(), validation.Required)
if err := (validation.Errors{
"UserID": domain.NormalizeValidationError(errUserID),
"Name": domain.NormalizeValidationError(errName),
"Email": domain.NormalizeValidationError(errEmail),
"HashedPassword": errHashedPassword,
"UserType": domain.NormalizeValidationError(errUserType),
}).Filter(); err != nil {
return CreateUser{}, err
}
return CreateUser{UserID: id, Name: n, Email: e, HashedPassword: hashedPassword, UserType: ut}, nil
}
func (CreateUser) IsCreate() {}
func (CreateUser) IsUser() {}
「永続化する値(DB保存対象)」と「検証」の対応が型で固定されます。
これにより、バリデーションとデータ境界を定義できました。
また、後述しますが、副次的な作用としてrepositoryのためのDTOも無くせました。
IsCreate, IsUserにはそれに関連する意味がありまして、詳細はのちほど説明します。
UpdateUser / UpdateEmail / UpdateHashedPassword(更新入力境界)
更新は “更新可能フィールドだけ” を持った型で表現します。
これによって、repository実装における更新対象のデータがどれであるか、の不整合の事故を防ぎます。
// domain/user/update/update_hashed_password.go
type UpdateHashedPassword struct {
HashedPassword uservalue.HashedPassword
}
func NewUpdateHashedPassword(hashedPassword uservalue.HashedPassword) (_ UpdateHashedPassword, err error) {
if err := (validation.Errors{
"HashedPassword": validation.Validate(hashedPassword.Value(), validation.Required),
}).Filter(); err != nil {
return UpdateHashedPassword{}, err
}
return UpdateHashedPassword{HashedPassword: hashedPassword}, nil
}
func (UpdateHashedPassword) IsUpdate() {}
func (UpdateHashedPassword) IsUser() {}
Policy:if を interactor から奪った
ログイン失敗の秘匿を domain に置きました。
// domain/user/login_policy.go
var (
ErrWrongEmailOrPassword = errors.New("wrong email or password")
ErrPasswordDoNotMatch = errors.New("password does not match")
)
func (u User) CheckExistAtLogin(err error) error {
if errors.Is(err, ErrUserNotFound) {
return ErrWrongEmailOrPassword
}
return err
}
func (u User) MaskPasswordMismatch(err error) error {
if errors.Is(err, ErrPasswordDoNotMatch) {
return ErrWrongEmailOrPassword
}
return err
}
これらの定義により、interactor 側は「実行→policyで評価→return」に固定できます。
今まではdomain層 + 型名.goでのパッケージ管理をしていたためにこういったドメイン知識の整理方針がなく、domainに型以外のコードを追加し始めると混沌化していったので、DDDが早速効いてきています。
domain, usecase, repository層で区切り、その下に型名のファイルを管理するようなクリーンアーキテクチャのサンプルは多くあるように感じますが、domain層だけルールを変えるほうがより整理が進むことが実感できました。
また、usecaseのコードのBefore / Afterを確認いただくと、その最適さが分かります。
実装:usecase(interactor) を“手順書”に固定した
Login:一直線の流れにした
passwordの不整合を隠蔽するためのLoginロジックの例です。
Beforeでは、errorハンドリングやerrorそのものの変換などの責務を担っていますが、Afterでは関数の1実行と、必要ならばerr評価用の関数の実行からerrを横流しするだけになり、コードの意味も明確になりました。
// Before
func (u *UserUseCase) Login(email, password string) (string, error) {
user, err := u.userRepo.FindByEmail(email)
if err == output_port.ErrUserNotFound {
return "", input_port.ErrWrongEmailOrPassword
}
if err != nil {
return "", err
}
err = u.userAuth.CheckPassword(user, password)
if err == output_port.ErrWrongPassword {
return "", input_port.ErrWrongEmailOrPassword
}
if err != nil {
return "", err
}
token, err := u.userAuth.IssueUserToken(user, u.clock.Now())
if err != nil {
return "", err
}
return token, nil
}
// After
func (u *UserUseCase) Login(email, password string) (uservalue.UserToken, error) {
emailVO, err := uservalue.NewEmail(email)
if err != nil { return uservalue.UserToken{}, err }
targetUser, err := u.userRepo.Find(nil, output_port.UserBy(emailVO))
if err = targetUser.CheckExistAtLogin(err); err != nil {
return uservalue.UserToken{}, err
}
err = u.userAuth.CheckPasswordMatching(targetUser.HashedPassword, password)
if err = targetUser.MaskPasswordMismatch(err); err != nil {
return uservalue.UserToken{}, err
}
return u.userAuth.IssueUserToken(targetUser.UserID, u.clock.Now())
}
usecase に散らばりがちな分岐が消え、読みやすくなりました。
しかしこれではまだ本質的ではありません。
単純に思いつくリファクタリングにお見受けするレベルだと思います。
しかし、これではdomainに書けはするが、あれはusecase層に書いておくべきでは?といった分岐がまだまだ発生し得る状況です。
しかし、後述の手法でさらにそういったことを全てDomain層に押し込む方針を標準化しており、そこに本質があります。
Create:入力境界→重複判定policy→repo の順に固定した
次に、email重複を確認してからCreateUserする例です。
先ほどと比べ、domain層で定義した型をそのままrepository層に渡している点にも注目してください。
// Before
func (u *UserUseCase) Create(userCreate input_port.UserCreate) (entity.User, error) {
hp, err := u.userAuth.HashPassword(userCreate.Password)
if err != nil {
return entity.User{}, err
}
user, err := factory.NewUserFromCreate(userCreate, u.ulid.GenerateID(), hp)
if err != nil {
return entity.User{}, err
}
_, err := u.userRepo.FindByEmail(userCreate.Email)
if errors.Is(err, output_port.ErrUserNotFound) {
// errがErrUserNotFoundの場合はcreateできるのでreturnしない
} else if err != nil {
// errがErrUserNotFound以外の場合はそのerrを返す
return entity.User{}, err
} else {
return entity.User{}, valueobject.ErrDuplicateUserEmail.NewError(userCreate.Email)
}
if err = u.userRepo.Create(nil, user); err != nil {
return entity.User{}, err
}
return u.userRepo.FindByID(nil, user.UserID)
}
// After
func (u *UserUseCase) Create(in input_port.UserCreate) (user.User, error) {
if err := uservalue.ValidatePassword(in.Password); err != nil {
return user.User{}, err
}
hp, err := u.userAuth.HashPassword(in.Password)
if err != nil { return user.User{}, err }
newUser, err := usercreate.NewCreateUser(
u.ulid.GenerateID(),
in.Name,
in.Email,
hp,
in.UserType,
)
if err != nil { return user.User{}, err }
duplicateUser, err := u.userRepo.Find(nil, output_port.UserBy(newUser.Email))
if err := duplicateUser.CheckEmailUpsertPolicy(err, newUser.UserID); err != nil {
return user.User{}, err
}
if err := u.userRepo.Create(nil, newUser); err != nil {
return user.User{}, err
}
return u.userRepo.Find(nil, output_port.UserBy(newUser.UserID))
}
これも見通しが非常に良くなっています。
このように、usecaseはdomain層とrepository層の関数を呼び、ただerrを返却するだけの形を徹底できています。
ただところどころに、"単純に見えるが単純でない"書き方が出てきていることにお気づきでしょうか?
今回施したリファクタリングの肝が、その点にも宿っています。
設計:repository の「関数増殖」を止めた(output_port + marker + type switch)
ここが最も特徴的で面白い個所だと思っています。
実際には、ここでの方針が逆説的にdomainとusecaseの書き方を決定づけた側面があります。
output_port:CRUD を “5関数” に固定した
UserRepository のインターフェースと型定義をシンプル化しました。
interfaceはなるべく減らして、「repository関数内でドメイン知識に触れずに分岐したい」というのが当初のモチベーションでした。
// Before
type UserRepository interface {
Create(tx interface{}, user entity.User) error
Delete(userID string) error
FindByEmail(email string) (entity.User, error)
FindByID(tx interface{}, userID string) (entity.User, error)
Search(UserSearch) ([]entity.User, int, *int, error)
Update(entity.User) error
UpdateEmail(tx interface{}, user entity.User) error
UpdatePassword(tx interface{}, user entity.User) error
}
// 必要なら型が続く。
// 上記はentityを用いているが、DTOの定義も大量発生していた。
// after
type UserRepository interface {
Create(tx any, c UserCreate) error
Delete(tx any, by UserDelete) error
Find(tx any, by UserFindBy) (user.User, error)
Search(tx any, args UserSearch) ([]user.User, int, *int, error)
Update(tx any, by UserUpdateBy, u UserUpdate) error
}
// UserCreate は Repository.Create の入力境界を表します。
// domain の Entity と切り離し、「永続化したいデータの型」をユースケースに応じて表現します。
type UserCreate interface {
IsCreate
IsUser
}
// UserUpdate は Repository.Update が受ける更新用の型です。
type UserUpdate interface {
IsUpdate
IsUser
}
// UserFindBy は UserRepository の検索条件を表します。
type UserFindBy interface {
IsUser
IsFind
}
// UserFindByKey は UserRepository 向けの検索キーです。
type UserFindByKey[T any] struct {
FindBy[T]
}
func (UserFindByKey[T]) IsUser() {}
// UserBy は UserRepository 向けの検索キーを生成します。
func UserBy[T any](v T) UserFindByKey[T] {
return UserFindByKey[T]{FindBy: By(v)}
}
// UserDelete は UserRepository の削除条件を表します。
type UserDelete interface {
IsUser
IsDelete
}
// UserDeleteByKey は UserRepository 向けの削除キーです。
type UserDeleteByKey[T any] struct {
DeleteBy[T]
}
func (UserDeleteByKey[T]) IsUser() {}
// UserDel は UserRepository 向けの削除キーを生成します。
func UserDel[T any](v T) UserDeleteByKey[T] {
return UserDeleteByKey[T]{DeleteBy: Del(v)}
}
// UserUpdateBy は UserRepository の更新キーを表します。
type UserUpdateBy interface {
IsUser
IsUpdate
}
// UserUpdateByKey は UserRepository 向けの更新キーです。
type UserUpdateByKey[T any] struct {
UpdateBy[T]
}
func (UserUpdateByKey[T]) IsUser() {}
// UserUpd は UserRepository 向けの更新キーを生成します。
func UserUpd[T any](values ...T) UserUpdateByKey[T] {
return UserUpdateByKey[T]{UpdateBy: Upd(values...)}
}
// UserSearch はユーザー検索用の引数です。
type UserSearch struct {
Page int
Take int
Q string
Order Order
OrderBy UserOrderBy
UserType uservalue.UserType
}
FindByEmail や UpdatePassword といった関数を実装しません。
その代わり、元々は存在していなかった抽象的なinterface型が増えています。
repository 実装1 Create:type switch で吸収した
Create関数の引数は
type UserCreate interface {
IsCreate
IsUser
}
を取っています。
これによって、domainの型でお見せした、
IsCreate
IsUser
を実装している型であれば、このCreate関数に渡せるようになります。
Domainがアウトプットする型は必要な値だけを持っており、かつバリデーションが完了した構造体ですので、これにより、データの永続化の仕組みにおいて、以下を達成できました。
- 「Createの型である」とdomainが定義づけた型だけをCreate関数に渡せるという、domainによるデータ境界の制約を達成
- 渡ってきた型が持つ値をただ永続化する責務を担えばよいという、実装の単純さも両立
- validationやその他の自由なロジックをセットにしたDTOの生成をdomain層に書ける形が定まったことで、冗長さや散らばりの可能性(validationはusecase層に、DTOはoutput_port層に…etc)を大幅に軽減
- entityの何が更新できるのか、repository層の中で実質的なドメインロジックを構成したり、呼んでしまっていないか、呼ばなければならない状況が発生しないか、等の、repository層への業務知識のしみ出し問題を最小限に
では、次に Create 関数の実体を見ていきます。
内部では “作成型” ごとに分岐します。
以下は、EmailをUpdateするためにUserが一時保有するTokenを管理する状態(CreateUserEmailUpdateToken)もuserドメイン管理とした場合の例です。
CreateUserEmailUpdateToken型はuserドメインでの定義のため、CreateUser型の他にUserRepositoryのCreateでCreateUserEmailUpdateTokenも永続化するようにした例です。
※この例は極端です。同一ドメインで管理することにするならば、型や永続化テーブルが違っていても同一ドメイン管理に矛盾しない例としてあえて書いています。
// After
// repository内部の実装
switch c := create.(type) {
case usercreate.CreateUser:
return tx.Create(&model.User{...}).Error
case usercreate.CreateUserEmailUpdateToken:
return tx.Create(&model.UserEmailUpdateToken{...}).Error
case usercreate.CreateUserPasswordUpdateToken:
return tx.Create(&model.UserPasswordUpdateToken{...}).Error
default:
return apierror.ErrUnexpected.Wrap(fmt.Errorf("unsupported create type: %T", create))
}
型がDomain層で列挙・決定づけられているので、それでswitchする形で書けました。
これにより、repositoryはあくまで取りうる型に応じてデータを永続化する責務のみに限定できました。
逆説的に感じられるかもしれないですが、外部(DB)との境界の定義をどうするかということそのものが、アーキテクチャをいかにクリーンに保つかを定義づける重要なポイントであることが分かります。
repository 実装2 Update:同様にtype switch で吸収した
Update も “更新型” ごとに固定した updates を使います。
// 引数に u を取り、以下の処理をする
switch p := u.(type) {
case userupdate.UpdateUser:
updates = map[string]any{ "email": ..., "name": ..., "user_type": ... }
case userupdate.UpdateEmail:
updates = map[string]any{ "email": ... }
case userupdate.UpdateHashedPassword:
updates = map[string]any{ "hashed_password": ... }
default:
return apierror.ErrUnexpected.Wrap(fmt.Errorf("unsupported update type: %T", u))
}
// updatesを永続処理する
この書き方により、Updateのためのrepo 関数はほとんどの場合でこれ以上増えません。
データ境界の型(UpdateUserやUpdateEmailなど)が保持している値を全てそのまま永続化するだけであると定義しているので、switch文が想起させる複雑さ以上に、実体はシンプルです。
単体テストも非常に書きやすく、「domainで定義されてる型の値がその通りに永続化されること」を確認すれば良いわけですので、非常に理にかなっていると考えています。
repository 実装3 where xxx = XXX:valueobjectの型を利用して1本化した
検索・削除・更新のキーは、汎用の criteria と domain 別 wrapper で作ります。
ValueObjectで定義されている型をwrapした型を返却するfactory関数がこちらです。
// output_port/user_repo.go
func UserBy[T any](v T) UserFindByKey[T] { ... }
func UserDel[T any](v T) UserDeleteByKey[T] { ... }
func UserUpd[T any](values ...T) UserUpdateByKey[T] { ... }
repositoryの実装は以下の通りで、wrapされたValueObjectを型判定することで、それ自身をwhere句で指定することに成功しています。
// after
func (r *UserRepository) Find(txAny any, by output_port.UserFindBy) (_ user.User, err error) {
tx, err := r.getTx(txAny)
if err != nil {
return user.User{}, err
}
var col string
var value string
switch b := by.(type) {
case output_port.UserFindByKey[uservalue.UserID]:
col, value = "user_id", b.Value.Value()
case output_port.UserFindByKey[uservalue.Email]:
col, value = "email", b.Value.Value()
default:
return user.User{}, output_port.ErrUnexpected.Wrap(fmt.Errorf("unsupported find type: %T", by))
}
ret := &model.User{}
err = tx.Model(&model.User{}).
Where(col+" = ?", value).
First(ret).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return user.User{}, output_port.ErrUserNotFound
}
if err != nil {
return user.User{}, output_port.ErrFailedToExecuteSQL.Wrap(err)
}
return ret.Entity(), nil
}
これにより、usecaseなどでの呼び出し側は以下のようになります。
// Before
u.userRepo.FindByID(nil, user.UserID)
u.userRepo.FindByEmail(input.NewEmail)
// After
u.userRepo.Find(nil, output_port.UserBy(newUser.Email))
u.userRepo.Find(nil, output_port.UserBy(newUser.UserID))
このように、ValueObjectの型の定義は副次的に値そのものの判別機能につながっており、Repositoryの関数の最適化に一役買いました。
また、このテクニックはUpdateのWhere部分にも適用しているので、「あるValueObjectをKeyとして、ある値についてのみ更新する」を1関数で表現できました。
旧定義の例:
- UpdateTotalAmountByPaymentID
- UpdateTaxByPurchaseID
- Update…
=> すべてUpdate関数1つでdomain的に安全・型も安全にコーディング可能になります。
この書き方は、基本的な業務システムに発生するCRUD領域を大幅にカバーできるため、非常に有効であると考えています。
アーキテクチャまとめ:クリーンなままにdomain, usecase, repositoryの書き方ルールが簡単に
上記の設計により、
- Domainとは?:純粋な型と純粋なロジックを全て書けばよい
- Usecaseとは?:関数を呼び出してerrを直で返せばよい
- Repositoryとは?:ほぼCRUDSだけ実装したらよい
というシンプルな説明が一貫できるようになりました。
私個人の一つの考えとして「どこに何を書くべきだ、という強いレビュワーがいなくなっても、同じようなコードの書かれ方をするか」がアーキテクチャとしての芯の強さだと思っていて、それを達成できたように感じています。
AGENTS.md:AIコーディングを“暴れさせない”ためにルールを置いた
今までご紹介した内容は、層毎のディレクトリでのAGENTS.mdに明記しています。
これにより、
- ValueObject等の面倒な定義
- OutputPort層, Repository層の複雑なinterfaceや実装
を、AIに正確に書かせることができます。
このアーキテクチャでは、「こう書いてね」ではなく「こう書かないとbuildが通らない」という形自体もルール化できていると考えています。
得られたものサマリ:AIが書いてもレビューできる
- domain に「更新条件」「重複判定」「秘匿」「最新トークンのみ」などの知識が戻り、書き場所の整理までできた
- interactor が “一直線の手順書” になり、読みやすさが上がった
- repository が “関数を増やさない” 形に固まり、拡張が関数の数を肥大化させなくなった
- map update の事故(入れ忘れ/過剰更新)を、Update型で潰せた
- AGENTS.md で設計意図を固定し、複数参画 + AI のブレを抑えられた
まとめ:DDDは「受託×小中大規模案件×AI」に効く
もともとクリーンアーキテクチャをベースとした開発とブラッシュアップ・思考錯誤を続けてきており、会社としての慣れを壊さずにより良いアーキテクチャをどう適用するか、という点は大きな課題でした。
一方で長い間、自身らの設計・アーキテクチャにブレークスルーはありませんでした。
かといって純粋なDDDの適用は、その経験値のなさや推奨されるディレクトリ構成の違いからリスクを取るに至らなかった状況です。
そんな中で、考え方を間借りするような形はできないかと思ってChatGPTと壁打ちし始めたのが一つのきっかけでした。
案件規模も会社の成長とともに大きくなる中で、やはり課題となったドメイン知識の管理などから、AIによるシステム開発の文脈においてもテンプレートの実装・アーキテクチャを非常に最適化できたのではないかと考えています。
AIはコードをたくさん書けます。
だからこそ、人間がレビューできる形で書かせる仕組みづくりが一番複利になると考えています。
Discussion