メインコンテンツまでスキップ

プロジェクト構成とアーキテクチャ

.NET (C#) の経験がある開発者向けに、標準的なディレクトリ構成、Clean Architecture/Hexagonalの適用方法とGoらしい設計を解説します。C# では「ソリューション内に Domain / Application / Infrastructure / Web の複数プロジェクトを作る」構成が定番ですが、Go では 1 モジュールの中の小さなパッケージで同じ依存の向きを実現します。Go チームが公式に示すレイアウトを軸に、過剰な抽象化を避ける Go の流儀を整理します。

1. このドキュメントで扱うこと​

  • cmd / internal / pkg の使い分け
  • 過剰な抽象化を避けるGoの流儀
  • パッケージ名とAPI設計の慣習
  • Effective Go / Code Review Comments の要点

cmd / internal / pkg の使い分け​

Go チームの公式ガイド「Organizing a Go module」は、プロジェクトの種類ごとに段階的な構成を示しています。小さく始めて、必要になってから分割するのが基本です。

構成例
単一のコマンドルートに go.mod と main.go を置く。ファイルが増えたら同じ main パッケージ内で分ける
コマンド + 補助パッケージルートの main.go と、実装を置く internal/
複数のコマンドcmd/サーバー名/main.go を実行ファイルごとに置き、共通コードは internal/ に置く
再利用されるライブラリルートにライブラリのパッケージを置き、公開したくない実装は internal/ に置く

各ディレクトリの役割です。

  • cmd/ は、実行ファイルごとの main パッケージを置きます。main は引数や設定を読み、依存関係を組み立てて起動するだけにして、処理の本体は internal/ に置きます。
  • internal/ は、モジュール外から import できないことをコンパイラーが保証する領域です (詳細は モジュールとパッケージ)。アプリケーションの実装は基本的にここに置きます。
  • pkg/ は、公式ガイドには登場しません。「外部から import してよいコード」を示す慣習として広まったディレクトリですが、必須ではなく、ライブラリでなければ不要です。新規のアプリケーションでは、internal/ を使えば足ります。
  • util、common、models のような、役割を表さない名前のパッケージは避けます。

過剰な抽象化を避けるGoの流儀​

Go は、まず具体的な型と関数で書き、必要になったときに抽象化を足す言語です。

  • インターフェースは、実装側ではなく、使う側のパッケージで定義します。メソッドは、その利用側が実際に呼ぶものだけに絞ります (詳細は 依存性注入と設定管理)。
  • 「インターフェースを受け取り、具体的な型 (構造体) を返す」(accept interfaces, return structs) が基本です。関数の引数は呼び出し側が差し替えやすいようにインターフェースで受け、戻り値は具体型にして利用側に選択肢を残します。
  • 実装が 1 つしかない段階で、IUserService のようなインターフェースを先に作りません。テストでモックが必要になった時点で、利用側に小さなインターフェースを定義すれば間に合います。
  • MediatR のような仲介役や、Repository / Service / Manager の多段構造は、最初から入れません。ハンドラーから関数やメソッドを直接呼びます。

パッケージ名とAPI設計の慣習​

  • パッケージ名は、短く、小文字で、アンダースコアや混在した大文字を含めません (orderitems であり order_items や orderItems ではない)。
  • 呼び出し側では パッケージ名.名前 と読まれるため、名前の重複 (stutter) を避けます。user.UserService ではなく user.Service とします。
  • util、common、base、helpers のような名前は、何でも入るパッケージになるため避けます。
  • ゲッターに Get を付けません。user.Name() であり user.GetName() ではありません。設定用は SetName とします。
  • エクスポートされた名前には、その名前で始まるドキュメントコメントを書きます (// Service handles ...)。
  • レシーバー名は 1 〜 2 文字の短い名前で、型を通じて一貫させます。this や self は使いません。
  • エラー文字列は小文字で始め、句読点で終えません。

Effective Go / Code Review Comments の要点​

Effective Go と Go Code Review Comments は、Go のコードを書くときの共通の拠り所です。特に次の点が実務で効きます。

項目要点
エラー処理エラーは無視せず返す。fmt.Errorf("...: %w", err) で文脈を付けて包む
context.Context第一引数に置き、構造体に保存しない (contextパッケージ)
ゼロ値ゼロ値がそのまま使える型にする
早期リターン正常系をインデントさせず、エラー系を先に返す
並行処理ゴルーチンの終了方法を、起動時に決める
インターフェース使う側で定義し、小さく保つ

2. .NET との対応表​

.NET (C#)Go
Clean Architecture (多層プロジェクト)cmd/ + internal/ + 小さなパッケージ
Repository / Service パターン利用側で定義する小さなインターフェース
MediatR / CQRS関数・ハンドラの直接呼び出しが基本

上の表を補う対応関係です。

.NET (C#)Go
ソリューション (.sln)モジュール (go.mod)、または複数モジュールの go.work
プロジェクト (.csproj) = 参照の単位パッケージ (ディレクトリ)
Program.cs / Startupcmd/名前/main.go
Domain / Application / Infrastructure プロジェクトinternal/ 配下の機能別パッケージ (例: order, postgres, httpapi)
internal (アセンブリ内に閉じる)internal/ ディレクトリ (モジュール内のみ import 可)
IOrderRepository を Domain プロジェクトに置くRepository インターフェースを order パッケージ (利用側) に置く
層ごとのフォルダー (Controllers / Services / Repositories)機能ごとのパッケージ (package by feature)
DTO / AutoMapper用途別の小さな構造体と、明示的な変換関数
IServiceCollection による登録main での手動の組み立て (依存性注入と設定管理)
[ApiController] と ASP.NET Corenet/http と http.Handler (Web API)

3. C# 開発者がつまずきやすいポイント​

層ごとのパッケージ分割は循環 import を招く​

C# では Controllers / Services / Repositories のように層でフォルダーを分けても、同じアセンブリ内なら相互に参照できます。Go はパッケージ間の循環 import をコンパイルエラーにします。models、services、repositories と層で分けると、services が models を、repositories が models と services の型を、と参照が絡みやすくなります。機能 (order、customer など) 単位でパッケージを切り、型と振る舞いを同じパッケージに置くと、この問題は起きにくくなります。

依存の向きは「インターフェースの置き場所」で決まる​

Clean Architecture では、内側 (ドメイン) が外側 (DB、HTTP) に依存しません。C# ではドメインのプロジェクトにインターフェースを置き、インフラのプロジェクトがそれを実装します。Go では、使う側 (たとえば order パッケージ) が必要なメソッドだけのインターフェースを定義し、postgres パッケージはそれを意識せず、具体的な型を公開します。Go のインターフェースは暗黙的に満たされるため、実装側は利用側のパッケージを import する必要がありません。

order は他の内部パッケージを import せず、httpapi と postgres が order の型を使います。main がすべてを組み立てます。

共有インターフェースを 1 か所に集めない​

C# の Interfaces フォルダーや、IFoo と Foo を対にする習慣を持ち込むと、実装と同じパッケージにインターフェースを置き、利用側が巨大なインターフェースに依存することになります。インターフェースは、使う側が必要とする最小のメソッド集合で、使う側のパッケージ内に置きます。標準ライブラリの io.Reader のように、1 メソッドのインターフェースが理想です。

「戻り値はインターフェース」にしない​

C# では、ファクトリーが IFoo を返すことは普通です。Go では、コンストラクターは具体的な型へのポインターを返します (func NewService(...) *Service)。戻り値をインターフェースにすると、利用側は選べず、メソッドの追加が互換性の問題になります。

pkg/ と過度な階層化​

golang-standards/project-layout のような、非公式のテンプレートを公式の標準だと思い込まないでください。Go チームの公式ガイドはより小さな構成から始めます。ディレクトリが深いだけでは、設計は良くなりません。import パスが長くなり、パッケージの責務も曖昧になります。

ドメインモデルに ORM のタグや属性を混ぜない問題​

C# の Entity Framework のエンティティを兼用する感覚で、ドメインの構造体に json、db のタグを何でも付けると、永続化や API の都合がドメインに入り込みます。単純なアプリケーションでは 1 つの構造体で十分ですが、複雑になったら、API 用の構造体と DB 用の構造体を各アダプターに置き、変換関数で結びます。

継承も抽象クラスもない​

テンプレートメソッドや基底クラスによる共通化はできません。共通の振る舞いは、構造体の埋め込み (embedding)、関数、または小さなインターフェースの組み合わせで表現します。埋め込みは継承ではなく委譲の簡易記法であり、多用すると API が曖昧になります。

4. コード例​

例 1: 公式ガイドに沿ったサーバープロジェクトの構成​

shop/
├── go.mod module example.com/shop
├── cmd/
│ ├── shop-api/
│ │ └── main.go HTTP サーバーの起動 (組み立てのみ)
│ └── shop-worker/
│ └── main.go 別の実行ファイル
├── internal/
│ ├── order/ ドメインとユースケース (外部へ依存しない)
│ │ ├── order.go
│ │ ├── service.go
│ │ └── service_test.go
│ ├── customer/
│ ├── postgres/ order.Repository の実装 (DB アダプター)
│ │ └── order_repository.go
│ ├── httpapi/ HTTP ハンドラーとルーティング
│ │ └── order_handler.go
│ └── config/ 設定の読み込み
└── migrations/ SQL マイグレーション

C# では、Shop.Domain、Shop.Application、Shop.Infrastructure、Shop.Api の 4 プロジェクトに分かれる構成を、Go では 1 モジュール内のディレクトリで表現します。

例 2: 利用側でインターフェースを定義する (C# との比較)​

C#:

// Domain プロジェクト
public interface IOrderRepository
{
Task<Order?> FindAsync(int id, CancellationToken ct);
Task SaveAsync(Order order, CancellationToken ct);
}

// Application プロジェクト
public class OrderService(IOrderRepository repository)
{
public async Task<Order> GetAsync(int id, CancellationToken ct)
=> await repository.FindAsync(id, ct)
?? throw new KeyNotFoundException();
}

// Infrastructure プロジェクト
public class SqlOrderRepository : IOrderRepository { /* ... */ }

Go:

// internal/order/service.go
package order

import (
"context"
"errors"
"fmt"
)

var ErrNotFound = errors.New("order not found")

type Order struct {
ID int
Total int
}

// Repository は Service が必要とするメソッドだけを持つ。
// 利用側 (order パッケージ) で定義する。
type Repository interface {
Find(ctx context.Context, id int) (*Order, error)
}

type Service struct {
repo Repository
}

// 引数はインターフェース、戻り値は具体的な型
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}

func (s *Service) Get(ctx context.Context, id int) (*Order, error) {
o, err := s.repo.Find(ctx, id)
if err != nil {
return nil, fmt.Errorf("get order %d: %w", id, err)
}
return o, nil
}
// internal/postgres/order_repository.go
package postgres

import (
"context"
"database/sql"
"errors"

"example.com/shop/internal/order"
)

type OrderRepository struct {
db *sql.DB
}

func NewOrderRepository(db *sql.DB) *OrderRepository {
return &OrderRepository{db: db}
}

// order.Repository を満たす。order パッケージを import するのは型を使うためだけで、
// 「implements」の宣言は不要。
func (r *OrderRepository) Find(ctx context.Context, id int) (*order.Order, error) {
var o order.Order
err := r.db.QueryRowContext(ctx, "SELECT id, total FROM orders WHERE id = $1", id).
Scan(&o.ID, &o.Total)
if errors.Is(err, sql.ErrNoRows) {
return nil, order.ErrNotFound
}
if err != nil {
return nil, err
}
return &o, nil
}

インターフェースを満たすかどうかを、コンパイル時に確認したい場合は、次の 1 行を postgres パッケージに書く慣用句があります。

var _ order.Repository = (*OrderRepository)(nil)

例 3: cmd の main は組み立てだけ​

// cmd/shop-api/main.go
package main

import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"

"example.com/shop/internal/httpapi"
"example.com/shop/internal/order"
"example.com/shop/internal/postgres"
)

func main() {
if err := run(); err != nil {
slog.Error("fatal", "error", err)
os.Exit(1)
}
}

func run() error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()

// 依存関係を内側から外側へ手で組み立てる
repo := postgres.NewOrderRepository(db)
svc := order.NewService(repo)
handler := httpapi.NewHandler(svc)

srv := &http.Server{Addr: ":8080", Handler: handler}
go func() {
<-ctx.Done()
_ = srv.Shutdown(context.Background())
}()

if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}

main を薄くし、run() error に処理を移すと、テストしやすく、defer も正しく動きます (os.Exit は defer を実行しないため)。接続文字列の読み込みや DI の詳細は 依存性注入と設定管理 を参照してください。

例 4: MediatR 風の仲介を使わない​

C#:

public record GetOrderQuery(int Id) : IRequest<OrderDto>;

public class GetOrderHandler(IOrderRepository repo) : IRequestHandler<GetOrderQuery, OrderDto>
{
public async Task<OrderDto> Handle(GetOrderQuery q, CancellationToken ct) { /* ... */ }
}

var dto = await mediator.Send(new GetOrderQuery(42), ct);

Go:

// 仲介役を置かず、サービスのメソッドを直接呼ぶ
o, err := svc.Get(ctx, 42)

横断的な処理 (ログ、認証、メトリクス) は、パイプラインビヘイビアではなく、http.Handler のミドルウェアで包むのが一般的です (Web API)。

例 5: パッケージ名と API の整え方​

// 避ける例
package services

type UserService struct{} // services.UserService は重複
func (s *UserService) GetUserName() string // Get と User の重複

// 整えた例
package user

type Service struct{} // user.Service
func (s *Service) Name() string // user.Service の Name

呼び出し側で読んだとき、user.Service、order.NewService のように自然な文章になる名前を目指します。

5. まとめ​

  • 公式ガイドに従い、小さく始めます。単一の main.go から、internal/、cmd/名前/ の順に必要になった時点で分割します。pkg/ は必須ではありません。
  • cmd/ の main は、設定の読み込みと依存関係の組み立て、起動だけを行います。処理の本体は internal/ の機能別パッケージに置きます。
  • 層ではなく機能でパッケージを分けると、循環 import を避けやすくなります。
  • インターフェースは使う側で定義し、小さく保ちます。引数はインターフェースで受け、戻り値は具体的な型にします。
  • 実装が 1 つの段階で抽象化せず、MediatR のような仲介や多段の Repository / Service は、必要が確認できるまで入れません。
  • パッケージ名は短い小文字にし、util や common のような名前を避けます。user.Service のように重複 (stutter) を避け、ゲッターに Get を付けません。
  • テストの書き方は テスト、モジュールと internal の仕様は モジュールとパッケージ を参照してください。

参考資料​