Goデザインパターン:Go開発者が押さえるべき必須パターンと面接対策

Goの設計思想に基づくデザインパターンを体系的に解説。Functional Options、Strategy、Observer、Middlewareなど、面接で問われる実践的パターンとその回答例を紹介します。

Goデザインパターン:Go開発者のための必須パターンと面接対策ガイド

Goのデザインパターンは、オブジェクト指向言語のそれとは根本的に異なる。クラスや継承を持たないGoでは、コンポジション、インターフェース、第一級関数を活用して同等の柔軟性を実現する。従来のパターンをそのまま移植するのではなく、Goの型システムに適応させることが、実務でも面接でも求められる。

Go設計の原則

Goはコンポジションを継承より優先する。すべてのデザインパターンは、クラス階層ではなく、インターフェース、構造体埋め込み、第一級関数を用いて適応させる必要がある。

Functional Optionsパターン:柔軟な設定の実現

Functional Optionsパターンは、Goにおける構造体の初期化で頻出する課題を解決する。メソッドのオーバーロードもデフォルト引数もないGoでは、オプショナルなパラメータの扱いが問題になる。Builderパターンも機能するが冗長になりがちである。Functional Optionsは、後方互換性を保ちつつ拡張可能なクリーンなAPIを提供する。

このパターンはDave Cheneyによって広められ、現在ではGoエコシステム全体で標準的に使用されている。可変長関数引数を使って構造体を設定する仕組みである。

server.gogo
package server

import (
	"time"
	"log/slog"
)

// Server holds the HTTP server configuration.
type Server struct {
	host         string
	port         int
	timeout      time.Duration
	maxConns     int
	logger       *slog.Logger
}

// Option defines a functional option for Server.
type Option func(*Server)

// WithPort sets the server port.
func WithPort(port int) Option {
	return func(s *Server) {
		s.port = port
	}
}

// WithTimeout sets the request timeout.
func WithTimeout(d time.Duration) Option {
	return func(s *Server) {
		s.timeout = d
	}
}

// WithMaxConns sets the maximum concurrent connections.
func WithMaxConns(n int) Option {
	return func(s *Server) {
		s.maxConns = n
	}
}

// New creates a Server with sensible defaults and applies options.
func New(host string, opts ...Option) *Server {
	srv := &Server{
		host:     host,
		port:     8080,           // default port
		timeout:  30 * time.Second, // default timeout
		maxConns: 100,            // default max connections
		logger:   slog.Default(),
	}
	for _, opt := range opts {
		opt(srv)
	}
	return srv
}

呼び出し側はデフォルト値と異なる部分のみを指定すればよい。新しいオプションを追加しても既存の呼び出し箇所には影響しない。このパターンはgoogle.golang.org/grpcgo.uber.org/zapなどのプロダクションライブラリでも採用されている。

Strategyパターン:インターフェースによる多態性

Strategyパターンは、交換可能なアルゴリズムを共通のインターフェースの背後にカプセル化する。Goでは、抽象クラスや継承チェーンを必要とせず、インターフェースベースのポリモーフィズムで直接表現できる。

決済処理システムがこのパターンの好例である。各決済方法が同一のインターフェースを実装し、チェックアウトサービスが実行時にストラテジーを選択する。

payment.gogo
package payment

import "fmt"

// Processor defines the strategy interface.
type Processor interface {
	Pay(amount float64) (string, error)
}

// CreditCard implements Processor for card payments.
type CreditCard struct {
	CardNumber string
	Expiry     string
}

func (c *CreditCard) Pay(amount float64) (string, error) {
	// Charge the card via payment gateway
	return fmt.Sprintf("charged %.2f to card ending %s", amount, c.CardNumber[len(c.CardNumber)-4:]), nil
}

// BankTransfer implements Processor for wire transfers.
type BankTransfer struct {
	IBAN string
}

func (b *BankTransfer) Pay(amount float64) (string, error) {
	return fmt.Sprintf("initiated transfer of %.2f to %s", amount, b.IBAN), nil
}

// Checkout processes a payment using the given strategy.
func Checkout(p Processor, amount float64) error {
	receipt, err := p.Pay(amount)
	if err != nil {
		return fmt.Errorf("payment failed: %w", err)
	}
	fmt.Println(receipt)
	return nil
}

Goのインターフェースは暗黙的に充足される。implementsキーワードは不要で、Pay(float64) (string, error)メソッドを持つ型であればProcessorとして使用できる。これにより結合度が低くなり、テストも容易になる。モックのProcessorを渡すだけで予測可能な結果を返すテストが書ける。

ファクトリ関数とコンストラクタパターン

Goにはコンストラクタが存在しない。その代わりにNewまたはNewXxxと命名されたファクトリ関数が初期化済みの構造体を返す。ファクトリ関数は、ゼロ値の初期化では保証できない不変条件を強制するために使用される。

面接準備においてこの区別は重要である。ファクトリ関数が必要な場合と、ゼロ値がそのまま有用な場合を正しく判断できる能力が求められる。

connpool.gogo
package pool

import (
	"errors"
	"sync"
)

// ConnPool manages a pool of reusable connections.
type ConnPool struct {
	mu       sync.Mutex
	conns    []Conn
	maxSize  int
}

// Conn represents a database connection.
type Conn struct {
	ID     int
	Active bool
}

// NewConnPool validates parameters and returns an initialized pool.
func NewConnPool(maxSize int) (*ConnPool, error) {
	if maxSize <= 0 {
		return nil, errors.New("pool: maxSize must be positive")
	}
	return &ConnPool{
		conns:   make([]Conn, 0, maxSize),
		maxSize: maxSize,
	}, nil
}

// Acquire returns a connection from the pool.
func (p *ConnPool) Acquire() (*Conn, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	for i := range p.conns {
		if !p.conns[i].Active {
			p.conns[i].Active = true
			return &p.conns[i], nil
		}
	}
	if len(p.conns) >= p.maxSize {
		return nil, errors.New("pool: no available connections")
	}
	c := Conn{ID: len(p.conns) + 1, Active: true}
	p.conns = append(p.conns, c)
	return &p.conns[len(p.conns)-1], nil
}

ファクトリ関数NewConnPoolmaxSizeパラメータを検証し、スライスを事前確保する。構造体を直接初期化(&ConnPool{})すると検証がスキップされ、実行時にパニックを引き起こす可能性がある。このパターンはFunctional Optionsと組み合わせることで、より複雑な設定シナリオにも対応できる。

Goの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

Observerパターン:チャネルとゴルーチンによる実装

Observerパターンは、状態が変化した際に複数の購読者に通知を送る。JavaやC#ではイベントリスナーとコールバックの登録が必要になるが、Goではチャネルというよりイディオマティックな手段がある。各購読者は専用のチャネルでイベントを受信し、ゴルーチンが並行的にイベントを配信する。

このアプローチはGoの並行処理プリミティブを直接活用し、コールバックの複雑な連鎖を回避できる。

eventbus.gogo
package events

import "sync"

// Event carries a topic and payload.
type Event struct {
	Topic   string
	Payload any
}

// Bus manages subscriptions and event dispatch.
type Bus struct {
	mu          sync.RWMutex
	subscribers map[string][]chan Event
}

// NewBus creates an event bus.
func NewBus() *Bus {
	return &Bus{
		subscribers: make(map[string][]chan Event),
	}
}

// Subscribe returns a channel that receives events for a topic.
func (b *Bus) Subscribe(topic string) <-chan Event {
	ch := make(chan Event, 16) // buffered to avoid blocking publisher
	b.mu.Lock()
	b.subscribers[topic] = append(b.subscribers[topic], ch)
	b.mu.Unlock()
	return ch
}

// Publish sends an event to all subscribers of the topic.
func (b *Bus) Publish(topic string, payload any) {
	b.mu.RLock()
	defer b.mu.RUnlock()
	for _, ch := range b.subscribers[topic] {
		select {
		case ch <- Event{Topic: topic, Payload: payload}:
		default:
			// subscriber too slow, drop event
		}
	}
}

select文にdefaultケースを設けることで、応答が遅い購読者がバス全体をブロックするのを防いでいる。プロダクション環境では、contextベースのキャンセル機構とUnsubscribeメソッドを追加して実装を完成させる必要がある。

Middlewareパターン:HTTPリクエストパイプライン

ミドルウェアチェーンはGoのHTTPサーバーの中核をなす。このパターンはhttp.Handlerをラップして、ロギング、認証、レート制限などの追加機能を、ハンドラ自体を変更することなく付加する。標準ライブラリのhttp.Handlerインターフェースにより、この構成は極めて容易である。

middleware.gogo
package middleware

import (
	"log/slog"
	"net/http"
	"time"
)

// Logging records request duration and status.
func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		wrapped := &statusWriter{ResponseWriter: w, status: 200}
		next.ServeHTTP(wrapped, r)
		slog.Info("request",
			"method", r.Method,
			"path", r.URL.Path,
			"status", wrapped.status,
			"duration", time.Since(start),
		)
	})
}

// statusWriter captures the HTTP status code.
type statusWriter struct {
	http.ResponseWriter
	status int
}

func (w *statusWriter) WriteHeader(code int) {
	w.status = code
	w.ResponseWriter.WriteHeader(code)
}

// Chain applies middleware in order: first listed = outermost.
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
	for i := len(mw) - 1; i >= 0; i-- {
		h = mw[i](h)
	}
	return h
}

Chain関数はミドルウェアを宣言順に適用する。各ミドルウェアが次のハンドラをラップしてパイプラインを形成する。このパターンはchiなどの人気ルーターでも同一で、func(http.Handler) http.Handler型のミドルウェアを受け付ける。

構造体埋め込みによるコンポジション

Goは継承をサポートしていない。代わりに、構造体埋め込み(struct embedding)を使って内部型のメソッドを外部型に昇格させ、密結合なしにコードを再利用する。これは継承ではない。埋め込まれた型は埋め込み元の型について何も知らず、仮想ディスパッチも発生しない。

この区別を理解することはGoの面接において極めて重要である。埋め込みと継承を混同する候補者は、Goの型システムへの理解が浅いと判断される。

models.gogo
package models

import "time"

// Timestamps provides common audit fields.
type Timestamps struct {
	CreatedAt time.Time
	UpdatedAt time.Time
}

// User embeds Timestamps to gain CreatedAt/UpdatedAt.
type User struct {
	Timestamps
	ID    int
	Email string
}

// Order also embeds Timestamps.
type Order struct {
	Timestamps
	ID     int
	UserID int
	Total  float64
}

UserOrderはどちらもCreatedAtUpdatedAtフィールドに直接アクセスできる。Timestampsに定義されたメソッドも同様に昇格される。重要な規則として、埋め込みは委譲を提供するものであり、代替ではない。UserTimestampsではなく、Timestampsを持っている。

Goデザインパターンに関する面接質問

Goの面接におけるデザインパターンの質問は、候補者がクラシカルなパターンをGoの型システムに適応できるかどうかを試すものである。以下は、技術スクリーニングやオンサイト面接で頻出する質問である。

Q: ファクトリ関数がパニックではなくエラーを返すべき場面はどのような場合か?

ファクトリ関数は、検証がランタイム入力(ユーザー提供の設定、環境変数、外部データ)に依存する場合にエラーを返すべきである。パニックはプログラマのエラー、つまりAPIの契約上禁止されているnilポインタを渡すなど、バグを示す状況に限定される。標準ライブラリもこの慣習に従っている。os.Openはエラーを返し、regexp.MustCompileはコンパイル時定数のパターンを想定しているためパニックする。

Q: Functional Optionsパターンはどのようにしてアプリケーションの発展を改善するのか?

新しいWithXxx関数の追加は後方互換性のある変更である。既存の呼び出し側はそのまま動作し、修正の必要がない。これは設定構造体(必須フィールドの追加で全ての呼び出し箇所が壊れる)や位置引数(引数の順序変更がバグを引き起こす)とは対照的である。

Q: GoのインターフェースがJavaやC#のインターフェースと異なる点は何か?

Goのインターフェースは暗黙的に充足される。型が必要なメソッドを持つだけでインターフェースを実装したことになり、implements宣言は不要である。これにより「インターフェースを受け取り、構造体を返す」という原則が成立する。つまり、実装側ではなく呼び出し側で狭いインターフェースを定義する。その結果、結合度が低くテスタビリティが高いコードが実現される。

面接のポイント

面接官は候補者に、具体的な依存をインターフェースにリファクタリングするよう求めることが多い。テストされるのは、必要最小限のメソッドセットを特定し、実装側ではなく利用側でインターフェースを抽出できるかどうかである。

Q: チャネルベースのObserverとコールバックベースのObserverの違いは何か?

チャネルはパブリッシャーとサブスクライバーを時間的にも空間的にも分離する。パブリッシャーはサブスクライバーの関数への参照を保持せず、チャネルに送信するだけである。サブスクライバーはバッファ付きチャネルを使って自分のペースでイベントを処理できる。select文により、タイムアウト処理、contextによるキャンセル、複数のイベントソースの多重化が可能になる。これらはコールバックでは得られない機能である。

よくある落とし穴

サブスクライバーチャネルを閉じ忘れるとゴルーチンリークが発生する。すべてのSubscribeには、チャネルを閉じてバスから削除する対応するUnsubscribeが必要である。

Goの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

まとめ

  • Functional Optionsパターンは、Builderや設定構造体を、既存の呼び出し側を壊さないクリーンで拡張可能なAPIに置き換える
  • GoのStrategyパターンはインターフェースベースのポリモーフィズムに対応する。インターフェースの定義は実装側ではなく利用側で行う
  • ファクトリ関数はゼロ値初期化では保証できない不変条件を強制する。ランタイム入力にはエラーを返し、パニックはプログラマのバグにのみ使用する
  • チャネルベースのObserverはゴルーチンとselectを活用し、コールバックチェーンなしに並行的で疎結合なイベント配信を実現する
  • HTTPミドルウェアチェーンはfunc(http.Handler) http.Handlerシグネチャを通じて構成され、標準ライブラリとサードパーティルーター全体で共通している
  • 構造体埋め込みは委譲を提供するものであり、継承ではない。この区別を理解することが、Goを深く理解する開発者とJavaパターンをそのまま移植する開発者を分ける
  • 面接での成功には、Gang of Fourの定義の暗記ではなく、パターンをGoの型システムにイディオマティックに適応させる能力が求められる

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#go
#design-patterns
#interview
#best-practices

共有

関連記事