Abstract Factory in Go: Platform-Independent Creation

Abstract Factory solves a specific problem: creating families of related objects without hardcoding their concrete types. When your application needs to work across Windows, macOS, and Linux—or AWS,...

Key Insights

  • Abstract Factory excels when your application must support multiple platforms or environments with families of related objects that must work together consistently.
  • Go’s implicit interface satisfaction makes Abstract Factory implementations cleaner than in languages requiring explicit interface declarations—factories and products just need matching method signatures.
  • Choose runtime factory selection for deployment flexibility, build tags for performance-critical code, and avoid the pattern entirely when you’re only dealing with a single product type.

Introduction to Abstract Factory Pattern

Abstract Factory solves a specific problem: creating families of related objects without hardcoding their concrete types. When your application needs to work across Windows, macOS, and Linux—or AWS, GCP, and Azure—you don’t want platform checks scattered throughout your codebase.

The pattern gives you a single interface for creating related objects. Swap the factory, and you swap the entire family of implementations. Your application code never knows which concrete types it’s working with.

This matters for cross-platform development because platforms rarely differ in just one way. A Windows application doesn’t just have different buttons—it has different checkboxes, dialogs, and menus that all need to look and behave consistently with each other. Abstract Factory ensures you get a coherent set of components, not a Frankenstein mix of platform implementations.

The Problem: Platform-Specific Dependencies

Here’s what happens without Abstract Factory. You start with a simple conditional, then another, then your codebase becomes a maze of platform checks:

package ui

import "runtime"

func CreateButton(label string) Button {
    switch runtime.GOOS {
    case "windows":
        return &WindowsButton{label: label}
    case "darwin":
        return &MacButton{label: label}
    default:
        return &LinuxButton{label: label}
    }
}

func CreateCheckbox(label string) Checkbox {
    switch runtime.GOOS {
    case "windows":
        return &WindowsCheckbox{label: label}
    case "darwin":
        return &MacCheckbox{label: label}
    default:
        return &LinuxCheckbox{label: label}
    }
}

func CreateDialog(title string) Dialog {
    switch runtime.GOOS {
    case "windows":
        return &WindowsDialog{title: title}
    case "darwin":
        return &MacDialog{title: title}
    default:
        return &LinuxDialog{title: title}
    }
}

This approach has three problems. First, adding a new platform means touching every creation function. Second, testing becomes painful—you can’t easily inject mock implementations. Third, nothing enforces consistency. A bug could give you a Windows button alongside a Mac checkbox.

The situation worsens in real applications where these creation calls are scattered across dozens of files. Every new platform means a codebase-wide search for runtime.GOOS.

Abstract Factory Structure in Go

Abstract Factory introduces two layers of abstraction: the factory interface and the product interfaces. In Go, we define these as interfaces that concrete implementations will satisfy:

package ui

// Product interfaces define what each UI component can do
type Button interface {
    Render() string
    OnClick(handler func())
}

type Checkbox interface {
    Render() string
    IsChecked() bool
    SetChecked(checked bool)
}

type Dialog interface {
    Render() string
    Show()
    Close()
}

// Factory interface defines how to create a family of products
type UIFactory interface {
    CreateButton(label string) Button
    CreateCheckbox(label string) Checkbox
    CreateDialog(title string) Dialog
}

Notice that Go doesn’t require explicit interface declarations on implementing types. Any struct with matching methods automatically satisfies these interfaces. This reduces boilerplate compared to languages like Java where you’d need implements UIFactory on every concrete factory.

The key insight is that UIFactory returns interface types, not concrete types. Client code works exclusively with Button, Checkbox, and Dialog—never with WindowsButton or MacCheckbox.

Implementing Platform-Specific Factories

Each platform gets its own factory implementation that creates platform-appropriate components:

package ui

// Windows implementations
type WindowsButton struct {
    label   string
    handler func()
}

func (b *WindowsButton) Render() string {
    return "[=====" + b.label + "=====]" // Windows-style button
}

func (b *WindowsButton) OnClick(handler func()) {
    b.handler = handler
}

type WindowsCheckbox struct {
    label   string
    checked bool
}

func (c *WindowsCheckbox) Render() string {
    mark := " "
    if c.checked {
        mark = "X"
    }
    return "[" + mark + "] " + c.label
}

func (c *WindowsCheckbox) IsChecked() bool      { return c.checked }
func (c *WindowsCheckbox) SetChecked(val bool)  { c.checked = val }

type WindowsDialog struct {
    title   string
    visible bool
}

func (d *WindowsDialog) Render() string {
    return "+--" + d.title + "--+\n|              |\n+--------------+"
}

func (d *WindowsDialog) Show()  { d.visible = true }
func (d *WindowsDialog) Close() { d.visible = false }

// Windows factory creates Windows-specific components
type WindowsFactory struct{}

func (f *WindowsFactory) CreateButton(label string) Button {
    return &WindowsButton{label: label}
}

func (f *WindowsFactory) CreateCheckbox(label string) Checkbox {
    return &WindowsCheckbox{label: label}
}

func (f *WindowsFactory) CreateDialog(title string) Dialog {
    return &WindowsDialog{title: title}
}

Now the Mac implementation:

package ui

// Mac implementations
type MacButton struct {
    label   string
    handler func()
}

func (b *MacButton) Render() string {
    return "( " + b.label + " )" // Mac-style rounded button
}

func (b *MacButton) OnClick(handler func()) {
    b.handler = handler
}

type MacCheckbox struct {
    label   string
    checked bool
}

func (c *MacCheckbox) Render() string {
    mark := "○"
    if c.checked {
        mark = "●"
    }
    return mark + " " + c.label
}

func (c *MacCheckbox) IsChecked() bool     { return c.checked }
func (c *MacCheckbox) SetChecked(val bool) { c.checked = val }

type MacDialog struct {
    title   string
    visible bool
}

func (d *MacDialog) Render() string {
    return "╭──" + d.title + "──╮\n│              │\n╰──────────────╯"
}

func (d *MacDialog) Show()  { d.visible = true }
func (d *MacDialog) Close() { d.visible = false }

// Mac factory creates Mac-specific components
type MacFactory struct{}

func (f *MacFactory) CreateButton(label string) Button {
    return &MacButton{label: label}
}

func (f *MacFactory) CreateCheckbox(label string) Checkbox {
    return &MacCheckbox{label: label}
}

func (f *MacFactory) CreateDialog(title string) Dialog {
    return &MacDialog{title: title}
}

Adding a new platform—say, Linux—means creating one new factory and its products. No existing code changes.

Client Code and Factory Selection

Client code accepts the factory interface and remains blissfully ignorant of platforms:

package app

import "myapp/ui"

type Application struct {
    factory ui.UIFactory
}

func NewApplication(factory ui.UIFactory) *Application {
    return &Application{factory: factory}
}

func (a *Application) CreateSettingsPanel() string {
    dialog := a.factory.CreateDialog("Settings")
    saveBtn := a.factory.CreateButton("Save")
    cancelBtn := a.factory.CreateButton("Cancel")
    darkMode := a.factory.CreateCheckbox("Dark Mode")
    
    // Compose the panel - works identically regardless of platform
    return dialog.Render() + "\n" +
           darkMode.Render() + "\n" +
           saveBtn.Render() + " " + cancelBtn.Render()
}

Factory selection happens at the application boundary—typically in main() or a bootstrap function:

package main

import (
    "os"
    "runtime"
    
    "myapp/app"
    "myapp/ui"
)

func selectFactory() ui.UIFactory {
    // Allow override via environment variable for testing
    if platform := os.Getenv("UI_PLATFORM"); platform != "" {
        switch platform {
        case "windows":
            return &ui.WindowsFactory{}
        case "mac":
            return &ui.MacFactory{}
        }
    }
    
    // Default to runtime detection
    switch runtime.GOOS {
    case "windows":
        return &ui.WindowsFactory{}
    case "darwin":
        return &ui.MacFactory{}
    default:
        return &ui.MacFactory{} // Fallback
    }
}

func main() {
    factory := selectFactory()
    application := app.NewApplication(factory)
    
    panel := application.CreateSettingsPanel()
    println(panel)
}

Testing becomes trivial—inject a mock factory that returns mock products:

package app_test

type MockFactory struct{}
type MockButton struct{ Label string }

func (b *MockButton) Render() string           { return "mock:" + b.Label }
func (b *MockButton) OnClick(handler func())   {}

func (f *MockFactory) CreateButton(label string) ui.Button {
    return &MockButton{Label: label}
}
// ... other mock methods

func TestSettingsPanel(t *testing.T) {
    app := NewApplication(&MockFactory{})
    panel := app.CreateSettingsPanel()
    // Assert against predictable mock output
}

Go-Specific Considerations

Go offers build tags for compile-time platform selection. This eliminates runtime overhead and dead code:

// factory_windows.go
//go:build windows

package ui

func NewPlatformFactory() UIFactory {
    return &WindowsFactory{}
}
// factory_darwin.go
//go:build darwin

package ui

func NewPlatformFactory() UIFactory {
    return &MacFactory{}
}

Now main() simply calls ui.NewPlatformFactory() and the compiler includes only the relevant implementation.

Use build tags when:

  • You’re shipping separate binaries per platform anyway
  • Platform-specific code has different dependencies
  • Performance matters and you want dead code elimination

Use runtime selection when:

  • You need a single binary for multiple platforms
  • Configuration might override platform detection
  • You’re testing and need to swap implementations

Go’s implicit interface satisfaction also enables a functional alternative for simpler cases:

type FactoryFuncs struct {
    NewButton   func(label string) Button
    NewCheckbox func(label string) Checkbox
}

var WindowsFuncs = FactoryFuncs{
    NewButton:   func(l string) Button { return &WindowsButton{label: l} },
    NewCheckbox: func(l string) Checkbox { return &WindowsCheckbox{label: l} },
}

This works for smaller product families but becomes unwieldy as the family grows.

Trade-offs and When to Use

Abstract Factory shines when you have multiple related products that must vary together. It provides compile-time safety—you can’t accidentally mix platforms—and makes testing straightforward through dependency injection.

The drawbacks are real. Every new product type requires updating every factory interface and implementation. With three platforms and ten product types, you’re maintaining thirty concrete product types plus three factories. Interface explosion is a genuine concern.

Use Abstract Factory when:

  • You have families of related objects (not just one type)
  • Platform consistency matters (mixing implementations would be a bug)
  • You need to swap entire families at once
  • Testing requires injecting mock implementations

Skip it when:

  • You only have one product type (use simple Factory Method instead)
  • Platform differences are minimal (a few conditionals might be clearer)
  • The “family” won’t grow (over-engineering for two products is wasteful)

For cloud provider abstraction (AWS/GCP/Azure), Abstract Factory works well—storage, compute, and networking services form a natural family. For simple configuration differences, a strategy pattern or even plain conditionals might serve better.

The pattern’s value scales with complexity. Two products and two platforms? Probably overkill. Ten products and four platforms? Abstract Factory will save your sanity.

Liked this? There's more.

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