依存性注入と設定管理
.NET (C#) の経験がある開発者向けに、コンストラクタ注入、Composition Root、Wire / fx、環境変数ベースの設定管理を解説します。
1. Go の DI の考え方
ASP.NET Core では IServiceCollection にサービスを登録し、ランタイムのコンテナが依存関係を解決します。Go には標準の DI コンテナがなく、main 関数で手動で組み立てるのが出発点です。
Go で DI を行う原則は次のとおりです。
- コンストラクタ関数 (
NewXxx) で依存を引数として受け取る: 必要なものは引数に並べ、構造体のフィールドに保持する - インターフェースは使う側で定義する: 実装側ではなく、依存する側のパッケージで必要最小限のメソッドだけを宣言する
- 組み立ては 1 か所 (Composition Root) に集約する: 通常は
mainパッケージ - コンテナが必要になるまでは使わない: 手動 DI で十分なプロジェクトがほとんど
「DI = コンテナ」ではなく「DI = 依存を外から渡すこと」と捉えると、Go のスタイルが理解しやすくなります。
2. .NET との対応表
| .NET (C#) | Go |
|---|---|
IServiceCollection / DI コンテナ | 手動 DI (main で組み立て) / google/wire / uber-go/fx |
| コンストラクタ注入 | NewXxx(dep1, dep2) 関数 |
services.AddScoped<IFoo, Foo>() | NewFoo を呼び、戻り値を使う側へ渡す |
| インターフェースは実装側と同じ場所で定義しがち | 使う側のパッケージで定義する |
Program.cs / Composition Root | main.go の run() 関数 |
| ライフタイム (Singleton / Scoped / Transient) | 明示的なスコープ管理 (インスタンスをどこで作り、どこまで渡すか) |
IHostedService / IDisposable | Start / Shutdown / Close メソッド、defer、fx の Lifecycle |
IOptions<T> / appsettings.json | 設定用の構造体 + 環境変数 (envconfig / viper 等) |
IConfiguration の階層化 (json → env → args) | フラグ → 環境変数 → デフォルト値を自前または viper で合成 |
Options の検証 (ValidateDataAnnotations) | Validate() メソッド / validator ライブラリ |
| Moq / NSubstitute | 手書きのフェイク (小さなインターフェースなので書きやすい) |
3. コンストラクタ注入
3.1 基本形
C# のコンストラクタ注入に相当するのは、NewXxx というファクトリ関数です。
// C#
public class UserService
{
private readonly IUserRepository _repo;
private readonly ILogger<UserService> _logger;
public UserService(IUserRepository repo, ILogger<UserService> logger)
{
_repo = repo;
_logger = logger;
}
}
// Go
type UserService struct {
repo UserRepository
logger *slog.Logger
}
func NewUserService(repo UserRepository, logger *slog.Logger) *UserService {
return &UserService{repo: repo, logger: logger}
}
慣習として次の点を守ります。
- コンストラクタは
New+ 型名。具象型 (*UserService) を返す ("accept interfaces, return structs") - 依存はフィールドに保持し、メソッド内でグローバル変数や
init()に頼らない - 必須の依存は引数で受け取り、
nilチェックが必要ならNewの中で行う
3.2 インターフェースは使う側で定義する
C# では IUserRepository をリポジトリの実装と同じプロジェクトに置くことが多いですが、Go ではそのインターフェースを必要とするパッケージ側に置きます。Go のインターフェースは暗黙的に満たされるため、実装側はインターフェースの存在を知らなくてかまいません。
// service パッケージ: 使う側が必要なメソッドだけを宣言する
package service
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Save(ctx context.Context, u *User) error
}
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
// postgres パッケージ: インターフェースを import せず、具象型を公開する
package postgres
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) FindByID(ctx context.Context, id int64) (*service.User, error) { /* ... */ }
func (r *UserRepository) Save(ctx context.Context, u *service.User) error { /* ... */ }
メリットは次のとおりです。
- インターフェースが小さく保たれる (使うメソッドだけ)
- 実装パッケージが利用側パッケージに依存しない (依存の向きが一方向になる)
- テスト用のフェイクを手書きしやすい
インターフェースの詳細はメソッドとインターフェースを参照してください。
3.3 Functional Options
任意の依存や設定が多い場合は、引数が肥大化しないよう Functional Options パターンを使います。必須の依存は引数、任意の設定はオプションに分けるのが定石です。
type Option func(*Server)
func WithLogger(l *slog.Logger) Option {
return func(s *Server) { s.logger = l }
}
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
func NewServer(handler http.Handler, opts ...Option) *Server {
s := &Server{
handler: handler,
logger: slog.Default(),
timeout: 30 * time.Second, // デフォルト値
}
for _, opt := range opts {
opt(s)
}
return s
}
srv := NewServer(h, WithTimeout(10*time.Second))
4. Composition Root (手動 DI)
Program.cs で builder.Services.AddXxx(...) を並べる代わりに、main.go で依存の順にインスタンスを作り、次のコンストラクタへ渡します。
4.1 main と run を分ける
main は終了コードを決めるだけにして、実処理を run() error に切り出します。defer が os.Exit で飛ばされるのを防ぎ、テストもしやすくなります。
// cmd/api/main.go
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"example.com/app/internal/config"
"example.com/app/internal/handler"
"example.com/app/internal/postgres"
"example.com/app/internal/service"
_ "github.com/jackc/pgx/v5/stdlib" // database/sql 用の pgx ドライバ登録
)
func main() {
if err := run(); err != nil {
slog.Error("fatal", "err", err)
os.Exit(1)
}
}
func run() error {
// 1. 設定 (IOptions<T> 相当)
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
}
// 2. ロガー
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// 3. インフラ (DB 接続)
db, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
// 4. 依存順に組み立てる (AddScoped / AddSingleton の並びに相当)
repo := postgres.NewUserRepository(db)
svc := service.NewUserService(repo, logger)
h := handler.New(svc, logger)
// 5. サーバーの起動とグレースフルシャットダウン
srv := &http.Server{
Addr: cfg.Addr,
Handler: h.Routes(),
ReadHeaderTimeout: 5 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
logger.Info("listening", "addr", cfg.Addr)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return fmt.Errorf("serve: %w", err)
case <-ctx.Done():
logger.Info("shutting down")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
ポイント:
- 依存関係がコードの並び順として見える。コンテナの魔法がなく、IDE のジャンプで追跡できる
- 依存の循環はコンパイル時に (変数の未定義として) 検出される
deferで後始末を積むため、IDisposableの破棄順序を意識しなくてよい (後入れ先出し)- グレースフルシャットダウンの詳細はcontextとWeb API開発を参照
4.2 ライフタイムの考え方
Go に Singleton / Scoped / Transient の区別はありません。どこでインスタンスを作り、誰に渡すかで決まります。
| .NET のライフタイム | Go での実現方法 |
|---|---|
| Singleton | main で 1 回だけ作り、使う側に渡す。複数の goroutine から使われるので並行安全に作る |
| Scoped (リクエスト単位) | ハンドラー内で都度生成する、または context.Context でリクエスト固有の値だけを運ぶ |
| Transient | 必要な場所で NewXxx を呼ぶ |
context.Context は DI コンテナの代わりではありません。リポジトリやサービスを context.WithValue に入れて取り回すのはアンチパターンです。コンテキストに入れてよいのは、リクエスト ID やトレース情報などリクエスト固有の値に限ります。
Singleton は並行安全にする
ASP.NET Core の Singleton と同様に、main で作ったインスタンスは複数のリクエスト (goroutine) から同時に呼ばれます。
*sql.DBや*http.Clientは並行安全 (1 つを共有する前提の設計)- 可変な状態を持つ型には
sync.Mutexを使うか、状態を持たない設計にする
Scoped 相当: リクエスト単位の依存
DB トランザクションなど「リクエスト内でだけ生きる依存」は、ファクトリや関数を介して扱います。
// トランザクション境界を関数で表現する
type TxManager interface {
WithTx(ctx context.Context, fn func(ctx context.Context, repo UserRepository) error) error
}
func (s *UserService) Rename(ctx context.Context, id int64, name string) error {
return s.tx.WithTx(ctx, func(ctx context.Context, repo UserRepository) error {
u, err := repo.FindByID(ctx, id)
if err != nil {
return err
}
u.Name = name
return repo.Save(ctx, u)
})
}
詳しくはデータアクセスを参照してください。
5. Wire と fx
プロジェクトが大きくなり、main の組み立てコードが長くなってきた場合の選択肢です。
5.1 google/wire (コード生成型)
Wire は、コンストラクタの型シグネチャから組み立てコードを生成するツールです。実行時のリフレクションは使わず、生成されるのは通常の Go コードです。
// wire.go
//go:build wireinject
package main
import (
"github.com/google/wire"
"example.com/app/internal/config"
"example.com/app/internal/postgres"
"example.com/app/internal/service"
)
func InitializeApp(cfg config.Config) (*App, func(), error) {
wire.Build(
openDB,
postgres.NewUserRepository,
// インターフェースと実装を結び付ける
wire.Bind(new(service.UserRepository), new(*postgres.UserRepository)),
service.NewUserService,
newApp,
)
return nil, nil, nil // 実際の戻り値は生成コードで置き換わる
}
# wire_gen.go を生成
go run github.com/google/wire/cmd/wire ./cmd/api
- コンパイル時に依存の不足や循環を検出できる (実行時エラーにならない)
- 生成コードは手書きの
mainと同じ形なので、デバッグしやすい - コンストラクタが
(T, func(), error)を返せば、クリーンアップ関数も扱える - 採用前に、リポジトリのメンテナンス状況を確認してください
5.2 uber-go/fx (ランタイム型)
fx はリフレクションで依存を解決するランタイム DI フレームワークで、.NET の Microsoft.Extensions.DependencyInjection + IHostedService に近い使い心地です。ライフサイクル管理 (起動・停止フック) を備えています。
func main() {
fx.New(
fx.Provide(
config.Load,
newLogger,
openDB,
// 具象型をインターフェースとして提供する
fx.Annotate(
postgres.NewUserRepository,
fx.As(new(service.UserRepository)),
),
service.NewUserService,
handler.New,
newHTTPServer,
),
fx.Invoke(func(*http.Server) {}), // 起動時に必ず生成したいもの
).Run()
}
func newHTTPServer(lc fx.Lifecycle, cfg config.Config, h *handler.Handler) *http.Server {
srv := &http.Server{Addr: cfg.Addr, Handler: h.Routes()}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
return err
}
go srv.Serve(ln)
return nil
},
OnStop: func(ctx context.Context) error {
return srv.Shutdown(ctx)
},
})
return srv
}
fx.ProvideがAddSingleton相当 (fx のインスタンスは基本的にシングルトン)- 依存の不足は実行時 (起動時) に検出される
- スタックトレースが追いにくくなる、リフレクションの学習コストがあるなどのトレードオフがある
5.3 どれを選ぶか
| 方式 | 向いているケース | 注意点 |
|---|---|---|
| 手動 DI | 小〜中規模。依存が数十個程度まで | main が長くなる (ただし単純) |
| Wire | 依存が多く、コンパイル時に検証したい | 生成ステップが必要。ビルド手順が増える |
| fx | ライフサイクル管理込みでモジュール化したい大規模サービス | 実行時に解決されるため、挙動の把握に慣れが必要 |
Go コミュニティではまず手動 DI から始め、本当に苦痛になってからツールを導入するのが一般的です。.NET の感覚でコンテナを最初から導入すると、Go の「明示的で単純」という方針と合わず、かえって複雑になりがちです。
6. 設定管理
ASP.NET Core の appsettings.json + IOptions<T> に相当するのは、設定用の構造体を定義し、起動時に 1 回読み込んで検証し、必要な部分だけを各コンストラクタに渡すパターンです。Twelve-Factor App に沿い、環境変数で設定するのが一般的です。
6.1 標準ライブラリだけで読み込む
// internal/config/config.go
package config
import (
"errors"
"fmt"
"net"
"os"
"time"
)
type Config struct {
Addr string
DatabaseURL string
LogLevel string
ShutdownTimeout time.Duration
}
func Load() (Config, error) {
cfg := Config{
// デフォルト値
Addr: ":8080",
LogLevel: "info",
ShutdownTimeout: 10 * time.Second,
}
if v, ok := os.LookupEnv("APP_ADDR"); ok {
cfg.Addr = v
}
if v, ok := os.LookupEnv("APP_LOG_LEVEL"); ok {
cfg.LogLevel = v
}
if v, ok := os.LookupEnv("APP_SHUTDOWN_TIMEOUT"); ok {
d, err := time.ParseDuration(v)
if err != nil {
return Config{}, fmt.Errorf("APP_SHUTDOWN_TIMEOUT: %w", err)
}
cfg.ShutdownTimeout = d
}
// 必須項目
cfg.DatabaseURL = os.Getenv("DATABASE_URL")
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
// Validate は ValidateOnStart() 相当
func (c Config) Validate() error {
var errs []error
if c.DatabaseURL == "" {
errs = append(errs, errors.New("DATABASE_URL is required"))
}
if _, _, err := net.SplitHostPort(c.Addr); err != nil {
errs = append(errs, fmt.Errorf("APP_ADDR %q: %w", c.Addr, err))
}
return errors.Join(errs...) // 複数のエラーをまとめて返す
}
os.Getenv は未設定と空文字を区別できません。区別が必要なときは os.LookupEnv を使います。
6.2 ライブラリを使う
環境変数の数が増えると定型コードが増えるため、構造体タグで宣言できるライブラリを使うことが多いです。
| ライブラリ | 特徴 |
|---|---|
| caarlos0/env | 構造体タグで環境変数をマッピング。軽量で、環境変数のみで十分な場合に向く |
| kelseyhightower/envconfig | 同様に構造体タグ方式。実績が長い |
| spf13/viper | YAML / JSON / 環境変数 / フラグを統合。多機能だが依存が多い |
| knadh/koanf | viper より軽量な代替。ソースを組み合わせて読み込む |
import "github.com/caarlos0/env/v11"
type Config struct {
Addr string `env:"APP_ADDR" envDefault:":8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
LogLevel string `env:"APP_LOG_LEVEL" envDefault:"info"`
ShutdownTimeout time.Duration `env:"APP_SHUTDOWN_TIMEOUT" envDefault:"10s"`
}
func Load() (Config, error) {
cfg, err := env.ParseAs[Config]()
if err != nil {
return Config{}, fmt.Errorf("parse env: %w", err)
}
return cfg, nil
}
.env ファイルはローカル開発用に joho/godotenv などで読み込めますが、本番ではプラットフォーム (コンテナ、Cloudflare、Kubernetes 等) の環境変数を使い、.env はコミットしません。
6.3 設定の渡し方
IOptions<T> のように構造体全体をあちこちに注入するのではなく、各コンポーネントが必要とする値だけを渡すことを推奨します。
// 悪い例: 全設定を受け取ると、何に依存しているか分からない
func NewUserService(cfg config.Config, repo UserRepository) *UserService
// 良い例: 必要な値だけを受け取る
func NewUserService(repo UserRepository, cacheTTL time.Duration) *UserService
// 設定が多いコンポーネントは、専用の Options 構造体を定義する
type ServerOptions struct {
Addr string
ReadTimeout time.Duration
WriteTimeout time.Duration
}
func NewServer(h http.Handler, opts ServerOptions) *http.Server
6.4 設定管理の指針
- 起動時に 1 回だけ読み込み、検証する: 不正なら即座に終了する (fail fast)。リクエスト処理中に環境変数を読まない
- 読み込み後は不変として扱う:
Configは値型で渡し、ポインタ越しの書き換えを避ける - デフォルト値はコードに持たせる: 本番で必須の値 (接続文字列など) にはデフォルトを置かない
- シークレットはログに出さない:
String()やLogValue()をマスクして実装する - グローバル変数に持たない:
config.Globalのような変数は、テストでの差し替えと並行テストを難しくする
// slog でシークレットを出力しない
type Secret string
func (Secret) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }
func (Secret) String() string { return "[REDACTED]" }
7. テストでの差し替え
小さなインターフェースを使う側で定義していれば、モックライブラリなしで手書きのフェイクを渡すだけで済みます。
type fakeRepo struct {
users map[int64]*service.User
err error
}
func (f *fakeRepo) FindByID(ctx context.Context, id int64) (*service.User, error) {
if f.err != nil {
return nil, f.err
}
u, ok := f.users[id]
if !ok {
return nil, service.ErrUserNotFound
}
return u, nil
}
func (f *fakeRepo) Save(ctx context.Context, u *service.User) error {
f.users[u.ID] = u
return f.err
}
func TestUserService_Rename(t *testing.T) {
repo := &fakeRepo{users: map[int64]*service.User{1: {ID: 1, Name: "old"}}}
svc := service.NewUserService(repo, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err := svc.Rename(context.Background(), 1, "new"); err != nil {
t.Fatal(err)
}
if got := repo.users[1].Name; got != "new" {
t.Errorf("Name = %q, want %q", got, "new")
}
}
環境変数に依存する設定のテストは、t.Setenv を使うとテスト終了時に自動で元へ戻ります。
func TestLoad(t *testing.T) {
t.Setenv("DATABASE_URL", "postgres://localhost/test")
t.Setenv("APP_ADDR", ":9999")
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
if cfg.Addr != ":9999" {
t.Errorf("Addr = %q", cfg.Addr)
}
}
テストの詳細はGoのテストを参照してください。
8. C# 開発者がつまずきやすいポイント
| つまずき | 説明 | 対処 |
|---|---|---|
| 最初にコンテナを探してしまう | Go では手動 DI が標準的 | まず main で組み立て、必要になったら Wire / fx を検討する |
| インターフェースを実装側に置く | IUserRepository を実装と同じパッケージに置くと、巨大なインターフェースになりやすい | 使う側のパッケージに、必要なメソッドだけを定義する |
| 全メソッドを含む巨大なインターフェース | テストでフェイクを書くのが大変になる | 1〜3 メソッド程度に分割する |
| インターフェースを返してしまう | NewXxx がインターフェースを返すと、利用側が実装の機能を使えない | 具象型を返し、受け取る側で抽象化する |
init() やグローバル変数での初期化 | 副作用があり、テストや初期化順序の制御が難しい | main から明示的に初期化する |
context に依存を詰める | 型安全でなく、依存関係が見えなくなる | コンストラクタ引数で渡す |
nil のインターフェース | 型付き nil をインターフェースに入れると != nil になる | 具象型の nil ポインタを返さない (メソッドとインターフェース) |
| 循環依存 | パッケージ間の循環は import できない (コンパイルエラー) | インターフェースを使う側に置いて依存の向きを反転させる (プロジェクト構成) |
| 設定をグローバルに持つ | テストでの差し替えが困難になる | Load() の戻り値を渡す |
| 設定の検証をしない | 不正値のまま起動して、実行中に障害が起きる | 起動時に Validate() して fail fast にする |
9. まとめ
- Go の DI はコンストラクタ関数 (
NewXxx) で依存を引数として渡すのが基本で、DI コンテナは必須ではない。 - 組み立ては Composition Root (
mainのrun()) に集約し、依存順にインスタンスを作る。後始末はdeferで行う。 - インターフェースは使う側で定義し、小さく保つ。コンストラクタは具象型を返す。
- ライフタイムの区別はなく、どこで作り誰に渡すかで決まる。Singleton は並行安全にし、リクエスト固有のものは
contextではなく関数やファクトリで扱う。 - 組み立てが複雑になったら、コンパイル時に検証する Wire、ライフサイクルも管理する fx を検討する。まずは手動 DI から始める。
- 設定は構造体に読み込み、起動時に 1 回検証する (fail fast)。環境変数が基本で、各コンポーネントには必要な値だけを渡す。
- テストでは手書きのフェイクと
t.Setenvで十分に差し替えられる。