Goのレイヤー構成
Goにおけるレイヤードアーキテクチャ 🧱
Section titled “Goにおけるレイヤードアーキテクチャ 🧱”Goでは、特定のアーキテクチャパターンは強制されませんが、役割ごとにコードを分けるレイヤードアーキテクチャが広く採用されています。これはMVCに似た考え方ですが、Goの設計哲学に沿ったよりシンプルで疎結合な構成です。
なぜレイヤードアーキテクチャが必要なのか
Section titled “なぜレイヤードアーキテクチャが必要なのか”問題のあるコード(レイヤーがない場合)
Section titled “問題のあるコード(レイヤーがない場合)”問題のあるコード:
// 問題: すべてのロジックが1つのハンドラに混在func handleUserRequest(w http.ResponseWriter, r *http.Request) { // HTTP処理 userID := r.URL.Query().Get("id")
// データベース接続 db, _ := sql.Open("postgres", "connection string") defer db.Close()
// ビジネスロジック var user User err := db.QueryRow("SELECT * FROM users WHERE id = $1", userID).Scan(&user.ID, &user.Name) if err != nil { // エラーハンドリング w.WriteHeader(http.StatusNotFound) return }
// レスポンス生成 json.NewEncoder(w).Encode(user)}
// 問題点:// 1. テストが困難(HTTP、DB、ビジネスロジックが混在)// 2. 再利用性が低い(他のハンドラで同じロジックを使えない)// 3. 責務が不明確(どこを変更すべきか分からない)// 4. スケーラビリティが低い(チーム開発が困難)解決: レイヤードアーキテクチャ
// 解決: レイヤーごとに責務を分離// Handler層: HTTP処理のみfunc (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) { userID := getUserIDFromRequest(r) user, err := h.userService.GetUser(userID) if err != nil { h.respondError(w, err) return } h.respondJSON(w, user)}
// Service層: ビジネスロジックfunc (s *UserService) GetUser(id int) (*User, error) { return s.repo.FindByID(id)}
// Repository層: データアクセスfunc (r *UserRepository) FindByID(id int) (*User, error) { // データベース操作}
// メリット:// 1. 各レイヤーを独立してテスト可能// 2. ビジネスロジックの再利用が容易// 3. 責務が明確(変更箇所が明確)// 4. チーム開発が容易(レイヤーごとに担当を分けられる)レイヤードアーキテクチャの本質的な価値
Section titled “レイヤードアーキテクチャの本質的な価値”1. テスト容易性の向上
// レイヤー分離により、各レイヤーを独立してテスト可能func TestUserService_GetUser(t *testing.T) { // モックリポジトリを使用 mockRepo := &MockUserRepository{} service := NewUserService(mockRepo)
// データベースなしでテスト可能 user, err := service.GetUser(1) assert.NoError(t, err) assert.Equal(t, 1, user.ID)}2. 変更への柔軟性
// データベースを変更しても、Service層は変更不要// PostgreSQL → MongoDB に変更する場合// Repository層の実装だけを変更すれば良い
// PostgreSQL実装type PostgresUserRepository struct { db *sql.DB}
// MongoDB実装type MongoUserRepository struct { collection *mongo.Collection}
// Service層は変更不要(インターフェースに依存)type UserService struct { repo UserRepository // インターフェースに依存}3. チーム開発の効率化
// レイヤーごとに担当を分けられる// フロントエンドエンジニア: Handler層// バックエンドエンジニア: Service層、Repository層// インフラエンジニア: Infrastructure層
// 並行開発が可能// Handler層とService層を同時に開発できる図解:Goのレイヤードアーキテクチャの概要
Section titled “図解:Goのレイヤードアーキテクチャの概要”graph TD subgraph "プレゼンテーション層" A[リクエスト] --> B(ハンドラ/コントローラ) end
subgraph "ビジネスロジック層" B --> C{サービス/ユースケース} end
subgraph "データ永続化層" C --> D[リポジトリ/データストア] end
D --> E[(データベース/外部API)]各レイヤーの役割
Section titled “各レイヤーの役割”ハンドラ/コントローラ (プレゼンテーション層)
Section titled “ハンドラ/コントローラ (プレゼンテーション層)”- 役割:
HTTPリクエストを受け付け、応答を返すインターフェースです。 - 機能: リクエストの検証、データのパース(
JSONなど)、サービスへの処理依頼、レスポンスの生成を行います。 - Goでの実装:
net/httpパッケージやWebフレームワーク(Echo,Ginなど)のHandler関数やメソッドとして実装されます。 MVCとの比較:MVCの「コントローラ」に相当します。
サービス/ユースケース (ビジネスロジック層)
Section titled “サービス/ユースケース (ビジネスロジック層)”- 役割: アプリケーションのコアとなるビジネスロジックを実行します。
- 機能: データの計算、複数のリポジトリの呼び出し、複雑なビジネスルールの適用など、アプリケーションの「中核」となる処理を担います。
- Goでの実装: サービスの構造体とそのメソッドとして実装されます。インターフェースを使って、依存するリポジトリなどを抽象化するのが一般的です。
MVCとの比較:MVCの「モデル」の一部(ビジネスロジック)に相当します。
リポジトリ/データストア (データ永続化層)
Section titled “リポジトリ/データストア (データ永続化層)”- 役割: データの永続化(データベースへの保存)を抽象化します。
- 機能: データベースへの接続、
SQLクエリの実行、データの読み書きなど、具体的なデータ操作をカプセル化します。 - Goでの実装: データベース操作を定義するインターフェースと、それを実装する構造体として実装されます。
MVCとの比較:MVCの「モデル」の別の部分(データアクセス)に相当します。
この構成では、各レイヤーが独立しているため、例えばデータベースを変更する際も、リポジトリレイヤーの実装を差し替えるだけで、サービスやハンドラレイヤーに影響を与えないように設計できます。これにより、保守性とテスト容易性が大幅に向上します。
ドメインモデル (Domain Model) の追加
Section titled “ドメインモデル (Domain Model) の追加”提示された構成では、サービス層とリポジトリ層が直接データをやり取りしているように見えますが、その間にドメインモデルというレイヤーを追加することで、アプリケーションのコアな概念をより明確にすることができます。
- 役割: アプリケーションの中心となるビジネスオブジェクトを定義します。これは、データベースのテーブル構造に直接縛られず、ビジネスルールを内包する純粋な
Goの構造体です。 - 機能: 例えば、ユーザーのパスワードハッシュ化や注文の合計金額計算など、そのオブジェクト自身が持つべきロジックをメソッドとして定義します。これにより、ビジネスロジックがサービス層だけでなく、より適切な場所に配置されるようになります。
- 実装:
internal/domain/ディレクトリに、各ドメイン(例:user.go,order.go)の構造体とメソッドを定義します。
例:Userドメインモデル
Section titled “例:Userドメインモデル”package domain
import ( "golang.org/x/crypto/bcrypt")
type User struct { ID int Name string Email string Password string // Hashed password}
// NewUser は新しいユーザーを生成し、パスワードをハッシュ化するfunc NewUser(name, email, password string) (*User, error) { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, err } return &User{ Name: name, Email: email, Password: string(hashedPassword), }, nil}この例では、パスワードのハッシュ化というビジネスルールが、サービス層ではなく、ユーザーオブジェクト自身にカプセル化されています。これにより、どのレイヤーからNewUserを呼び出しても、一貫したルールが適用されます。
設定・インフラストラクチャ層 (Configuration/Infrastructure Layer) の役割明確化
Section titled “設定・インフラストラクチャ層 (Configuration/Infrastructure Layer) の役割明確化”提示された構成ではconfigs/として言及されていますが、これは単なる設定ファイルの置き場以上の役割を持ちます。
- 役割: アプリケーションの外部依存関係(データベース接続、ロギング、キャッシュなど)を初期化・管理します。
- 機能:
main.goの近くにこのロジックを集約することで、アプリケーションの起動時に必要なすべての外部サービスをセットアップする責任を明確にします。 - 実装:
configs/ディレクトリ:config.goのようなファイルに、アプリケーション全体の共通設定を読み込むロジックを実装します。internal/infra/: データベースクライアントや外部APIクライアントなど、外部サービスへの接続クライアントを定義します。
例:データベース接続の初期化
Section titled “例:データベース接続の初期化”package infra
import ( "database/sql" _ "github.com/lib/pq" // PostgreSQLドライバ)
// NewPostgresDB はデータベース接続を確立するfunc NewPostgresDB(connStr string) (*sql.DB, error) { db, err := sql.Open("postgres", connStr) if err != nil { return nil, err } if err := db.Ping(); err != nil { return nil, err } return db, nil}このNewPostgresDB関数は、main.goから呼び出され、確立された接続がリポジトリ層に注入されます。これにより、リポジトリ層は具体的なデータベース実装に依存せず、インターフェースを介して抽象的に操作できます。
テスト戦略の明確化
Section titled “テスト戦略の明確化”レイヤードアーキテクチャの最大の利点の一つは、テスト容易性です。各レイヤーを独立してテストする戦略を明確にしておくと、開発効率が大幅に向上します。
-
プレゼンテーション層 (ハンドラ):
- テストタイプ: ユニットテストとインテグレーションテスト。
- テスト方法: モックのサービスを使用して、リクエストとレスポンスの処理が正しく行われているかを確認します。実際のビジネスロジックはテスト対象外です。
-
ビジネスロジック層 (サービス):
- テストタイプ: ユニットテスト。
- テスト方法: モックのリポジトリを使用して、複雑なビジネスロジックが期待通りに動作するかを検証します。データベースへのアクセスは行いません。
-
データ永続化層 (リポジトリ):
- テストタイプ: インテグレーションテスト。
- テスト方法: 実際のデータベースインスタンス(
Dockerコンテナなど)を起動して、SQLクエリやデータ操作が正しく行われるかを確認します。
これらの追加的な考慮事項は、Goでスケーラブルでメンテナンス性の高いアプリケーションを構築する上で非常に重要です。
依存性注入 (DI)
Section titled “依存性注入 (DI)”レイヤードアーキテクチャでは、各層が疎結合であるべきです。これを実現する最も効果的な手法がDIです。DIは、あるコンポーネントが依存するオブジェクトを、そのコンポーネント自身が生成するのではなく、外部から渡す(注入する)という設計パターンです。
- なぜ
DIが必要か- テスト容易性: 依存性を外部から注入することで、テスト時にモックやスタブに簡単に差し替えることができます。例えば、サービス層のテストでは、本物のデータベース接続ではなく、モックのリポジトリを注入することで、データベースアクセスを伴わない高速なユニットテストが可能になります。
- 柔軟性と再利用性: 依存関係が明確になり、コンポーネントが特定の具象実装に縛られなくなります。これにより、リポジトリ層の実装を
PostgreSQLからMongoDBに変更する場合でも、サービス層のコードを修正する必要がなくなります。
サービス層にリポジトリを注入する例です。
package service
import ( "my-api-project/internal/domain")
// UserRepository はリポジトリ層の抽象化type UserRepository interface { GetUserByID(id int) (*domain.User, error) // その他のデータベース操作...}
// UserService はビジネスロジックを担うtype UserService struct { repo UserRepository // インターフェースを介して依存性を定義}
// NewUserService は新しいUserServiceを初期化func NewUserService(repo UserRepository) *UserService { return &UserService{repo: repo}}
// GetUser はユーザーを取得するビジネスロジックfunc (s *UserService) GetUser(id int) (*domain.User, error) { // リポジトリの抽象メソッドを呼び出す return s.repo.GetUserByID(id)}この例では、UserServiceは具体的なUserRepositoryの実装を知りません。NewUserService関数で注入されたUserRepositoryインターフェースを介してのみ操作します。これにより、テスト時にはこのインターフェースのモック実装を渡すことができます。
エラーの抽象化 (Error Abstraction)
Section titled “エラーの抽象化 (Error Abstraction)”Goのエラーハンドリングは強力ですが、各レイヤーで発生するエラーをそのまま上位層に伝播させると、レイヤー間の依存性が高まります。エラーを抽象化することで、この問題を解決し、堅牢なアーキテクチャを維持できます。
- なぜエラーの抽象化が必要か
- レイヤー間の分離: リポジトリ層で発生したデータベース特有のエラー(例:
pq: no rows in result set)をサービス層にそのまま渡してしまうと、サービス層がデータベースの実装に依存してしまいます。これをdomain.UserNotFoundのような汎用的なエラーに変換することで、依存関係を断ち切ります。 - 一貫したエラー処理: プレゼンテーション層(ハンドラ)は、抽象化されたエラー(例:
domain.InvalidInput)を基に、適切なHTTPステータスコード(例:400 Bad Request)を返すことができます。これにより、エラーの種別ごとに一貫したレスポンスを生成しやすくなります。
- レイヤー間の分離: リポジトリ層で発生したデータベース特有のエラー(例:
リポジトリ層で発生したエラーを抽象化してサービス層に返す例です。
// internal/app/service/user_service.go (修正版)package service
import ( "my-api-project/internal/domain" "errors")
func (s *UserService) GetUser(id int) (*domain.User, error) { user, err := s.repo.GetUserByID(id) if err != nil { if errors.Is(err, domain.ErrUserNotFound) { // リポジトリからの特定のエラーをサービス層のエラーに変換 return nil, domain.ErrUserNotFound } // その他の予期せぬエラーはそのまま返す return nil, fmt.Errorf("failed to get user: %w", err) } return user, nil}package domain
import "errors"
var ( ErrUserNotFound = errors.New("user not found") // その他のドメインエラー...)この構成では、service層はrepository層の具体的なエラーを知る必要がなくなり、domainで定義されたエラーのみを扱います。これにより、各レイヤーが独立した責任を持つという原則が保たれます。
アーキテクチャパターンの選択判断
Section titled “アーキテクチャパターンの選択判断”レイヤードアーキテクチャ vs クリーンアーキテクチャ vs ヘキサゴナルアーキテクチャ
Section titled “レイヤードアーキテクチャ vs クリーンアーキテクチャ vs ヘキサゴナルアーキテクチャ”レイヤードアーキテクチャが適している場合:
// 適用範囲:// - 中規模から大規模のWebアプリケーション// - CRUD操作が中心のアプリケーション// - チームが明確に分離されている場合
// 構造Handler → Service → Repository → Databaseメリット:
- 理解しやすい構造
- チーム間の責務が明確
- Goの標準的なパターンと相性が良い
デメリット:
- ビジネスロジックがService層に集中しがち
- ドメインモデルが貧血症になりやすい
クリーンアーキテクチャが適している場合:
// 適用範囲:// - 複雑なビジネスロジックを持つアプリケーション// - ドメイン駆動設計(DDD)を採用する場合// - 長期的な保守性を最優先する場合
// 構造┌─────────────────────────────────┐│ Handler (Interface) │├─────────────────────────────────┤│ Use Case (Application) │├─────────────────────────────────┤│ Domain (Business Logic) │├─────────────────────────────────┤│ Repository (Interface) │├─────────────────────────────────┤│ Infrastructure (Implementation) │└─────────────────────────────────┘実装例:
// Domain層: ビジネスロジックの核心package domain
type User struct { ID int Name string Email string}
func (u *User) Validate() error { if u.Email == "" { return errors.New("email is required") } return nil}
// Use Case層: アプリケーションロジックpackage usecase
type UserRepository interface { Save(user *domain.User) error FindByID(id int) (*domain.User, error)}
type CreateUserUseCase struct { repo UserRepository}
func (uc *CreateUserUseCase) Execute(name, email string) error { user := &domain.User{Name: name, Email: email} if err := user.Validate(); err != nil { return err } return uc.repo.Save(user)}判断基準:
| 観点 | レイヤード | クリーンアーキテクチャ |
|---|---|---|
| 学習コスト | 低い | 高い |
| ビジネスロジックの表現力 | 中程度 | 高い |
| テスト容易性 | 高い | 非常に高い |
| 適用範囲 | 中規模 | 大規模 |
| Goとの親和性 | 高い | 中程度 |
実践的な選択指針:
// レイヤードアーキテクチャを選ぶべき場合:// 1. チームがGoに慣れ親しんでいる// 2. ビジネスロジックが比較的シンプル// 3. 開発速度を優先する// 4. 中規模のCRUDアプリケーション
// クリーンアーキテクチャを選ぶべき場合:// 1. 複雑なビジネスルールがある// 2. ドメインエキスパートと密接に協業する// 3. 長期的な保守性を最優先する// 4. 複数の外部システムと統合する必要がある実践的な設計判断
Section titled “実践的な設計判断”レイヤー間の責務境界の明確化
Section titled “レイヤー間の責務境界の明確化”よくある問題: 責務の漏れ
// 問題のあるコード: Handler層にビジネスロジックが漏れているfunc (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest json.NewDecoder(r.Body).Decode(&req)
// 問題: ビジネスロジックがHandler層にある if req.Email == "" { http.Error(w, "Email is required", http.StatusBadRequest) return }
// 問題: データ変換ロジックがHandler層にある user := &User{ Name: req.Name, Email: req.Email, }
h.repo.Save(user) json.NewEncoder(w).Encode(user)}
// 解決: 責務を適切な層に配置func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest json.NewDecoder(r.Body).Decode(&req)
// Handler層: HTTPリクエストの受け取りとレスポンスの生成のみ user, err := h.userService.CreateUser(req.Name, req.Email) if err != nil { h.respondError(w, err) return }
h.respondJSON(w, user)}
// Service層: ビジネスロジックとバリデーションfunc (s *UserService) CreateUser(name, email string) (*User, error) { // Service層: ビジネスロジックとバリデーション if email == "" { return nil, ErrEmailRequired }
user := &User{Name: name, Email: email} return s.repo.Save(user)}これらの概念を導入することで、Goのレイヤードアーキテクチャはさらに洗練され、長期的なプロジェクトのメンテナンス性が飛躍的に向上します。
シニアエンジニアとして考慮すべき点:
- プロジェクトの規模: 小規模ならシンプルに、大規模なら構造化
- チームの構成: 機能チームなら機能ベース、専門チームならレイヤーベース
- 将来の拡張性: プロジェクトの成長を見据えた設計
- 一貫性: チーム全体で統一された構成を維持