
- 1. はじめに
- 2. 既存管理画面のリプレース背景
- 3. DDD導入における課題
- 4. 課題解決に向けた対策
- 5. Go言語を用いたDDDの実践
- 6. まとめ
- 7. 参考資料
1. はじめに
電子書籍開発部 基盤開発グループの山口です。
電子書籍開発部では、 DMMブックスのサーバーサイド開発・運用を通じて、お客様に快適な読書体験を提供しています。
基盤開発グループは、DMMブックスECサイト、バックオフィスで使う管理画面、API/BFF、バッチ処理など、幅広い領域を担当しています。安定稼働を目標とし、不具合対応に加え、システムの改善や新規施策のサポートも常に行っています。
私が携わっているバックオフィス向け管理画面サービスは、10年以上にわたって運用されてきた歴史あるサービスです。昨年、システムの負債脱却を目的とした大規模なリプレースプロジェクトを実施しました。 本記事では、このリプレースプロジェクトで直面したDDD(ドメイン駆動設計)の課題と、その対策について、Go言語での具体的な実践例を交えながらご紹介します。
2. 既存管理画面のリプレース背景
技術的負債の解消を主目的とし、従来のPHP(FuelPHP)で構築された管理画面を、フロントエンドにReact、バックエンドにGoを採用し、ドメイン駆動設計(DDD)を取り入れた構成で再構築しました。
2.1 技術選定の理由
2.1.1 フロントエンド: React
- コンポーネントベースのアーキテクチャは、UIの再利用性と保守性を大幅に向上させます。
- 豊富なライブラリと開発ツールが、開発生産性の向上を促進します。
2.1.2 バックエンド: Go
- シンプルな構文と強力な標準ライブラリにより、開発効率が向上し、大規模システムでも安定した動作が期待できます。
- 静的型付け言語であるGoは、コンパイル時にエラーを検出しやすく、保守性に優れています。
2.1.3 設計: ドメイン駆動設計(DDD)
- DDDは、ビジネスロジックを中心とした設計手法であり、システムの複雑性を軽減し、保守性を向上させます。
- DDDの導入により、コードの可読性・保守性が向上し、開発効率の改善が見込まれます。
2.2 再構築による期待効果
これらの技術選定とDDDの導入により、以下の効果が期待されます。
- システムの持続可能性向上:変化に強く、柔軟なシステムへの進化
- 開発生産性向上:迅速な機能追加と改修
- 保守性向上:長期的な運用コストの削減
- 開発者体験の向上:最新技術の導入によるモチベーション向上と人材確保
3. DDD導入における課題
DDDは、複雑なシステムをドメインモデルを中心とした設計によって整理し、開発・運用していくための有効な手段です。しかし、実際にプロジェクトに適用するにあたっては、いくつかの課題に直面しました。
3.1 DDDの概念理解と実践のギャップ
DDDを学ぶうえで、最初に直面したのが、その概念の理解と実践の乖離でした。DDDの書籍やWebサイトを参考に学習を進めましたが、実際にプロジェクトに適用しようとすると、抽象的な概念と具体的な実装との間にギャップを感じました。
例えば、Entity、Value Object、Aggregateなどの概念は理解できても、それを実際のGo言語のコードにどのように落とし込めばよいのか、具体的なイメージが掴みづらい状況でした。
3.2 Go言語におけるDDD実装のノウハウ不足
また、Go言語におけるDDDの実装例や解説が少ないことも課題でした。市販されているDDD関連書籍を見ても、Go言語に特化した実装例はほとんどありません。そのため、Go言語でDDDを実践するためのノウハウを、 自分たちで試行錯誤しながら蓄積していく必要がありました。
3.3 ビジネスロジックの適切な配置
DDDでは、ビジネスロジックをドメイン層に配置することが推奨されますが、その境界線を引くのが難しい場合もありました。特に、UIに関わる処理や、インフラ層との連携処理などをどのように扱うべきか、悩ましい場面がありました。
4. 課題解決に向けた対策
DDD導入における課題を克服するために、私たちは学習だけに留まらず、実際にプロジェクトにDDDを適用し、試行錯誤を繰り返しながらGo言語でのDDD実装ノウハウを蓄積していくという、実践重視のアプローチを取りました。
具体的には、DDDの概念をコードに落とし込む際に生じる疑問や課題に対して、チーム内で徹底的に議論し、最適な実装方法を模索しました。実装したコードは、必ずチーム内でレビューし、 DDDの原則に則っているか、より良い実装方法はないかなどを多角的に検討しました。このレビュープロセスを通じて、チームメンバー間で知識を共有し、DDD実装のスキル向上を図りました。 さらに、Go言語の特性を活かしたDDD実装方法を模索し、試行錯誤を繰り返すことで、徐々にGo言語でのDDD実装に慣れていき、自分たちなりのベストプラクティスを確立していくことができました。
プロジェクトが進むにつれて、DDDの有効性を肌で感じ、開発効率の向上やコードの品質向上を実感できました。特に、ドメインモデルを中心とした設計は、チームメンバー間での共通認識を醸成し、 コミュニケーションを円滑にする効果がありました。また、ビジネスロジックの明確な分離は、コードの可読性と保守性を向上させ、変更容易性と拡張性を高めることに繋がりました。
このように、実践と学習、そしてチーム内での継続的な知識共有を通じて、DDDを効果的に活用し、プロジェクトを成功に導くことができました。
5. Go言語を用いたDDDの実践
ここでは、私たちがプロジェクトで培ってきたGo言語を用いたDDDの実践方法を、具体的なコード例を交えながら詳細に解説します。
5.1 DDDの基本概念
Domain-Driven Design(ドメイン駆動設計)は、大規模組織向けの複雑なシステムを対象としたソフトウェア開発の手法です。複雑なビジネスドメインや問題領域を解決するために、そのドメインに焦点を当てる方法論となります。 もともとはEric Evans氏の著書『エリック・エヴァンスのドメイン駆動設計』で提唱されました。 DDDを理解し、実践していくうえで、Entity、Value Object、Aggregate、Repositoryなど、いくつかの重要な概念を抑えておく必要があります。 これらの概念をGoのコードに落とし込む方法を学ぶことで、より効果的にDDDを実践できます。
5.1.1 ドメイン (Domain)
ドメインとは、ビジネスが取り組む問題領域全体を指します。例えば、ECサイトであれば「商品の販売」「顧客管理」「在庫管理」「決済」などが個別のドメインとなります。 ドメインをよく理解したいのであれば、ドメインストーリーテリングをご一読ください。これらのテクニックを活用すれば、ドメインの複雑性を理解し、 ドメイン内で何が起こっているのかがわかり、ドメインを構成したり、分解したりする方法を学ぶことができます。
5.1.2 エンティティ (Entity)
エンティティは、一意の識別子を持つオブジェクトです。例えば、ECサイトの商品であれば、商品IDによって個々の商品が区別されます。
package domain type ProductID string type Product struct { ID ProductID Name string Price int } func NewProduct(id ProductID, name string, price int) *Product { return &Product{ ID: id, Name: name, Price: price, } }
この例では、ProductIDが識別子、Productがエンティティです。
5.1.3 バリューオブジェクト (Value Object)
バリューオブジェクトは、属性の値を表す不変のオブジェクトです。例えば、商品の価格は通貨単位と金額で構成されるバリューオブジェクトとして表現できます。
package domain type Currency string const ( JPY Currency = "JPY" USD Currency = "USD" ) type Price struct { amount int currency Currency } func NewPrice(amount int, currency Currency) Price { return Price{ amount: amount, currency: currency, } } // 金額を取得するためのメソッド func (p Price) Amount() int { return p.amount } // 通貨を取得するためのメソッド func (p Price) Currency() Currency { return p.currency }
Priceはバリューオブジェクトであり、金額と通貨の組み合わせでその値を表します。NewPrice関数でPriceオブジェクトを生成していますが、 この関数以外に金額や通貨を変更する手段が提供されていません。
5.1.4 アグリゲート (Aggregate)
アグリゲートは、エンティティとバリューオブジェクトの集合であり、一貫性の境界となります。 アグリゲートルートと呼ばれるエンティティがアグリゲート全体を管理します。例えば、注文は注文自体(エンティティ)と注文明細(バリューオブジェクト)からなるアグリゲートとして表現できます。
package domain import "time" type OrderID string type Order struct { ID OrderID Items []OrderItem OrderedAt time.Time } type OrderItem struct { ProductID ProductID Quantity int Price Price } func NewOrder(id OrderID, items []OrderItem) *Order { return &Order{ ID: id, Items: items, OrderedAt: time.Now(), } }
Orderがアグリゲートルートであり、OrderItemはバリューオブジェクトです。
5.1.5 リポジトリ (Repository)
リポジトリは、アグリゲートの永続化を担当するオブジェクトです。データベースへのアクセスなどを抽象化します。
package domain type OrderRepository interface { Save(order *Order) error FindByID(id OrderID) (*Order, error) }
これはインターフェースであり、具体的な実装はデータストア(例:データベース)に依存します。
5.1.6 サービス (Service)
サービスは、特定のエンティティやバリューオブジェクトに属さないドメインロジックを実行するオブジェクトです。例えば、注文の合計金額を計算する処理はサービスとして実装できます。
package domain func CalculateOrderTotal(order *Order) Price { totalAmount := 0 for _, item := range order.Items { totalAmount += item.Price.Amount * item.Quantity } return NewPrice(totalAmount, JPY) }
CalculateOrderTotalはサービスであり、Orderアグリゲートの合計金額を計算します。
5.2 DDDにおけるDI/DIP
DDDを実践するうえで、特に重要な概念の一つが依存性逆転の原則(DIP)と依存性注入(DI)です。 DIPとは、高レベルモジュールと低レベルモジュールを抽象に依存させることで、モジュール間の結合度を下げる原則です。 DIは、このDIPを実現するための具体的な実装方法の一つで、外部から依存性を注入することで、モジュールの独立性を高めます。
Go言語では、インターフェースと構造体を組み合わせることで、DIP/DIを効果的に実装できます。 DIP/DIを活用することで、ドメイン層の独立性を保ち、ビジネスロジックを他の層から切り離すことが可能になります。
5.2.1 依存性逆転の原則(DIP)とは?
依存性逆転の原則(Dependency Inversion Principle, DIP)は、SOLID原則の一つです。これは以下の2つの要点を含んでいます。
- 上位モジュールは下位モジュールに依存すべきではありません。両方とも抽象(インターフェース)に依存すべきです。
- 抽象は詳細(具体的な実装)に依存すべきではありません。詳細が抽象に依存すべきです。
簡単に言うと、「具象クラス(具体的な実装)ではなく、抽象クラス(インターフェース)に依存せよ」ということです。つまり、インターフェースに対してプログラミングするべきで、具体的な実装に対してプログラミングするべきではありません。
5.2.2 依存性注入(DI)とは?
依存性注入(Dependency Injection, DI)は、DIPを実現するための具体的な手法です。モジュール間の依存関係をモジュール内部から外部に移し、外部が依存オブジェクトの生成と注入を担当します。これにより、モジュール間の結合度を下げ、変更に強く、テストしやすいコードを書くことができます。
5.3 Goのコードを用いてDDDにおけるDIPとDIをどのように実装するか?
それでは、実際にGoのコードを用いて、DDDにおけるDIPとDIをどのように実装するのか、具体的な例を挙げて解説していきます。
5.3.1 ドメイン層(domainパッケージ)
まず、ドメイン層を定義します。ここにはビジネスロジックとエンティティ、インターフェースが含まれます。
package domain // User エンティティ type User struct { ID int Name string Email string } // UserRepository インターフェース type UserRepository interface { FindByID(id int) (*User, error) Save(user *User) error } // UserService サービス(ビジネスロジック) type UserService struct { repo UserRepository // UserRepositoryインターフェースに依存 } // NewUserService UserServiceのコンストラクタ func NewUserService(repo UserRepository) *UserService { return &UserService{repo: repo} } // RegisterUser ユーザー登録処理 func (s *UserService) RegisterUser(name, email string) (*User, error) { // ビジネスロジック(例:メールアドレスのバリデーションなど) if email == "" { return nil, fmt.Errorf("email is required") } user := &User{Name: name, Email: email} err := s.repo.Save(user) if err != nil { return nil, err } return user, nil }
重要な点: UserServiceはUserRepositoryインターフェースに依存しており、具体的な実装(例えばデータベースアクセス)には依存していません。これがDIPの重要なポイントです。
5.3.2 インフラストラクチャ層(infrastructureパッケージ)
次に、インフラストラクチャ層でUserRepositoryインターフェースの実装を提供します。
package infrastructure import ( "database/sql" "fmt" "example/domain" // ドメインパッケージをインポート ) // MySQLUserRepository MySQLを使ったUserRepositoryの実装 type MySQLUserRepository struct { db *sql.DB } // NewMySQLUserRepository MySQLUserRepositoryのコンストラクタ func NewMySQLUserRepository(db *sql.DB) *MySQLUserRepository { return &MySQLUserRepository{db: db} } // FindByID UserRepositoryインターフェースの実装 func (r *MySQLUserRepository) FindByID(id int) (*domain.User, error) { // データベースアクセス処理 var user domain.User err := r.db.QueryRow("SELECT id, name, email FROM users WHERE id = ?", id).Scan(&user.ID, &user.Name, &user.Email) if err != nil { return nil, fmt.Errorf("failed to find user: %w", err) } return &user, nil } // Save UserRepositoryインターフェースの実装 func (r *MySQLUserRepository) Save(user *domain.User) error { // データベース保存処理 _, err := r.db.Exec("INSERT INTO users (name, email) VALUES (?, ?)", user.Name, user.Email) return err }
5.3.3 main関数(DIの実行)
最後に、main関数でDIを実行します。
package main import ( "database/sql" "fmt" "log" _ "github.com/go-sql-driver/mysql" "example/domain" "example/infrastructure" ) func main() { db, err := sql.Open("mysql", "user:password@tcp(localhost:3306)/database") if err != nil { log.Fatal(err) } defer db.Close() // DI: MySQLUserRepositoryをUserServiceに注入 repo := infrastructure.NewMySQLUserRepository(db) service := domain.NewUserService(repo) // ユーザー登録処理 user, err := service.RegisterUser("田中 太郎", "tanaka@example.com") if err != nil { log.Fatal(err) } fmt.Println("登録されたユーザー:", user) user2, err := repo.FindByID(1) if err != nil { log.Fatal(err) } fmt.Println("取得されたユーザー:", user2) }
DIのポイント: main関数でMySQLUserRepositoryのインスタンスを作成し、それをNewUserServiceに渡しています。これにより、UserServiceは具体的なデータベースアクセス方法を知らずに、ビジネスロジックを実行できます。
この例を通して、DI/DIPを適用することで、以下のようなメリットが分かります。
- DIP(依存性逆転の原則): UserServiceは具体的な実装ではなく、UserRepositoryインターフェースに依存しています。
- DI(依存性注入): main関数でMySQLUserRepositoryのインスタンスをUserServiceに注入しています。
- テストの容易性: テスト時には、モックのUserRepositoryをUserServiceに注入することで、データベースにアクセスせずにテストを行うことができます。
- 変更への柔軟性: データベースを変更する場合(例えばMySQLからPostgreSQLへ)、UserRepositoryインターフェースを実装する新しいリポジトリを作成し、main関数で注入する実装を変更するだけで済みます。ドメイン層のコードを変更する必要はありません。
6. まとめ
この記事では、Go言語を用いたDDDの実践方法を解説しました。DDDは複雑なシステム開発において非常に有効な設計手法ですが、導入にあたっては様々な課題に直面する可能性があります。しかし、継続的な学習、実践を通じたノウハウの蓄積ことで、保守性と柔軟性に優れたソフトウェア開発が可能です。この記事が、Go言語を用いたDDDの実践に挑戦する皆様にとって、少しでも参考になれば幸いです。
7. 参考資料
私たちと一緒に働く仲間を募集しております。興味を持っていただけた方はよろしくお願いします。 dmm-corp.com