Go のアーキテクチャどうしてる? ギフティで実際に試した構成パターンを紹介します – giftee Tech Blog

eyecatch

こんにちは、ギフティでエンジニアをやっている中屋(@nakaryo79)です!

これは「ギフティ Advent Calendar 2025」と「Go Advent Calendar 2025」2日目の記事になります。

はじめに

ギフティではここ数年、Go を採用したプロダクトが徐々に増えてきました。
採用の背景については、こちらの記事で詳しく紹介しています。

tech.giftee.co.jp

さて、Go には Rails や Laravel のような「フルスタック Web フレームワーク」の決定版が存在せず、パッケージ設計についても明確なデファクトスタンダードがありません。
公式が提示している パッケージレイアウトのガイドラインも基本的な構成の話に留まっており、サーバーアプリケーションの内部構造をどう設計すべきかまでは踏み込んでいません。

そのため、ギフティでも新しい Go プロジェクトを立ち上げるたびに、どのようなパッケージ構成が最適かを毎回試行錯誤しつつ、いくつかの構成パターンを実際に運用してきました。

本記事では、これまでのプロジェクトで採用してきたパッケージ構成を紹介し、それぞれの特徴や工夫したポイントを紹介します。

※ 記事内のファイル名やコードは説明のため一般化しており、実際のプロジェクトの命名とは異なります。また、説明に関係のないコードは適宜省略しています。

DDD × レイヤードアーキテクチャ

まずは、DDD(ドメイン駆動設計)の「集約」の概念を取り入れつつ、レイヤードアーキテクチャで実装した例を紹介します。

以下がパッケージ構成の外観です。

internal/
├── domain/           
├── handler/          
├── infrastructure/   
└── usecase/          

handler が HTTP リクエストを受け、usecase を呼び出します。
usecase はドメインモデルを組み立て、リポジトリを使って永続化処理をします。

依存方向を整理すると、次のような形になります。

handler/ (REST, GraphQL, Middleware)
↓
usecase/ (Application Services)
↓
domain/model/ (Domain Models)
↑
infrastructure/ (Repository Implementations, External APIs)

依存性逆転の原則を適用し、domainusecaseinfrastructure に直接依存しないようにしています。
具体的には、domain レイヤーにリポジトリのインターフェースを定義し、infrastructure 側でその実装を持ちます。データベースへの読み書きは集約ルート単位で行うため、リポジトリのインターフェースは集約ごとに用意します。

各レイヤーの詳細を見ていきましょう。

domain

domain はドメインモデルやビジネスロジックを管理するレイヤーで、以下のような構成になっています。

domain/
  ├── model/          
  │   ├── task/       
  │   │   ├── task.go
  │   │   ├── task_test.go
  │   │   ...
  │   │   └── repository.go
  │   ├── user/       
  │   │   ├── user.go
  │   │   ├── user_test.go
  │   │   ...
  │   │   └── repository.go
  │   ...
  ├── factory/        
  ├── service/        
  └── specification/  

model/ 配下に各集約ごとにパッケージを切り、集約と同名のファイルに集約ルートとコンストラクタを定義します。
あわせて、その集約の永続化処理を行うための Repository インターフェースも集約ごとに定義します。

package user

type User struct {
    id   uuid.UUID
    name string
}

func New(name string) (User, error) {
    
    return User{id: uuid.New(), name: name}, nil
}

domain が依存できるのは domain 内のコードだけで、usecaseinfrastructure など他レイヤーへの依存は禁止しています。また、集約間の依存も基本的には持たない方針です。

集約インスタンスの生成は集約ごとに定義されたコンストラクタで行います。ただし、生成ロジックが複雑であったり、他の集約のデータが必要だったりする場合は factory/ 配下にファクトリ関数を定義してそれを使います。とはいえ、大体の集約は集約ごとのコンストラクタで事足りるように設計しているので、ファクトリが登場するケースはあまり多くありません。
余談ですが、集約生成時の不変条件を考えると集約が大きくなりすぎることがあるので、それを避けるためにファクトリを使いたくなりますが、ファクトリの乱用は集約間の依存を強めてしまう側面もあるので、集約境界を適切に設計する必要があります。

service/ にはいわゆるドメインサービスを置きます。複数集約をまたいだ処理など、エンティティや値オブジェクトだけでは表現しづらいロジックをここに置きます。

specification/ には、DDD の仕様パターンに基づくコード(例:権限チェックの条件など)を配置します。

handler

handler はこのアプリケーションを叩く外界とのやりとり(主に HTTP リクエスト)を管理するレイヤーです。
GraphQL API や REST API に関するコードをまとめています。

handler/
├── graphql/                    
│   ├── loader/                 
│   ├── gqlmiddleware/          
│   └── resolver/               
│       ├── task.resolvers.go
│       └── user.resolvers.go
├── rest/                       
│   ├── health_check.go
│   ├── task.go
│   ├── user.go
│   └── restmiddleware/         
└── middleware/                 
    ├── auth.go                 
    ├── recover.go              
    └── ...

handler/ 直下で、GraphQL / REST / gRPC といったプロトコルごとにディレクトリを分け、その中にエンドポイント単位のファイルを配置します。
GraphQL の場合でも HTTP サーバー自体は REST と共通で使えるため、GraphQL と REST の両方で利用したいミドルウェア(認証や panic リカバリなど)は、handler/ 直下の middleware/ にまとめておく構成にしています。

usecase

usecase はアプリケーションサービスに関するコードを置きます。

usecase/
├── command/        
│   ├── task.go
│   ├── user.go
│   └── ...
└── query/          
    ├── task.go
    ├── user.go
    └── ...

CQRS(Command Query Responsibility Segregation)パターンを採用しており、書き込み(command)と読み取り(query)を明確に分離しています。

コマンド側では、入力を元にリポジトリ経由で必要なデータを取得したり、それを元にドメインモデルを構築し、リポジトリ経由で永続化を行います。

package command

type UserService struct {
    UserRepo user.UserRepostory
}

func (s *UserService) CreateUser(ctx context.Context, name string) (*user.User, error) {
  
  u, err := user.New(name)
  

  
  err := UserRepo.Create(ctx, u)
  
}

クエリ側で行うのはデータの取得のみで、usecase 層にやらせるロジックがないため、クエリ用の interface とレスポンス用の構造体定義のみがされています。そのため、handler 側からはこの interface 経由で永続化層を呼び出し、データを取得します。
handlerinfrastructure はこのレスポンス用の構造体に依存することで、データの詰め替えを最小限にしています。

package query

type UserService interface {
    QueryByID(ctx context.Context, id uuid.UUID) (User, error)
}


type User struct {
  ID uuid.UUID
  Name string
}

infrastructure

infrastructure はデータベースアクセスや外部 API コールなど、外部システムとのやりとりを管理します。
Repository などの何らかの interface を実装したもので、usecase から使う場合は interface 越しに呼び出すことで、usecase 層が infrastructure の実装に直接依存しないようにしています。

infrastructure/
├── database/                   
│   ├── task_repository.go      
│   ├── task_query_service.go   
│   ├── user_repository.go      
│   ├── user_query_service.go   
│   ├── database.go             
│   ├── mysql.go                
│   └── transaction.go          
└── hoge_api/                   
    └── client.go

書き込み側の処理では生クエリを書いて sqlx を使ってクエリを実行していますが、読み込み側ではクライアントからの入力値を元に複雑な参照クエリを動的に組み立てることがあるので、goqu などのクエリビルダーを使用することでプリペアドステートメントを作りやすくすることで SQL インジェクションのリスクを下げています。

DB 周りはリポジトリの実装とクエリサービスの実装の両方を同じ database/ に置いていますが、別々のパッケージに分けてしまっても良いかなと思っています。

ちなみに、データベーストランザクションの管理はこのレイヤーで行います。集約が保存時の一貫性の単位になるため、基本的にはトランザクション境界はリポジトリの実装側だけで扱えるはずです。とはいえ、今後 usecase 側でトランザクションをコントロールしたい場面が出てきた際にどうするか、というのは悩みどころだったりします。

DDD × レイヤードアーキテクチャ × イベント

上述の DDD × レイヤードアーキテクチャにさらにイベントの概念を取り入れた構成です。

おおまかなパッケージ構成は変わらないのですが、コマンド処理の中でドメインモデルではなくイベントを取り回す点が異なります。
具体的なコードを見てみましょう。

まず、domain のコードです。ユーザー集約に、ユーザーを作成するための Created イベントが定義されています。CreateUser 関数を呼ぶと Created イベントが返ってきます。

package user

type User struct {
  id   uuid.UUID
  name string
}

type Event interface {
    IsUserEvent()
}

type Created struct {
  AggregateID uuid.UUID
  Name        string
}

func (Created) IsUserEvent() {}

func CreateUser(name string) (Created, error) {
  
  return Created{AggregateID: uuid.New(), Name: name}, nil
}

usecase のコードを見てましょう。

domain のイベント作成の関数を呼び出してイベントを手に入れ、それをリポジトリ経由で永続化層に渡して永続化します。

package command

type CreateUserService struct {
    UserRepo user.Repository
}

func (s *CreateUserService) Do(ctx context.Context, name string) (user.Created, error) {
  event, err := user.CreateUser(name)
  

  err = s.UserRepo.PersistEvent(ctx, event)
  

  return event, nil
}

先ほどの構成ではリポジトリの永続化メソッドに集約全体を渡していましたが、こちらの実装ではイベントオブジェクトを渡すようになっています。
永続化層では引数のシグネチャが user.Event という interface になっているので、渡されたイベントに対してタイプアサーションしてイベントの具体的な型を特定し、そのイベントに必要な永続化処理を行います。interface を使わずに永続化用のメソッドをイベントごとに分けて定義してもいいのですが、イベントが増えてくると interface として定義するメソッドの数がどんどん増えてテスト用のモックを作るのが大変になりそうです。

package database

type UserRepository struct{}

func (r *UserRepository) PersistEvent(ctx context.Context, event user.Event) error {
    switch e := event.(type) {
    case user.Created:
        return r.persistUserCreated(ctx, e)
    default:
        return errors.New("invalid event")
    }
}

集約には「どのような変更がされたか」という情報が含まれていないため、集約全体を渡してしまうと永続化層としては全体を保存し直すしかありません。
しかし、イベントを渡すことで「何が起こったのか」が永続化層側で明確になり、永続化の実装を最適化することができます。ここら辺は DDD の実装パターンの話になり長くなるので、これ以上の細かい詳細は割愛します。

MVC × ORM

そこまで細かくレイヤーを切らない、MVC っぽいパターンもやってみました。Rails っぽい形で、よく見るパターンかなと思います。

internal/
├── handler/                      
├── model/                        
└── service/                      

データベースアクセスは GORM を使って、データベース保存用の構造体定義とドメインモデル定義を密結合させています。

handler の責務は先ほどと変わらず、外からのリクエストを受け付けて、ユースケース層である service にデータを渡します。
service ではドメインモデルを作って、GORM 経由でデータベースへの入出力を行います。

model

model/ にはドメインモデルを置きます。パッケージは切らずに全てのモデルをフラットに置いています。

model/
├── task.go
├── user.go
└── ...

各モデルファイルにはコンストラクタやドメインロジックの他に、GORM 用のスコープ定義なども書いています。
また、ドメインモデルのフィールド定義には GORM 用のタグを付与しています。

package model

type User struct {
    ID   uuid.UUID `gorm:"<-:create"`
    Name string
}


func (u User) IsAdmin() func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        
    }
}

func NewUser(name string) *User {
    
    return &User{ID: uuid.New(), Name: name}
}

service

service/ はドメインモデルを使って、ユースケースを実現します。

こちらも特にパッケージは切らずに全てのサービスをフラットに置いています。

service/
├── task.go
├── user.go
└── ...

model パッケージのコンストラクタでモデルオブジェクトを生成し、GORM 経由で永続化を行います。データの取得も同様に GORM 経由で行います。

package service

type UserService struct{}

func (s *UserService) CreateUser(ctx context.Context, name string) (*model.User, error) {
    
  user, err := model.NewUser(name)
    

    
  err = db.DB(ctx).Create(&user).Error
    

    return user, nil
}

データベーストランザクションの管理も service で行います。

まだ試したことはないけれど、個人的に気になっているパッケージ構成

フラットパッケージ

Go では「フラットパッケージ構成」が一定の支持を得ている印象があります。Future Architect さんの記事でも紹介されていますが、Go の標準ライブラリでも 1 パッケージ内に 100 ファイル以上が置かれている例は珍しくありません。
Go の思想とも相性がよく、「パッケージの粒度は小さく分けすぎない」「適度にフラットでよい」という考え方のもと、多くのプロジェクトで採用されているように感じます。Go では循環参照が禁止されているので、パッケージを細かく分けすぎるとそうした面でも不都合があります。

個人的にはまだ本格的に試したことはないものの、シンプルさ重視のプロジェクトなら相性は良さそうだと感じています。責務が比較的限定されているサービスでは、細かくディレクトリを切るより、単一パッケージに必要なファイルを並べておく方が認知負荷が低く、コードを探索しやすい場合もありそうです。

package by feature

今まで試したものは大体 package by layer の考え方でパッケージを分割しています。
DDD に基づいた場合は、集約部分のみ feature で分割していますが、ユースケースや永続化層は feature ではなく技術的な関心ごとで切っています。

しかし、ユースケースやリポジトリの実装で各集約を跨いだ依存関係は基本的にないため、それらを集約ごとのパッケージ、つまり feature で切った一つのパッケージに全てまとめるのはできなくもなさそうです。その方がコードを追うのもやりやすいかもしれません。

もちろん、各レイヤー内で共通で使いたい処理はあります。例えば、永続化層で言うと DB へのコネクションやクエリを実行するための処理は feature によらず使い回したいです。そうしたものは共通処理を集めたパッケージを定義して、各 feature 側にインジェクトしてあげる形になると思います。そうしたものはドメインに依存しない処理になるはずなので、複数の集約から依存されていたとしても問題は起きなそうです。

とはいえ、各レイヤーで共通して使いたい処理は必ず存在します。たとえば永続化層であれば、DB への接続管理やトランザクション管理など、feature に依らず再利用したい機能が出てきます。こうした部分は共通パッケージとして切り出し、feature 単位のパッケージにインジェクトする形になると思います。ここで重要なのは、共通パッケージがドメインに依存しないことです。その性質上、複数の feature から参照されることになっても、意図しない依存ループや凝集度の低下にはつながりにくいため、構成としても健全さを保ちやすいと考えています。

雑感

昔は自分も、Clean Architecture だ、依存性の逆転だ、ドメインは崇高だと、コードをいかにロバストに書くかに夢中になっていました。しかし最近では、多くのケースではシンプルな MVC っぽい構成で必要十分なのではと感じるようになっています。もちろんアーキテクチャにはそれぞれトレードオフがあり、唯一の正解はありません。最終的には「何を作るか」によって最適解は変わりますが、ギフティではシステムを細かく分割して構築していることもあり、個々の責務がそこまで大きくないため、重厚長大なアーキテクチャを必要だと感じる場面が少ないのかもしれません。

レイヤードアーキテクチャや、集約の概念を取り入れた DDD の実装は、確かに堅牢な構造を作れます。ただその一方で実装のオーバーヘッドが大きく、常にこの構成を選ぶのはなかなか骨が折れます。新しいコードを追加するたびに interface を定義し、データの詰め替えを書き…と、どうしてもコード量が増えがちです。

個人的には、GORM や ent などの ORM を使った方が、慣れれば少ないコードで意図した処理を実現できますし、レイヤーを最小限に抑えることでデータ詰め替えのコストも減らせます。その結果、短期的なアジリティを高めやすくなります。また依存の逆転についても、データベースを載せ替える機会がそうそうないため、日常的にそのための抽象化を維持するコスパはあまり良くないと感じています(もちろん、Clean Architecture の考え方自体は素晴らしいものだと理解しています。アンクルボブの本は何度も読み返しています)。

ドメイン駆動設計をはじめよう』の中では、システムが対象とする業務領域を「中核の業務領域」、「一般的な業務領域」、「補完的な業務領域」の3つに分類しています。これにならい、作ろうとしているシステムがどの領域に属するのかを見極め、その性質に合わせてアーキテクチャや開発プロセスを選ぶのが良いと思います。たとえば、中核の業務領域で継続的にドメインを育てていく前提があるなら、開発プロセス自体にドメイン駆動設計を採用し、コードにもその構造を反映させる価値は十分にあると思います。

終わりに

この記事では、ギフティで試してきた Go のコードアーキテクチャをいくつか紹介しました。Go には明確なフレームワークやパッケージ構成の正解がない分、プロダクトの規模やドメインの性質、開発チームの好みに合わせて柔軟に設計する力が求められます。設計する際に、「何を基準に構成を選ぶか」は常に悩ましいテーマでもあります。

今回紹介した構成はあくまで一例であり、どれにもメリット・デメリットがあります。大切なのは、チームが無理なく運用でき、変更しやすく、プロダクトの成長に耐えられる構成を選ぶことだと思います。

まだまだ模索中の部分も多いので、引き続き色々試していきたいと思います。

それでは、良い Go 開発を!




元の記事を確認する

関連記事