The original version of this post had two paragraphs and a Dog.Speak() example. That covers the syntax but misses the point entirely. The factory pattern in Go is not about creating animals. It is about writing code where the calling layer does not need to know or care which concrete implementation it receives.
The Problem Without a Factory#
Consider a UserService that directly instantiates a PostgreSQL storage layer:
package user
type UserService struct {
db *PostgresDB // concrete type, not an interface
}
func NewUserService() *UserService {
db := &PostgresDB{
Host: "localhost",
Port: 5432,
Database: "users",
}
db.Connect()
return &UserService{db: db}
}This is problematic for three reasons:
- You cannot test
UserServicewithout a running Postgres instance - You cannot swap Postgres for SQLite in development without touching
UserService - The service owns the database connection lifecycle – a responsibility it should not have
The Factory Pattern: Interface-Based Construction#
Define an interface that expresses what storage can do, then write a factory that returns the right implementation based on configuration:
package storage
import "context"
// Storage is the interface the rest of the codebase depends on.
// It never mentions Postgres, SQLite, or any concrete backend.
type Storage interface {
GetUser(ctx context.Context, id int64) (*User, error)
CreateUser(ctx context.Context, u *User) error
DeleteUser(ctx context.Context, id int64) error
}
type Config struct {
Type string // "postgres", "sqlite", "memory"
DSN string // connection string for postgres/sqlite
}
// NewStorage is the factory function.
// The caller gets a Storage interface and never sees the concrete type.
func NewStorage(cfg Config) (Storage, error) {
switch cfg.Type {
case "postgres":
return newPostgresStorage(cfg.DSN)
case "sqlite":
return newSQLiteStorage(cfg.DSN)
case "memory":
return newMemoryStorage(), nil
default:
return nil, fmt.Errorf("unknown storage type: %q", cfg.Type)
}
}Using It for Testing#
The in-memory implementation is not a toy. It is the key that makes unit tests fast and self-contained:
package storage
import (
"context"
"fmt"
"sync"
)
type memoryStorage struct {
mu sync.RWMutex
users map[int64]*User
seq int64
}
func newMemoryStorage() *memoryStorage {
return &memoryStorage{users: make(map[int64]*User)}
}
func (m *memoryStorage) GetUser(ctx context.Context, id int64) (*User, error) {
m.mu.RLock()
defer m.mu.RUnlock()
u, ok := m.users[id]
if !ok {
return nil, fmt.Errorf("user %d not found", id)
}
return u, nil
}
func (m *memoryStorage) CreateUser(ctx context.Context, u *User) error {
m.mu.Lock()
defer m.mu.Unlock()
m.seq++
u.ID = m.seq
m.users[m.seq] = u
return nil
}
func (m *memoryStorage) DeleteUser(ctx context.Context, id int64) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.users, id)
return nil
}Now your unit tests never touch a database:
package user_test
import (
"context"
"testing"
"github.com/example/app/storage"
"github.com/example/app/user"
)
func TestCreateUser(t *testing.T) {
// In-memory storage: no database required, no cleanup needed
store, err := storage.NewStorage(storage.Config{Type: "memory"})
if err != nil {
t.Fatal(err)
}
svc := user.NewUserService(store)
created, err := svc.CreateUser(context.Background(), "alice", "alice@example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if created.ID == 0 {
t.Error("expected non-zero ID")
}
}Factory with Dependency Injection#
The service itself accepts the interface in its constructor – it does not call the factory:
package user
import (
"context"
"fmt"
"github.com/example/app/storage"
)
type UserService struct {
store storage.Storage // interface, not concrete type
}
// NewUserService accepts any Storage implementation.
// The factory decides which one to use; the service does not care.
func NewUserService(store storage.Storage) *UserService {
return &UserService{store: store}
}
func (s *UserService) CreateUser(ctx context.Context, name, email string) (*storage.User, error) {
u := &storage.User{Name: name, Email: email}
if err := s.store.CreateUser(ctx, u); err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
return u, nil
}At the application entrypoint, wire everything together:
func main() {
storageType := os.Getenv("STORAGE_TYPE")
if storageType == "" {
storageType = "postgres"
}
store, err := storage.NewStorage(storage.Config{
Type: storageType,
DSN: os.Getenv("DATABASE_URL"),
})
if err != nil {
log.Fatal(err)
}
userSvc := user.NewUserService(store)
// ... wire up HTTP handlers, etc.
}Set STORAGE_TYPE=memory in CI. Set STORAGE_TYPE=postgres in production. The service code is identical in both environments.
Notification Sender: A Real-World Factory#
The same pattern applies to any backend that varies by environment:
package notify
import "context"
type Sender interface {
Send(ctx context.Context, to, subject, body string) error
}
type Config struct {
Type string // "smtp", "log", "noop"
SMTPHost string
SMTPPort int
From string
}
func NewSender(cfg Config) (Sender, error) {
switch cfg.Type {
case "smtp":
return newSMTPSender(cfg)
case "log":
// Logs to stdout -- useful in development and CI
return &logSender{}, nil
case "noop":
// Silently discards all messages -- useful in tests
return &noopSender{}, nil
default:
return nil, fmt.Errorf("unknown sender type: %q", cfg.Type)
}
}Production uses smtp. Integration tests use log so you can see what would be sent. Unit tests use noop to skip email entirely. No environment variable gymnastics, no mocking frameworks.
The wire Tool for Large Codebases#
When your dependency graph grows beyond a handful of services, Google Wire generates the wiring code at compile time. You define provider functions (your factories) and Wire generates a wire_gen.go that builds the complete object graph with no runtime reflection.
For most applications, manual wiring in main.go is cleaner and more readable. Reach for Wire when the wiring code itself becomes a maintenance burden.
Common mistakes and how to avoid them
Returning concrete types instead of interfaces
If your factory returns *PostgresStorage instead of Storage, every caller is coupled to Postgres. The whole point of the factory is to return the interface. The caller should never import the concrete package.
Factory that does too much work
A factory function should construct and configure. It should not make network calls, run migrations, or seed data. Keep construction fast and move initialization work to explicit Init() or Start() methods that callers control.
No “noop” or in-memory implementation for testing If the only implementations are “real backends” (Postgres, SMTP, S3), your tests must mock at the HTTP level or spin up containers for every test run. Always write a minimal in-memory or noop implementation. It costs 30 lines and pays dividends in test speed.
Returning nil from the default case
Returning nil, nil from the default case of the switch in a factory means callers get a nil interface and a nil error. The call site will panic when it tries to use the returned value. Always return an explicit error when the requested type is unknown.
If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.