Facade Pattern in Go: Package-Level Facades

The facade pattern provides a simplified interface to a complex subsystem. Instead of forcing clients to understand and coordinate multiple components, you give them a single entry point that handles...

Key Insights

  • Go’s package system provides natural facade boundaries through exported identifiers and internal/ directories, making the facade pattern feel idiomatic rather than forced.
  • Package-level facades work best when they hide coordination complexity between subsystems while exposing a minimal, task-oriented API that clients actually need.
  • The choice between struct-based facades and package-level functions depends on whether you need multiple instances with different configurations or a single, application-wide abstraction.

The Facade Pattern in Go’s Context

The facade pattern provides a simplified interface to a complex subsystem. Instead of forcing clients to understand and coordinate multiple components, you give them a single entry point that handles the orchestration internally.

Go developers often implement facades without realizing it. Every time you design a package with a clean public API that hides messy implementation details, you’re building a facade. The language’s visibility rules—lowercase for private, uppercase for public—create natural boundaries that the facade pattern exploits.

Reach for an explicit facade when you find clients repeatedly coordinating the same set of operations across multiple packages, when you want to isolate subsystem changes from the rest of your codebase, or when you need to provide a simpler API for common use cases while keeping advanced functionality accessible.

Go Packages as Natural Facades

Go’s package system already enforces encapsulation. Only exported identifiers are visible outside the package, which means every package implicitly defines a facade through its public API.

The internal/ directory takes this further. Packages under internal/ can only be imported by code rooted at the parent of internal/. This lets you create subsystems that are completely hidden from external consumers.

// Project structure
myapp/
├── payment/
   ├── payment.go          // Public facade API
   ├── internal/
      ├── validator/      // Hidden from external packages
         └── validator.go
      ├── gateway/
         └── gateway.go
      └── ledger/
          └── ledger.go

The payment package exposes a clean API. The internal/ packages contain the real complexity—validation rules, gateway protocols, ledger operations—but external code never sees them. They can only interact through the facade.

// payment/internal/validator/validator.go
package validator

type CardValidator struct {
    luhnCheck bool
    expiryCheck bool
}

func (v *CardValidator) Validate(cardNumber string, expiry string) error {
    // Complex validation logic hidden from consumers
    if v.luhnCheck && !passesLuhn(cardNumber) {
        return ErrInvalidCardNumber
    }
    // ... more validation
    return nil
}

External packages import payment and use its simplified interface. They never import payment/internal/validator directly—Go’s compiler prevents it.

Designing a Package-Level Facade

Good facades emerge from understanding what clients actually need. Start by identifying the subsystems involved and their dependencies, then define the minimal API that serves real use cases.

// payment/payment.go
package payment

import (
    "context"
    "myapp/payment/internal/gateway"
    "myapp/payment/internal/ledger"
    "myapp/payment/internal/validator"
)

// Processor is the facade that coordinates payment subsystems
type Processor struct {
    validator *validator.CardValidator
    gateway   *gateway.Client
    ledger    *ledger.Store
}

// ProcessorOption configures the Processor
type ProcessorOption func(*Processor)

// WithGatewayTimeout sets the payment gateway timeout
func WithGatewayTimeout(d time.Duration) ProcessorOption {
    return func(p *Processor) {
        p.gateway.SetTimeout(d)
    }
}

// NewProcessor creates a configured payment processor
func NewProcessor(gatewayURL string, opts ...ProcessorOption) (*Processor, error) {
    p := &Processor{
        validator: validator.New(),
        gateway:   gateway.New(gatewayURL),
        ledger:    ledger.New(),
    }
    
    for _, opt := range opts {
        opt(p)
    }
    
    return p, nil
}

// Charge processes a payment, hiding all coordination complexity
func (p *Processor) Charge(ctx context.Context, req ChargeRequest) (*ChargeResult, error) {
    // Validate card details
    if err := p.validator.Validate(req.CardNumber, req.Expiry); err != nil {
        return nil, fmt.Errorf("validation failed: %w", err)
    }
    
    // Process through gateway
    txn, err := p.gateway.Authorize(ctx, req.Amount, req.CardNumber)
    if err != nil {
        return nil, fmt.Errorf("authorization failed: %w", err)
    }
    
    // Record in ledger
    if err := p.ledger.Record(ctx, txn); err != nil {
        // Attempt to void the authorization
        _ = p.gateway.Void(ctx, txn.ID)
        return nil, fmt.Errorf("ledger recording failed: %w", err)
    }
    
    return &ChargeResult{
        TransactionID: txn.ID,
        Status:        txn.Status,
    }, nil
}

Notice how Charge coordinates three subsystems but clients only see a single method call. The facade handles validation, gateway communication, ledger recording, and even compensating transactions on failure.

Implementation Patterns

You have two main approaches for package-level facades: struct-based and function-based.

Struct-based facades work well when you need multiple instances with different configurations or when the facade maintains state:

// Struct-based: multiple instances possible
processor1 := payment.NewProcessor("https://gateway1.example.com")
processor2 := payment.NewProcessor("https://gateway2.example.com")

Package-level functions suit singleton-style facades where you configure once at startup:

// payment/payment.go
package payment

var defaultProcessor *Processor

// Init configures the package-level facade
func Init(gatewayURL string, opts ...ProcessorOption) error {
    p, err := NewProcessor(gatewayURL, opts...)
    if err != nil {
        return err
    }
    defaultProcessor = p
    return nil
}

// Charge uses the default processor
func Charge(ctx context.Context, req ChargeRequest) (*ChargeResult, error) {
    if defaultProcessor == nil {
        return nil, ErrNotInitialized
    }
    return defaultProcessor.Charge(ctx, req)
}
// Usage in main.go
func main() {
    payment.Init("https://gateway.example.com")
    
    // Later, anywhere in the application
    result, err := payment.Charge(ctx, req)
}

The package-level approach reduces boilerplate but makes testing harder and hides dependencies. I prefer struct-based facades for most cases—they’re explicit about dependencies and easier to test.

Testing Facades and Their Subsystems

Test internal components in isolation, then test the facade as an integration point.

// payment/internal/validator/validator_test.go
package validator

func TestCardValidator_Validate(t *testing.T) {
    v := New()
    
    tests := []struct {
        name       string
        cardNumber string
        expiry     string
        wantErr    bool
    }{
        {"valid card", "4111111111111111", "12/25", false},
        {"invalid luhn", "4111111111111112", "12/25", true},
        {"expired", "4111111111111111", "01/20", true},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := v.Validate(tt.cardNumber, tt.expiry)
            if (err != nil) != tt.wantErr {
                t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

For facade-level tests, use interfaces to mock subsystems:

// payment/payment.go
type cardValidator interface {
    Validate(cardNumber, expiry string) error
}

type gatewayClient interface {
    Authorize(ctx context.Context, amount int64, card string) (*Transaction, error)
    Void(ctx context.Context, txnID string) error
}

// payment/payment_test.go
type mockGateway struct {
    authorizeFunc func(ctx context.Context, amount int64, card string) (*Transaction, error)
}

func (m *mockGateway) Authorize(ctx context.Context, amount int64, card string) (*Transaction, error) {
    return m.authorizeFunc(ctx, amount, card)
}

func TestProcessor_Charge_GatewayFailure(t *testing.T) {
    p := &Processor{
        validator: &mockValidator{},
        gateway: &mockGateway{
            authorizeFunc: func(ctx context.Context, amount int64, card string) (*Transaction, error) {
                return nil, errors.New("gateway timeout")
            },
        },
        ledger: &mockLedger{},
    }
    
    _, err := p.Charge(context.Background(), ChargeRequest{Amount: 1000})
    if err == nil {
        t.Error("expected error for gateway failure")
    }
}

Real-World Example: Database Access Facade

Here’s a complete storage package that hides connection pooling, query building, and caching:

// storage/storage.go
package storage

import (
    "context"
    "database/sql"
    "time"
    
    "myapp/storage/internal/cache"
    "myapp/storage/internal/query"
)

type Store struct {
    db    *sql.DB
    cache *cache.LRU
    qb    *query.Builder
}

type Config struct {
    DSN             string
    MaxOpenConns    int
    MaxIdleConns    int
    ConnMaxLifetime time.Duration
    CacheSize       int
}

func New(cfg Config) (*Store, error) {
    db, err := sql.Open("postgres", cfg.DSN)
    if err != nil {
        return nil, fmt.Errorf("opening database: %w", err)
    }
    
    db.SetMaxOpenConns(cfg.MaxOpenConns)
    db.SetMaxIdleConns(cfg.MaxIdleConns)
    db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
    
    if err := db.Ping(); err != nil {
        return nil, fmt.Errorf("pinging database: %w", err)
    }
    
    return &Store{
        db:    db,
        cache: cache.NewLRU(cfg.CacheSize),
        qb:    query.NewBuilder(),
    }, nil
}

// GetUser retrieves a user, checking cache first
func (s *Store) GetUser(ctx context.Context, id string) (*User, error) {
    // Check cache
    if cached, ok := s.cache.Get("user:" + id); ok {
        return cached.(*User), nil
    }
    
    // Build and execute query
    q := s.qb.Select("users").Where("id = ?", id).Build()
    
    var u User
    err := s.db.QueryRowContext(ctx, q.SQL, q.Args...).Scan(&u.ID, &u.Email, &u.Name)
    if err == sql.ErrNoRows {
        return nil, ErrNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("querying user: %w", err)
    }
    
    // Populate cache
    s.cache.Set("user:"+id, &u, 5*time.Minute)
    
    return &u, nil
}

// CreateUser inserts a user and invalidates relevant caches
func (s *Store) CreateUser(ctx context.Context, u *User) error {
    q := s.qb.Insert("users").
        Columns("id", "email", "name").
        Values(u.ID, u.Email, u.Name).
        Build()
    
    _, err := s.db.ExecContext(ctx, q.SQL, q.Args...)
    if err != nil {
        return fmt.Errorf("inserting user: %w", err)
    }
    
    s.cache.Delete("user:" + u.ID)
    return nil
}

func (s *Store) Close() error {
    return s.db.Close()
}

Clients of the storage package don’t know about connection pooling configuration, the LRU cache, or the query builder. They call GetUser and get a user back.

Trade-offs and Anti-Patterns

Facades become problematic when they grow into “god packages” that do everything. If your facade has dozens of methods spanning unrelated domains, split it. A storage facade that handles users, products, orders, and analytics should probably become four separate packages.

Watch for leaky abstractions. If clients need to understand subsystem details to use your facade correctly, the abstraction isn’t working. The facade should handle edge cases and error conditions internally.

Signs you need to refactor:

  • The facade file exceeds 500 lines
  • You’re exposing subsystem types in your public API
  • Clients frequently need to call multiple facade methods in a specific order
  • You find yourself adding “escape hatches” for power users

The facade pattern differs from related patterns. An adapter converts one interface to another—it’s about compatibility. A mediator coordinates communication between multiple objects that know about each other. A facade simplifies access to a subsystem that clients shouldn’t need to understand.

Use facades to hide complexity, not to add abstraction for its own sake. If your “subsystem” is a single package with a clear API, you don’t need a facade wrapping it. The pattern earns its keep when it genuinely simplifies client code by handling coordination that would otherwise be duplicated across your codebase.

Liked this? There's more.

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