本記事でやること
- レイヤードアーキテクチャを題材として密結合なコードを紹介
- 密結合だとどういう問題があるのかを紹介
- 疎結合なコードに変更
- 疎結合にするとどういうメリットがあるのかを紹介
今回実装したコードはこちらのレポジトリで公開しております。
対象読者
- 「密結合」・「疎結合」というワードは知っているが、実際にどういう状態を指すのかわからない方
- 「密結合」だとどういう問題があるのかを知りたい方
- 「疎結合」だとどういう利点があるのかを知りたい方
使用言語
- Go言語 1.21.0
背景
システム設計やソースコードなど含め、「疎結合」に保つことが重要というのはよく耳にすると思います。
筆者の過去の経験ですが、インターネットで「密結合」や「疎結合」について調べてみても、モノに例えて説明している記事が多く、腑に落ちないことがありました。
そこで、より実務に沿った内容で密結合と疎結合について考えることができないかと思い、レイヤードアーキテクチャを題材にしてみました。
レイヤードアーキテクチャの紹介
レイヤードアーキテクチャは、アプリケーションが持つ責務をいくつかの層に分け、各層の依存の向きを一方向に制御する、設計パターンの1つです。
この他にも、クリーンアーキテクチャやオニオンアーキテクチャなどがありますが、各設計パターンはアプリケーションの責務を定義し、依存の向きを明確にすることで共通しています。
今回想定するアプリケーションと各層の定義
今回は、レイヤードアーキテクチャを題材に密結合と疎結合について考えていきます。 簡略化のため、以下の3層のみを考えます。
レイヤードアーキテクチャは、Presentation層からInfrastructure(以下Infra)層に向かって上から下に依存の向きが一方向になるように設計されます。
また、想定するアプリケーションは入力されたユーザーIDを元にデータベースからユーザー名を取得するというものとします。
以下に、各層の責務と各層で定義しているメソッド名も合わせて記載します。
| 層 | 責務 | メソッド |
|---|---|---|
| Presentation | ユーザーからの入力を受け取り、Usecase層に渡す | GetUserHandler |
| Usecase | ユーザーからの入力を元にユーザー名を取得する | GetUserById |
| Infrastructure | データベースからユーザー名を取得する | Get |
レイヤードアーキテクチャにおける密結合なコード
ソフトウェアにおける密結合な状態とは、「あるモジュールが他のモジュールに強く依存しており、どちらかを変更するともう一方も変更する必要がある状態」のことを指します。
以下にUsecase層(GetUserByIdメソッド)とInfra層(Getメソッド)のコードを示します。
GetUserByIdメソッドの中でGetメソッドを呼んでおり、さらにGetUserByIdメソッドが受け取ったdb引数(sql.DB型)をそのままGetメソッドに渡しています。
つまり、この状態はGetUserByIdメソッドがGetメソッドに強く依存している状態と言えます。
type UserDTO struct {
Id int
Name string
}
func GetUserById(ctx context.Context, userId int, db *sql.DB) (UserDTO, error) {
user, err := infra.Get(ctx, userId, db)
if err != nil {
// error handling
}
return UserDTO{
Id: user.Id,
Name: user.Name,
}, nil
}
type User struct {
Id int
Name string
}
func Get(ctx context.Context, userId int, db *sql.DB) (User, error) {
u := User{}
err := db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", userId).Scan(&u.Id, &u.Name)
if err != nil {
// error handling
}
return u, nil
}
次に「どちらかを変更するともう一方も変更する必要がある状態」について紹介します。
例えば、Infra層がアクセスするデータベースがElasticsearchに変わった場合を考えます。
- func Get(ctx context.Context, userId int, db *sql.DB) (User, error) {
+ func Get(ctx context.Context, userId int, db *elasticsearch.Client) (User, error) {
u := User{}
- err := db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", userId).Scan(&u.Id, &u.Name)
- if err != nil {
- // error handling
- }
+ // Get user from elasticsearch
return u, nil
}
Usecase層のGetUserByIdメソッドのdb引数の型(sql.DB)やメソッド内で呼んでいるGetメソッドのdb引数の型(sql.DB)を変更する必要が出てきます。
- func GetUserById(ctx context.Context, userId int, db *sql.DB) (UserDTO, error) {
+ func GetUserById(ctx context.Context, userId int, db *elasticsearch.Client) (UserDTO, error) {
user, err := infra.Get(ctx, userId, db)
if err != nil {
// error handling
}
return UserDTO{
Id: user.Id,
Name: user.Name,
}, nil
}
これらの状態からGetUserByIdメソッドとGetメソッドが密結合な状態になっていると言えます。
密結合だと何が問題なのか
GetUserByIdメソッドとGetメソッドが密結合による問題点を以下にまとめます。
1. 変更に弱いコードになってしまう
前章でも触れましたが、Infra層の変更(接続するデータベースが変わったこと)によってUsecase層も変更する必要が出てきてしまう。
2. テストがしにくいコードになってしまう
Getメソッドの具象に依存していることで、Usecase層の単体テスト時にGetメソッドをmockすることができず都度データベースに接続する必要があり、テストの実行が遅くなってしまうデメリットが発生します。
また、データベースに接続するということは、テストの度にデータベースの状態を初期化したり、必要なデータを用意する実装が求められます。
3. 実装の順序が決まってしまう
Getメソッドの具象に依存していることで、Getメソッドを実装するまでUsecase層のメソッドを実装することができなくなります。
疎結合なコードに変更する
疎結合とは、モジュール間の結合度が低いことを指します。今回の場合、Usecase層とInfra層の依存度合いを極力小さくし、各層が定義する関心事に集中できるようにします。
疎結合なコードに変更するために以下2点を実施します。
1. 抽象に依存する
Go言語における"抽象"はインターフェイスを指すため、今回はインターフェイスが持つメソッドのシグネチャに依存するようにします。
2. Dependency Injectionを行う
モジュール間の結合を低くするために、外部から依存するコンポーネント(今回はインターフェース)を注入します。
type UserDTO struct {
Id int
Name string
}
+ type UserRepositorier interface {
+ Get(ctx context.Context, userId int) (infra.User, error)
+ }
- func GetUserById(ctx context.Context, userId int, db *sql.DB) (UserDTO, error) {
+ func GetUserById(ctx context.Context, userId int, repo UserRepositorier) (UserDTO, error) {
- user, err := infra.Get(ctx, userId, db)
+ user, err := repo.Get(ctx, userId)
if err != nil {
// error handling
}
return UserDTO{
Id: user.Id,
Name: user.Name,
}, nil
}
また、Infra層はsqlHandler構造体がUserRepositorierインターフェイスの実装を満たすようにします。
こうすることでsqlHandler型はGetUserByIdメソッドのrepo引数(UserRepositorier型)に代入することができるようになります。
type User struct {
Id int
Name string
}
+ type sqlHandler struct {
+ db *sql.DB
+ }
+ func NewSqlHandler(db *sql.DB) *sqlHandler {
+ return &sqlHandler{db: db}
+ }
- func Get(ctx context.Context, userId int, db *sql.DB) (User, error) {
+ func (s *sqlHandler) Get(ctx context.Context, userId int) (User, error) {
u := User{}
err := s.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", userId).Scan(&u.Id, &u.Name)
if err != nil {
// error handling
}
return u, nil
}
疎結合だと何が良いのか
GetUserByIdメソッドがGetメソッドの抽象に依存することで、以下のようなメリットがあります。
1. 変更に強いコードになる
抽象に依存することで、Getメソッドの処理の詳細を知る必要がなくなり、Usecase層はユーザーIDを入力としてユーザー情報を返すことのみに関心が向けられます。
Getメソッドの具象を変更したとしても、Usecase層への影響はありません。 つまり、ビジネスロジックを定義している重要なUsecase層が安定度の高い層になり、より変更に強いアプリケーションになります。
Dependency Injectionを行うことで、UserRepositorierインターフェイスの実装を満たせば、どのような型でも受けつけられるようになります。
例えば、利用するデータベースをMySQLからElasticsearchに変更した場合でも、UserRepositorierインターフェイスが持つGetメソッドのシグネチャを満たすように実装すれば、Usecase層のGetUserByIdメソッドの変更は必要ありません。
2. テストがしやすいコードになる
GetUserByIdメソッドの単体テスト時にGetメソッドをmockすることができるようになり、Getメソッドの中でデータベースにアクセスする必要がなくなり、テストがしやすくなります。
おまけ
依存関係の向きを確認してみる
ここで、各層の依存の向きを確認してみましょう。 依存の向きがInfra層→Usecase層となっていることがわかります。
これは、以下の変更を加えたことによります。
-
Getメソッドのシグネチャを持つUserRepositorierインターフェイスをUsecase層に定義 -
UserRepositorierインターフェースを満たすようにGetメソッドをInfra層で実装 -
Usecase層のGetUserByIdメソッドをUserRepositorierインターフェースに依存するように修正
このように、抽象に依存させたことで依存関係が逆転しています。これを依存性逆転の原則(Dependency Inversion Principle)と呼びます。
Presentation層・Infra層からUsecase層へ依存の向きが向いている図に見覚えがある方もいるかもしれません。これはクリーンアーキテクチャの同心円の図に似ています。
つまり、クリーンアーキテクチャは関心の分離・疎結合なコードを実現し、アプリケーションの重要な部分であるビジネスロジックが変更に強いコードになるようにするためのアーキテクチャと言えます。
まとめ
今回は、レイヤードアーキテクチャを通して、密結合な状態がどういう状態であるか、またどのような問題があるかを確認し、疎結合なコードに変更することでどのようなメリットがあるかを確認しました。
また、最後におまけ程度ではありますが疎結合なコードに変更した結果、依存関係の向きが逆転したことやクリーンアーキテクチャとの関係についても触れました。
この記事が「密結合」「疎結合」について理解するきっかけになれば幸いです。
次のシリーズ9の記事は @mahiro72 さんです。
#ZOZO Advent Calendar 2023はシリーズ9までありますので、ぜひご覧ください!


