Factory Method in Go: Interface-Based Factories

The Factory Method pattern encapsulates object creation logic, letting you create objects without specifying their exact concrete types. In Go, this pattern feels natural because of how interfaces...

Key Insights

  • Go’s implicit interface implementation makes the Factory Method pattern cleaner than in languages requiring explicit interface declarations—your factories return interfaces, and any struct with matching methods automatically satisfies them.
  • Factory functions in Go should return interfaces, not concrete types, enabling you to swap implementations without changing client code and making testing significantly easier.
  • Keep your factories simple: a function returning an interface is often all you need. Avoid over-engineering with abstract factory hierarchies that add complexity without clear benefits.

Introduction to Factory Method in Go

The Factory Method pattern encapsulates object creation logic, letting you create objects without specifying their exact concrete types. In Go, this pattern feels natural because of how interfaces work—you define behavior, not inheritance hierarchies.

Consider the difference between direct instantiation and a factory approach:

// Direct instantiation - client knows about concrete type
notifier := &EmailNotifier{
    smtpHost: "smtp.example.com",
    smtpPort: 587,
    username: "user@example.com",
    password: "secret",
}

// Factory approach - client only knows about the interface
notifier, err := NewNotifier("email", config)
if err != nil {
    log.Fatal(err)
}

The factory approach decouples the client from concrete implementations. Your application code works with a Notifier interface, remaining blissfully ignorant of whether it’s sending emails, SMS messages, or Slack notifications. This separation becomes powerful when you need to add new notification types or swap implementations based on configuration.

Defining the Product Interface

Go interfaces should be small and focused. The standard library exemplifies this with interfaces like io.Reader (one method) and io.Writer (one method). Your factory’s product interface should follow the same principle.

package notify

// Notifier defines the contract for sending notifications.
type Notifier interface {
    Send(ctx context.Context, message string) error
}

// For notifiers that support batch operations, define a separate interface.
type BatchNotifier interface {
    Notifier
    SendBatch(ctx context.Context, messages []string) error
}

Notice we’re not cramming every possible notification operation into a single interface. If some notifiers support batch sending and others don’t, create a separate interface. Clients that need batch capabilities can type-assert or accept BatchNotifier directly.

This approach follows the Interface Segregation Principle: clients shouldn’t depend on methods they don’t use. It also makes implementing new notifier types easier—you only need to implement the methods that make sense for your concrete type.

Implementing Concrete Products

With the interface defined, let’s create concrete implementations. Each struct implements the Notifier interface by providing a Send method with the correct signature.

package notify

import (
    "context"
    "fmt"
    "net/smtp"
)

// EmailNotifier sends notifications via email.
type EmailNotifier struct {
    smtpHost string
    smtpPort int
    username string
    password string
    from     string
    to       []string
}

func (e *EmailNotifier) Send(ctx context.Context, message string) error {
    addr := fmt.Sprintf("%s:%d", e.smtpHost, e.smtpPort)
    auth := smtp.PlainAuth("", e.username, e.password, e.smtpHost)
    
    msg := []byte(fmt.Sprintf("Subject: Notification\r\n\r\n%s", message))
    return smtp.SendMail(addr, auth, e.from, e.to, msg)
}

// SMSNotifier sends notifications via SMS using Twilio.
type SMSNotifier struct {
    accountSID string
    authToken  string
    from       string
    to         string
    client     *http.Client
}

func (s *SMSNotifier) Send(ctx context.Context, message string) error {
    // Twilio API implementation
    endpoint := fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", s.accountSID)
    
    data := url.Values{}
    data.Set("To", s.to)
    data.Set("From", s.from)
    data.Set("Body", message)
    
    req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(data.Encode()))
    if err != nil {
        return fmt.Errorf("creating request: %w", err)
    }
    req.SetBasicAuth(s.accountSID, s.authToken)
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    
    resp, err := s.client.Do(req)
    if err != nil {
        return fmt.Errorf("sending SMS: %w", err)
    }
    defer resp.Body.Close()
    
    if resp.StatusCode >= 400 {
        return fmt.Errorf("twilio returned status %d", resp.StatusCode)
    }
    return nil
}

// SlackNotifier sends notifications to a Slack channel.
type SlackNotifier struct {
    webhookURL string
    channel    string
    client     *http.Client
}

func (s *SlackNotifier) Send(ctx context.Context, message string) error {
    payload := map[string]string{
        "channel": s.channel,
        "text":    message,
    }
    
    body, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("marshaling payload: %w", err)
    }
    
    req, err := http.NewRequestWithContext(ctx, "POST", s.webhookURL, bytes.NewReader(body))
    if err != nil {
        return fmt.Errorf("creating request: %w", err)
    }
    req.Header.Set("Content-Type", "application/json")
    
    resp, err := s.client.Do(req)
    if err != nil {
        return fmt.Errorf("sending to Slack: %w", err)
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("slack returned status %d", resp.StatusCode)
    }
    return nil
}

Each implementation handles its own complexity internally. The email notifier deals with SMTP, the SMS notifier talks to Twilio’s API, and the Slack notifier posts to a webhook. Client code doesn’t need to know any of these details.

Building the Factory Function

The factory function is where we centralize the creation logic. It returns the interface type, hiding concrete implementations from callers.

package notify

import (
    "fmt"
    "net/http"
)

// NotifierType represents the type of notifier to create.
type NotifierType string

const (
    NotifierTypeEmail NotifierType = "email"
    NotifierTypeSMS   NotifierType = "sms"
    NotifierTypeSlack NotifierType = "slack"
)

// NewNotifier creates a Notifier based on the specified type and configuration.
func NewNotifier(notifierType NotifierType, cfg Config) (Notifier, error) {
    switch notifierType {
    case NotifierTypeEmail:
        if cfg.Email == nil {
            return nil, fmt.Errorf("email configuration required")
        }
        return &EmailNotifier{
            smtpHost: cfg.Email.SMTPHost,
            smtpPort: cfg.Email.SMTPPort,
            username: cfg.Email.Username,
            password: cfg.Email.Password,
            from:     cfg.Email.From,
            to:       cfg.Email.To,
        }, nil
        
    case NotifierTypeSMS:
        if cfg.SMS == nil {
            return nil, fmt.Errorf("SMS configuration required")
        }
        return &SMSNotifier{
            accountSID: cfg.SMS.AccountSID,
            authToken:  cfg.SMS.AuthToken,
            from:       cfg.SMS.From,
            to:         cfg.SMS.To,
            client:     &http.Client{Timeout: 10 * time.Second},
        }, nil
        
    case NotifierTypeSlack:
        if cfg.Slack == nil {
            return nil, fmt.Errorf("Slack configuration required")
        }
        return &SlackNotifier{
            webhookURL: cfg.Slack.WebhookURL,
            channel:    cfg.Slack.Channel,
            client:     &http.Client{Timeout: 10 * time.Second},
        }, nil
        
    default:
        return nil, fmt.Errorf("unknown notifier type: %s", notifierType)
    }
}

For applications with many notifier types, a registry-based approach scales better:

package notify

var registry = make(map[NotifierType]func(Config) (Notifier, error))

// Register adds a notifier constructor to the registry.
func Register(notifierType NotifierType, constructor func(Config) (Notifier, error)) {
    registry[notifierType] = constructor
}

// NewNotifier creates a notifier using the registry.
func NewNotifier(notifierType NotifierType, cfg Config) (Notifier, error) {
    constructor, ok := registry[notifierType]
    if !ok {
        return nil, fmt.Errorf("unknown notifier type: %s", notifierType)
    }
    return constructor(cfg)
}

func init() {
    Register(NotifierTypeEmail, newEmailNotifier)
    Register(NotifierTypeSMS, newSMSNotifier)
    Register(NotifierTypeSlack, newSlackNotifier)
}

Factory Method with Configuration

Real-world factories need configuration. A dedicated config struct keeps the factory signature clean and makes it easy to add new options.

package notify

// Config holds configuration for all notifier types.
type Config struct {
    Email *EmailConfig
    SMS   *SMSConfig
    Slack *SlackConfig
}

type EmailConfig struct {
    SMTPHost string
    SMTPPort int
    Username string
    Password string
    From     string
    To       []string
}

type SMSConfig struct {
    AccountSID string
    AuthToken  string
    From       string
    To         string
}

type SlackConfig struct {
    WebhookURL string
    Channel    string
}

For more flexibility, functional options let callers customize behavior without bloating the config struct:

package notify

type options struct {
    httpClient *http.Client
    timeout    time.Duration
    retries    int
}

type Option func(*options)

func WithHTTPClient(client *http.Client) Option {
    return func(o *options) {
        o.httpClient = client
    }
}

func WithTimeout(timeout time.Duration) Option {
    return func(o *options) {
        o.timeout = timeout
    }
}

func WithRetries(retries int) Option {
    return func(o *options) {
        o.retries = retries
    }
}

func NewNotifier(notifierType NotifierType, cfg Config, opts ...Option) (Notifier, error) {
    o := &options{
        httpClient: &http.Client{},
        timeout:    10 * time.Second,
        retries:    3,
    }
    for _, opt := range opts {
        opt(o)
    }
    // Use o.httpClient, o.timeout, etc. when creating notifiers
    // ...
}

Testing Factory-Created Objects

Factories dramatically improve testability. Since your code depends on interfaces, you can inject mock implementations during tests.

package notify_test

import (
    "context"
    "testing"
    
    "yourapp/notify"
)

// MockNotifier records calls for verification.
type MockNotifier struct {
    SendFunc   func(ctx context.Context, message string) error
    SendCalls  []string
}

func (m *MockNotifier) Send(ctx context.Context, message string) error {
    m.SendCalls = append(m.SendCalls, message)
    if m.SendFunc != nil {
        return m.SendFunc(ctx, message)
    }
    return nil
}

func TestOrderService_NotifiesOnCompletion(t *testing.T) {
    mock := &MockNotifier{}
    svc := NewOrderService(mock)
    
    err := svc.CompleteOrder(context.Background(), "order-123")
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    
    if len(mock.SendCalls) != 1 {
        t.Errorf("expected 1 notification, got %d", len(mock.SendCalls))
    }
    if !strings.Contains(mock.SendCalls[0], "order-123") {
        t.Errorf("notification should contain order ID")
    }
}

Table-driven tests work well for validating factory behavior:

func TestNewNotifier(t *testing.T) {
    tests := []struct {
        name         string
        notifierType notify.NotifierType
        config       notify.Config
        wantErr      bool
    }{
        {
            name:         "valid email notifier",
            notifierType: notify.NotifierTypeEmail,
            config:       notify.Config{Email: &notify.EmailConfig{SMTPHost: "localhost"}},
            wantErr:      false,
        },
        {
            name:         "email without config",
            notifierType: notify.NotifierTypeEmail,
            config:       notify.Config{},
            wantErr:      true,
        },
        {
            name:         "unknown type",
            notifierType: "carrier-pigeon",
            config:       notify.Config{},
            wantErr:      true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := notify.NewNotifier(tt.notifierType, tt.config)
            if (err != nil) != tt.wantErr {
                t.Errorf("NewNotifier() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

When to Use (and Avoid) Factories in Go

Use factories when:

  • Object creation requires complex initialization with validation, default values, or derived fields
  • You need runtime type selection based on configuration or user input
  • You want to hide implementation details and have clients depend only on interfaces
  • Testing requires swapping implementations without changing production code

Avoid factories when:

  • Direct struct initialization is clear and simple&User{Name: "Alice"} doesn’t need a factory
  • There’s only one implementation and you don’t anticipate others
  • You’re adding abstraction for abstraction’s sake—Go rewards simplicity

A good rule: start with direct initialization. Introduce a factory when you feel pain from duplicated creation logic, need to swap implementations, or want to enforce validation during construction. Don’t preemptively add factories because “you might need them later.”

Go’s philosophy favors explicit, straightforward code. A well-designed factory simplifies your codebase by centralizing creation logic. An over-engineered factory adds indirection without benefit. Know the difference.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.