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.