はじめに
「Goのバックエンドをきちんと設計したい」と思い、自作NASシステム(FOX-Storage)を作りました。
ただコードを書くだけでなく、層を分けて独立性を高める・DIで疎結合にする・インターフェースでテストしやすくするという設計上の目標を持って取り組みました。本記事では、その過程で得た知見を「なぜそう設計したか」「どんな問題があったか」という視点で整理しています。
対象読者
- Webバックエンド開発の経験があり、Goの設計に興味がある方
- レイヤードアーキテクチャやDIを実際のコードで学びたい方
プロジェクト概要
FOX-Storageは、クラウドストレージを自作するプロジェクトです。AWS S3に代表されるオブジェクトストレージを模した設計で、DB上でファイルの階層構造を論理的に表現し、UUIDでファイルとフォルダを管理します。
完成したコードは以下のリポジトリにあります。
https://github.com/KanadeSisido/FOX-Storage
1. アーキテクチャ設計
目標と方針
コードが増えてくると、「この処理はどこに書くべきか」が曖昧になりがちです。責務を明確にするために、処理を複数の層(レイヤー)に分割する「レイヤードアーキテクチャ」を採用しました。
設計目標は次の2点です。
- 各層の関数を独立させる:ある層を変更しても、他の層に影響が出ないようにする
- 上位層は1つ下の層だけを呼ぶ:依存の方向を一方向に保つ
レイヤー構成
採用したレイヤーは以下の通りです。HTTPフレームワークにGin、ORMにGormを使っています。
[Handler] → [Controller] → [Service] → [Repository] → [DB]
| 層 | 役割 |
|---|---|
| Main | DBコネクションの生成、エントリーポイント |
| Wire | DI担当 |
| Router | Ginのルーター定義 |
| Handler | GinのContextを受け取り、Gin固有のレスポンスを生成する |
| Controller | Ginに依存しない処理。Serviceを1〜複数回呼び出す |
| Service | ビジネスロジック(認証・サインアップなど)。Repositoryを呼び出す |
| Repository | GormによるDB操作 |
| DB | GormでラップされたDBコネクション |
HandlerとControllerを分けているのは、GinというフレームワークへのロックインをHandlerに閉じ込めるためです。Controllerはgin.Contextではなくcontext.Contextを受け取ります。
2. DI(依存性注入)
DIとは何か
DI(Dependency Injection)とは、あるオブジェクトが依存する別のオブジェクトを、内部で生成するのではなく「外から注入してもらう」設計パターンです。
例えば、DBに問い合わせを行う関数「QueryFunc(query string)」があったとして、DBコネクションをどこで作成するかについてはいくつか方法があります。
1. 関数QueryFunc(query string)内でコネクションを作成する
func QueryFunc(query string) {
// イメージ
conn := createConnection();
}
2. 関数外で宣言したDBコネクションを関数の引数に与える
func QueryFunc(query string, conn *gorm.DB) {
...
}
3. structの中にコネクションをフィールドとしてセットし、そのstructにメソッドを生やす
type struct dbStruct {
db *gorm.DB
}
func (d dbStruct) QueryFunc(query String){
...
}
2,3はDIと言っていいと思います。
DBという、この関数の依存オブジェクトが外部から与えられるためです。
DIのうまみ
DIのうまみは、テスト時にわかります。テストとは、関数が正しく動いているかを検証する操作のことです。
コードを検証する際、検証対象のコード(ここではQueryFunc)に問題があるかどうかを調べるために、DBをいちいち操作するのは面倒です。したがって、偽物のDBみたいなモジュール(=モック)を作成し、それをDBの代わりに入れます。
1のコードでは、検証するコード内にDBを作成するコードが入ってしまっているので、DBコネクションの代わりにモックを入れる余地がありません。
一方、DBコネクションが外部から与えられる2,3では、外部でモックを作って検証対象に放り込めばOKです。したがって、DIを意識するとモックが入れやすくなります。
GoはStructにメソッドを定義できるため、オブジェクト指向に近い形で設計できます。DIをGoで実現する典型的な方法は、Structのフィールドに下位層のオブジェクトを持たせることです。
// ServiceはRepositoryに依存する
type UserService struct {
repo UserRepository
}
// 依存を外から注入するコンストラクタ
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
こうすることで、ServiceはUserRepositoryというインターフェースにしか依存せず、具体的な実装(本番用・テスト用)を後から差し替えられます。
インターフェースとモック
GoのインターフェースはJavaなどと異なり、実装側が明示的に宣言しなくてもインターフェースを満たせる(暗黙的実装)という特徴があります。これを活かせば、テストで楽ができます。
具体例で見てみましょう。
// ① インターフェースを定義する
type UserRepository interface {
CreateUser(ctx context.Context, username string, email string, hashedPassword string) error
GetUserByUsername(ctx context.Context, username string) (*model.User, error)
GetUserById(ctx context.Context, userId string) (*model.User, error)
}
// ② 本番用の実装(UserRepositoryInterfaceを暗黙的に満たす)
type userRepository struct {
db *gorm.DB
}
func (r userRepository) CreateUser(ctx context.Context, username string, email string, hashedPassword string) error {/*...*/}
func (r userRepository) GetUserByUsername(ctx context.Context, username string) (*model.User, error) {/*...*/}
func (r userRepository) GetUserById(ctx context.Context, userId string) (*model.User, error) {/*...*/}
// ③ Serviceはインターフェースに依存させる
type userService struct {
repository repository.UserRepository // 具体型ではなくインターフェースで持つ
}
テスト時には、UserRepositoryInterfaceを満たすモック構造体を渡します。
// テスト用モック
type MockUserRepository struct{}
func (m userRepository) CreateUser(ctx context.Context, username string, email string, hashedPassword string) error {
return nil
}
func (m userRepository) GetUserByUsername(ctx context.Context, username string) (*model.User, error) {
return &User{ID: 0, Name: username}, nil
}
func (m userRepository) GetUserById(ctx context.Context, userId string) (*model.User, error) {
return &User{ID: userId, Name: "テストユーザー"}, nil
}
// テストコード
func TestUserService(t *testing.T) {
mockRepo := &MockUserRepository{}
service := NewUserService(mockRepo) // モックを注入
// ...
}
Serviceのテストをする際にDBが不要になります。これがDI+インターフェースの最大の恩恵です。
モックの自動生成:Gomock
手動でモックを書く場合、Repositoryのインターフェースにメソッドが増えるたびにモックの実装も追加しなければなりません。Gomockを使うと、インターフェースからモックを自動生成できます。
go install go.uber.org/mock/mockgen@latest
mockgen -source=repository/user_repository.go -destination=mocks/mock_user_repository.go
生成されたモックは、呼び出し回数や引数の検証も柔軟に設定できます。
3. DIの組み立て
手動DIの問題
main関数内でDIを手動で組み立てるのは面倒です。
// イメージ
func main() {
db := connectDB()
userRepo := repository.NewUserRepository(db)
fileRepo := repository.NewFileRepository(db)
userUseCase := usecase.NewUserUseCase(userRepo)
fileUseCase := usecase.NewFileUseCase(fileRepo, userRepo)
userController := controller.NewUserController(userUseCase)
fileController := controller.NewFileController(fileUseCase)
// ...
}
層が増えるほどこの記述が長くなり、依存関係の全体像を把握しにくくなります。
Wireによる静的DI生成
WireはGoの静的解析を使って、DIのコードを自動生成してくれるツールです。
(アーカイブされてしまいましたが……)
各層のパッケージは「下位層のStructを受け取り、自層のStructを返す関数(コンストラクタ)」を持ちます。Wireはこれらのコンストラクタを読み取り、正しい順序で呼び出すコードを生成します。
// wire.go(生成の設定を書く)
//go:build wireinject
func InitializeRouter() *gin.Engine {
wire.Build(
db.InitDB,
repository.NewItemRepository,
repository.NewUserRepository,
service.NewItemService,
service.NewUserService,
controller.NewItemController,
controller.NewUserController,
handler.NewItemHandler,
handler.NewUserHandler,
router.NewRouter,
)
return nil
}
wire コマンドを実行すると、wire_gen.go に先ほど手動で書いていたようなコードが自動生成されます。依存関係のグラフをWireが解決してくれるので、コンストラクタを増やしてもwire.goに追記するだけで済みます。
4. 主要機能の実装
ファイルアップロード
multipart/form-dataでアップロードを受け付けます。リクエストが来た際の処理フローは以下の通りです。
[Handler]
└─ ファイル・メタデータを取り出す+レスポンス作成
[Controller]
└─ Serviceにぶん投げる(ControllerはUser系で使われています)
[Service]
└─ 認可・DBへのメタデータ登録とストレージ保存を依頼する
[Repository]
├─ UUID生成
├─ DBにファイルのメタデータ(UUID・ファイル名・サイズなど)を登録する
└─ アップロードされたファイルをUUID名でサーバーストレージに保存する
UUIDをファイル名にすることで、同名ファイルの衝突を防ぎ、ファイルの特定もDBから行えます。
ファイルダウンロード
フロントエンドはUUIDとダウンロードのリクエストをREST APIで送ります。バックエンドはUUIDをもとにDB上のメタデータを取得し、ユーザーの権限を確認してからファイルを送信します。
権限チェックをRepositoryではなくServiceで行うことで、「誰がダウンロードできるか」というビジネスルールをServiceに集約できます。
JWT認証
JWT(JSON Web Token)を使用したユーザー認証を実装しています。ログイン成功時にJWTを発行してCookieに保存し、以降のリクエストはGinのミドルウェアでJWTを検証します。
// ミドルウェア
func Auth(c *gin.Context) {
tokenStr, err := c.Cookie("Authorization")
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Un Authorized Error"})
return
}
keyData, err := os.ReadFile("config/private_key.pem")
if err != nil {
fmt.Println("Error: Private key cannot Open")
return
}
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(keyData)
if err != nil {
fmt.Println("Error: cannot parse PEM")
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
return privateKey.Public(), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Invalid or expired token"})
return
}
c.Set("userId", claims.UserID)
}
5. 開発環境(Docker)
air-verse/airを用いることで、ホットリロードができるようになっています。
# Dockerfile(開発環境)
FROM golang:1.25.0-alpine3.22
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && \
go install github.com/air-verse/air@latest
EXPOSE 8080
CMD ["air", "-c", ".air.toml"]
Composeは以下の通りです。
services:
api:
build: ./
volumes:
- .:/app
ports:
- 8080:8080
env_file:
- .env
depends_on:
- db
db:
image: mariadb:12.0
volumes:
- ./db_data:/var/lib/mysql
restart: always
ports:
- 3306:3306
env_file:
- .env
今回はGoバックエンドの設計理解に注力したため、本番デプロイは次のプロジェクト(ame:ato)で取り組みます。
6. 振り返り:見えてきた3つの課題
実装を進める中で、このアーキテクチャの課題が明確になりました。
課題1:層ごとの単一Structはファイル分割しにくい
今回は各層に1つのStructを作り、全メソッドをそこに集めました。(※現在のコードでは、少し分割しています)
そうなると、インターフェース定義も単一ファイルに集中し、複数人での開発ではなんか嫌な構造になっていました。
// こうなりがち
type UserRepository interface {
CreateUser(ctx context.Context, username string, email string, hashedPassword string) error
GetUserByUsername(ctx context.Context, username string) (*model.User, error)
GetUserById(ctx context.Context, userId string) (*model.User, error)
...
...
...
...
}
// ↑ 全部同じファイルに集まってしまう
課題2:層が多すぎた
Router / Handler / Controller / Service / Repository という5層構成は、今回の規模には少し多すぎました。特にControllerがほとんど処理を持たず、Serviceへの中継しかしていない場面が多くなりました。
課題3:層内の結合度がまだ高い
層を分けることで層間の結合は下げられましたが、同じ層の中での結合度は高いままでした。たとえばServiceの中でRegisterUser処理とTryAuthorization(Login)処理が同じStructを共有しているため、片方の変更がもう片方に影響するリスクがあります。
この問題を次のプロジェクト「ame:ato」では「ロジックごとのStruct分割」で解決します。
まとめ
本記事では、自作NASシステムFOX-Storageを通じて以下を実践しました。
- レイヤードアーキテクチャで責務を分離し、変更の影響範囲を限定する
- DI+インターフェースで依存を外から注入し、テスト可能な構造にする
- WireでDIコードを自動生成し、手動管理のコストを下げる
一方で「単一Structへの集中」「層の粒度が粗い」という課題も見つかりました。これらを踏まえて設計を改善した続編「ame:ato開発記」に続きます。