プロジェクト構成とアーキテクチャ
.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 / Startup | cmd/名前/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 Core | net/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の仕様は モジュールとパッケージ を参照してください。