Goでバックエンド開発を始めると、「パッケージ構成をどう設計すべきか」という壁に必ずぶつかります。RailsやLaravelのようなフルスタックフレームワークにはデフォルトのディレクトリ規約がありますが、Goにはそれがありません。Go公式チームも「公式の標準プロジェクトレイアウトは存在しない」と明言しています。
そこで多くの開発者が採用を検討するのがレイヤードアーキテクチャです。責務を層ごとに分離するこのパターンは、Goの言語特性と組み合わせたときにどの程度うまく機能するのでしょうか。
レイヤードアーキテクチャの基本構造
レイヤードアーキテクチャは、アプリケーションを責務ごとの「層(レイヤー)」に分割する設計パターンです。一般的には以下の4層で構成されます。
| 層 | 責務 | Goでの典型的な実装 |
|---|---|---|
| プレゼンテーション層 | HTTPリクエスト/レスポンスの処理 | handler/パッケージ、HTTPハンドラ関数 |
| アプリケーション層 | ユースケースの調整、ビジネスフローの制御 | usecase/パッケージ、サービス構造体 |
| ドメイン層 | ビジネスルールとエンティティの定義 | domain/パッケージ、構造体・値オブジェクト |
| インフラストラクチャ層 | DB接続・外部APIなど技術的関心事 | infrastructure/パッケージ、リポジトリ実装 |
各層は上位から下位への一方向にのみ依存します。プレゼンテーション層はアプリケーション層を呼び出せますが、逆方向の依存は許可されません。この制約により、変更の影響範囲が局所化されます。
閉鎖レイヤーと解放レイヤー
層の依存ルールには2つのバリエーションがあります。
閉鎖レイヤーは、各層が直下の層のみを呼び出せるルールです。プレゼンテーション層からドメイン層を直接呼び出すことはできず、必ずアプリケーション層を経由します。変更の影響範囲が明確になる反面、単純な処理でも全層を通過する「シンクホールアンチパターン」が発生しやすくなります。
解放レイヤーは、層を飛ばした呼び出しを許可するルールです。柔軟性は高まりますが、依存関係が複雑化するリスクがあります。
Goのプロジェクトでは、閉鎖レイヤーを基本としつつ、Read系の単純なAPIではアプリケーション層を省略する実用的な折衷案がよく採用されます。
Goの言語特性がレイヤード設計に与える影響
Goにはレイヤードアーキテクチャとの親和性を高める言語特性がいくつかあります。一方で、注意が必要な点も存在します。
暗黙的interface実装と依存性逆転
Goのinterfaceは暗黙的に実装されます。JavaのimplementsキーワードやPHPのimplements宣言のような明示的な宣言が不要です。
// domain/repository.go — ドメイン層でinterfaceを定義
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
Save(ctx context.Context, user *User) error
}
// infrastructure/user_repository.go — インフラ層で実装
type userRepositoryImpl struct {
db *sql.DB
}
func (r *userRepositoryImpl) FindByID(ctx context.Context, id string) (*domain.User, error) {
// DBアクセスの実装
row := r.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id)
u := &domain.User{}
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, fmt.Errorf("find user by id: %w", err)
}
return u, nil
}
func (r *userRepositoryImpl) Save(ctx context.Context, user *domain.User) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO users (id, name, email) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET name = $2, email = $3",
user.ID, user.Name, user.Email,
)
if err != nil {
return fmt.Errorf("save user: %w", err)
}
return nil
}
userRepositoryImplはUserRepositoryを実装していますが、コード上に「このinterfaceを実装する」という宣言がありません。メソッドシグネチャが一致していれば自動的に満たされます。
この特性により、ドメイン層はインフラ層のパッケージを一切importせずにinterfaceを定義でき、依存性逆転の原則(DIP)がフレームワークなしで自然に実現されます。JavaやC#で必要になるDIコンテナが、Go標準の言語機能だけでカバーできるのは大きな利点です。
パッケージの循環参照禁止
Goコンパイラはパッケージ間の循環参照をコンパイルエラーとして検出します。handler → usecase → domainという一方向の依存は問題ありませんが、domain → handlerのような逆方向の参照があるとビルドが通りません。
この制約はレイヤードアーキテクチャの層間依存ルールをコンパイラレベルで強制する効果があります。他の言語ではリンターやアーキテクチャテストで検出する必要がある違反を、Goでは言語仕様として防止できます。
構造体のコンストラクタインジェクション
GoではNew関数パターンによるコンストラクタインジェクションが標準的です。
// usecase/user_usecase.go
type UserUsecase struct {
repo domain.UserRepository
mailer domain.Mailer
}
func NewUserUsecase(repo domain.UserRepository, mailer domain.Mailer) *UserUsecase {
return &UserUsecase{
repo: repo,
mailer: mailer,
}
}
func (u *UserUsecase) Register(ctx context.Context, name, email string) (*domain.User, error) {
user := domain.NewUser(name, email)
if err := u.repo.Save(ctx, user); err != nil {
return nil, fmt.Errorf("register user: %w", err)
}
if err := u.mailer.SendWelcome(ctx, user); err != nil {
// メール送信失敗はログに記録して処理を続行
slog.Error("failed to send welcome email", "userID", user.ID, "error", err)
}
return user, nil
}
依存するinterfaceをNew関数の引数として受け取るだけで、DIコンテナなしに依存性の注入が完了します。小〜中規模のプロジェクトではこれで十分です。依存グラフが複雑化する大規模プロジェクトでは、uber-go/fx(ライフサイクル管理付きのDIフレームワーク)やuber-go/dig(リフレクションベースのDIコンテナ)といったツールの導入も選択肢になります。なお、Google Wire(コード生成ベースのDIツール)は2025年8月にアーカイブされメンテナンスが終了しているため、新規プロジェクトでの採用は避けるのが無難です。
注意点: Goの型システムとドメインモデリング
Goにはクラス継承やジェネリクスの柔軟性(Go 1.18以降は基本的なジェネリクスをサポート)で制限があるため、ドメイン駆動設計(DDD)で頻出する複雑なドメインモデルの表現には工夫が必要です。
- 値オブジェクトの等価性比較にはメソッドを定義する必要がある
- エンティティの継承階層は埋め込み(embedding)で代替する
- 列挙型は
iotaで定義し、文字列変換にはStringerインターフェースを実装する
これらはGoの設計思想(シンプルさの重視)に起因するもので、レイヤードアーキテクチャの採用可否とは独立した問題です。
Goプロジェクトのディレクトリ構成例
レイヤードアーキテクチャを採用したGoプロジェクトのディレクトリ構成は、以下が実用的な基本形です。
myapp/
├── cmd/
│ └── api/
│ └── main.go # エントリーポイント、DI配線
├── handler/
│ ├── user_handler.go # HTTPハンドラ
│ └── middleware.go # ミドルウェア
├── usecase/
│ └── user_usecase.go # ユースケース
├── domain/
│ ├── user.go # エンティティ
│ ├── user_repository.go # リポジトリinterface
│ └── errors.go # ドメインエラー定義
├── infrastructure/
│ ├── postgres/
│ │ └── user_repository.go # PostgreSQL実装
│ └── smtp/
│ └── mailer.go # メール送信実装
├── go.mod
└── go.sum
エントリーポイントでのDI配線
main.goで全層のインスタンスを生成し、依存関係を組み立てます。
// cmd/api/main.go
func main() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
// インフラ層
userRepo := postgres.NewUserRepository(db)
mailer := smtp.NewMailer(os.Getenv("SMTP_HOST"))
// ユースケース層
userUsecase := usecase.NewUserUsecase(userRepo, mailer)
// ハンドラ層
userHandler := handler.NewUserHandler(userUsecase)
// ルーティング(Go 1.22+の標準ServeMux)
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", userHandler.GetByID)
mux.HandleFunc("POST /users", userHandler.Create)
log.Println("server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Go 1.22以降はnet/httpパッケージのServeMuxがHTTPメソッドやパスパラメータを直接サポートするため、ルーティングのためだけにサードパーティ製フレームワークを導入する必要性が下がっています。
レイヤードアーキテクチャと類似パターンの整理
「レイヤードアーキテクチャ」「クリーンアーキテクチャ」「オニオンアーキテクチャ」「ヘキサゴナルアーキテクチャ」は混同されやすいパターンです。それぞれの違いを整理します。
| 観点 | レイヤード | クリーン | オニオン | ヘキサゴナル |
|---|---|---|---|---|
| 依存の方向 | 上から下への一方向 | 外から内への一方向 | 外から内への一方向 | ポートを介した双方向抽象化 |
| 中心の関心事 | 技術的責務の分離 | ビジネスルールの独立性 | ドメインモデルの保護 | アプリケーションと外部の分離 |
| 層の名称例 | Presentation / Application / Domain / Infrastructure | Entities / Use Cases / Interface Adapters / Frameworks | Domain / Domain Services / Application Services / Infrastructure | Application / Ports / Adapters |
| 依存性逆転 | 必須ではない | 必須(内側に依存) | 必須(内側に依存) | 必須(ポート経由) |
| Goとの親和性 | 高い(パッケージの循環禁止が層分離を強制) | 高い(暗黙的interfaceがDIPを自然に実現) | 高い(同上) | 中程度(ポート定義がやや冗長になりがち) |
| 学習コスト | 低い | 中〜高 | 中〜高 | 中 |
| 推奨規模 | 小〜中規模 | 中〜大規模 | 中〜大規模 | 中〜大規模 |
実務的には、Goプロジェクトではレイヤードアーキテクチャを出発点として採用し、プロジェクトが成長するにつれてクリーンアーキテクチャやヘキサゴナルアーキテクチャへ段階的に移行するケースが多く見られます。レイヤードアーキテクチャに依存性逆転を組み合わせると、実質的にオニオンアーキテクチャに近い構成になります。
プロジェクト規模別の推奨アーキテクチャ
すべてのプロジェクトにレイヤードアーキテクチャが最適とは限りません。規模とチーム構成に応じた選定が重要です。
小規模(1〜2人、APIエンドポイント10個以下)
フラットパッケージ構成が適しています。main.goに加えて数個のファイルで完結する規模では、層分離のオーバーヘッドがメリットを上回ります。
myapp/
├── main.go
├── handler.go
├── store.go # DB操作
└── model.go # データ構造
Go公式ドキュメントでも「パッケージは提供する機能(what it provides)で整理すべき」と述べられており、過度な分割よりも実用性を重視する思想と一致します。
中規模(3〜8人、APIエンドポイント10〜50個)
レイヤードアーキテクチャが最も効果を発揮する規模です。責務の分離が明確になり、チームメンバーが担当する領域を分割しやすくなります。テストの書きやすさも向上します。
大規模(8人以上、マイクロサービス構成)
クリーンアーキテクチャやヘキサゴナルアーキテクチャの検討を推奨します。マイクロサービス間の境界が明確になり、サービスごとの独立したデプロイが容易になります。各サービス内部でレイヤードアーキテクチャを採用する構成も有効です。
Goレイヤードアーキテクチャでのテスト戦略
レイヤードアーキテクチャの最大のメリットのひとつは、テスタビリティの向上です。各層をinterfaceで分離することで、依存先をモックに差し替えたユニットテストが容易になります。
ユースケース層のテスト例
// usecase/user_usecase_test.go
type mockUserRepo struct {
users map[string]*domain.User
}
func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) {
user, ok := m.users[id]
if !ok {
return nil, domain.ErrUserNotFound
}
return user, nil
}
func (m *mockUserRepo) Save(ctx context.Context, user *domain.User) error {
m.users[user.ID] = user
return nil
}
type mockMailer struct {
sent []string
}
func (m *mockMailer) SendWelcome(ctx context.Context, user *domain.User) error {
m.sent = append(m.sent, user.Email)
return nil
}
func TestUserUsecase_Register(t *testing.T) {
repo := &mockUserRepo{users: make(map[string]*domain.User)}
mailer := &mockMailer{}
uc := NewUserUsecase(repo, mailer)
user, err := uc.Register(context.Background(), "Alice", "alice@example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "Alice" {
t.Errorf("got name %q, want %q", user.Name, "Alice")
}
// リポジトリに保存されたことを確認
saved, err := repo.FindByID(context.Background(), user.ID)
if err != nil {
t.Fatalf("user not saved: %v", err)
}
if saved.Email != "alice@example.com" {
t.Errorf("got email %q, want %q", saved.Email, "alice@example.com")
}
// ウェルカムメールが送信されたことを確認
if len(mailer.sent) != 1 || mailer.sent[0] != "alice@example.com" {
t.Errorf("welcome email not sent correctly: %v", mailer.sent)
}
}
Goの暗黙的interface実装により、テスト用のモック構造体は対象のinterfaceのメソッドを実装するだけで済みます。mockgenやtestifyなどのモックライブラリを使わなくても基本的なテストは書けます。
各層のテスト方針
| 層 | テスト手法 | ポイント |
|---|---|---|
| ハンドラ層 | httptestパッケージによるHTTPテスト | リクエストのバリデーション、レスポンスフォーマットの検証 |
| ユースケース層 | モックを注入したユニットテスト | ビジネスロジックの正確性、エラーハンドリング |
| ドメイン層 | 純粋なユニットテスト | エンティティのバリデーション、値オブジェクトの等価性 |
| インフラ層 | テストDB(testcontainers-go等)を使った結合テスト | SQLの正確性、トランザクション処理 |
レイヤードアーキテクチャ導入時のアンチパターン
Goでレイヤードアーキテクチャを採用する際に陥りやすい失敗パターンとその対策をまとめます。
1. シンクホールアンチパターン
問題: すべてのリクエストがhandler → usecase → domain → infrastructureの全層を通過するが、usecaseやdomainではデータをそのまま通すだけで何もしていない。
対策: Read系のシンプルなAPIでは、handlerから直接リポジトリを呼び出す「Query Service」パターンを検討します。CQRS(コマンドクエリ責務分離)の考え方を部分的に取り入れることで、不要な中間層を省略できます。
2. ドメイン貧血症
問題: ドメイン層のエンティティがフィールドだけの構造体(いわゆる「貧血ドメインモデル」)になり、ビジネスロジックがすべてユースケース層に集中する。
対策: バリデーションや状態遷移のルールはエンティティのメソッドとして実装します。
// domain/user.go
type User struct {
ID string
Name string
Email string
Status UserStatus
CreatedAt time.Time
}
func NewUser(name, email string) (*User, error) {
if name == "" {
return nil, errors.New("name is required")
}
if !isValidEmail(email) {
return nil, errors.New("invalid email format")
}
return &User{
ID: generateID(),
Name: name,
Email: email,
Status: UserStatusActive,
CreatedAt: time.Now(),
}, nil
}
func (u *User) Deactivate() error {
if u.Status != UserStatusActive {
return fmt.Errorf("cannot deactivate user with status %s", u.Status)
}
u.Status = UserStatusInactive
return nil
}
3. パッケージの過度な細分化
問題: domain/user/entity/, domain/user/value_object/, domain/user/repository/のように深くネストしたパッケージ構成を作り、Javaのようなディレクトリ構造になる。
対策: Goのパッケージは浅いネストが推奨されます。domain/直下にファイルを配置し、ファイル名で区別するのがGo流です。パッケージ名はインポートパスの一部として使われるため、user.NewUser()のように「パッケージ名.関数名」で意味が通る命名を意識します。
4. トランザクションの層跨ぎ
問題: トランザクション管理がインフラ層に閉じず、ユースケース層に*sql.Txが漏洩する。
対策: トランザクションの抽象化には主に2つの方法があります。
方法A: Contextに格納する
// infrastructure/tx.go
type txKey struct{}
func RunInTx(ctx context.Context, db *sql.DB, fn func(ctx context.Context) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
ctx = context.WithValue(ctx, txKey{}, tx)
if err := fn(ctx); err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
方法B: トランザクションinterfaceをDIする
// domain/transaction.go
type Transaction interface {
RunInTx(ctx context.Context, fn func(ctx context.Context) error) error
}
方法Aはシンプルですが、Contextの暗黙的な値に依存するため明示性が低下します。方法BはDIPに準拠しますが、コード量が増えます。プロジェクトの複雑さに応じて選択してください。
Goのレイヤードアーキテクチャは「相性が良い」のか
結論として、Goとレイヤードアーキテクチャの相性は高いと評価できます。その根拠をまとめます。
相性が良い理由:
- 暗黙的interfaceにより、依存性逆転がフレームワークなしで実現できる
- パッケージの循環参照禁止が、層間の依存ルールをコンパイル時に強制する
- コンストラクタインジェクションが言語慣習として定着しており、DIが自然に書ける
- Go 1.22以降の標準
ServeMux強化により、プレゼンテーション層のフレームワーク依存が軽減された
注意すべき点:
- ジェネリクスやクラス継承がない分、ドメインモデルの表現力はJavaやC#と比べると制限がある
- 小規模プロジェクトではフラットパッケージの方がGoの思想に合致する
- 過度な層分離はシンクホールアンチパターンを招くため、CQRS的アプローチとの併用を検討する
Goでレイヤードアーキテクチャを導入する際のポイントは、「Goらしさ」を損なわない範囲で責務を分離することです。Javaのディレクトリ構成をそのまま持ち込むのではなく、パッケージをフラットに保ちながら層の論理的な分離を維持するバランスが重要になります。
中規模以上のGoプロジェクトで設計に迷ったら、まずレイヤードアーキテクチャから始めて、必要に応じてクリーンアーキテクチャやヘキサゴナルアーキテクチャへ進化させるアプローチを推奨します。