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.