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

依存性注入と設定管理

.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 Rootmain.go の run() 関数
ライフタイム (Singleton / Scoped / Transient)明示的なスコープ管理 (インスタンスをどこで作り、どこまで渡すか)
IHostedService / IDisposableStart / 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 での実現方法
Singletonmain で 1 回だけ作り、使う側に渡す。複数の goroutine から使われるので並行安全に作る
Scoped (リクエスト単位)ハンドラー内で都度生成する、または context.Context でリクエスト固有の値だけを運ぶ
Transient必要な場所で NewXxx を呼ぶ
依存を context に詰めない

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/viperYAML / JSON / 環境変数 / フラグを統合。多機能だが依存が多い
knadh/koanfviper より軽量な代替。ソースを組み合わせて読み込む
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 で十分に差し替えられる。

参考資料​