Shaded Interfaces in Go: Balancing Simplicity and Power

If you’ve spent time with Go, you’ve likely heard these two proverbs:

  • Accept interfaces, return structs.
  • The bigger the interface, the weaker the abstraction.

They’re short, a bit cryptic, but packed with wisdom. Together, they guide us to keep interfaces small and flexible while letting concrete types shine when needed. But what happens when your system has both common functionality everyone needs and specialized features unique to specific implementations? Enter the Shaded Interface a pattern I’ve been using to strike that balance.

In this post, I’ll walk you through what a Shaded Interface is, why it’s useful, and how it fits neatly into Go’s philosophy. Let’s dive in!

Why Shaded Interfaces?

Real-world systems often have two kinds of behavior:

  • The Basics: Core operations like connecting to a service, closing it, or running simple queries. Every implementation supports these.
  • The Extras: Special features unique to a specific implementation, like a database that streams real-time changes or another with bulk import optimizations.

The basics are easy to abstract into a clean, portable interface. The extras? Not so much. If you try to cram every feature into a single “mega-interface,” you end up with a bloated mess that’s hard to implement, test, or swap. But if you only expose concrete types, you lose the flexibility and testability that interfaces provide.

A Shaded Interface solves this by keeping the common interface small and swappable while offering instant access to the concrete type for those special features with compile-time guarantees.

What Is a Shaded Interface?

At its core, a Shaded Interface is a small, generic interface that includes essential methods and a way to access the underlying concrete type. Here’s a simple example:

package db

import "context"

// A minimal interface every DB provider must support
type DatabaseProvider[T any] interface {
    Provides() *T
    Connect(ctx context.Context) error
    Close(ctx context.Context) error
}

The Provides() method is the star, it gives you the concrete type (*T) directly, with compile-time safety, so you can access powerful, implementation-specific features without being limited to the interface’s methods.

Making It Ergonomic

To make Shaded Interfaces even smoother to use, you can add a constructor-style helper that takes a DatabaseProvider[T] and returns the underlying concrete type:

func NewDB[T any](p DatabaseProvider[T]) *T {
    return p.Provides()
}

This keeps the API clean and intuitive, callers can just use NewDB to get the concrete type without thinking about Provides() directly.

Example: SQL vs. Streaming DB

Let’s see this in action with two database backends:

  • SQLProvider: Wraps a standard SQL database.
  • StreamProvider: Wraps a database that also streams real-time changes.

Here’s how they might look:

type SQLDB struct { /* ... */ }
type StreamDB struct { /* ... */ }

type SQLProvider struct{ db *SQLDB }
func MakeSQLProvider() DatabaseProvider[SQLDB] {
    return &SQLProvider{db: &SQLDB{}}
}
func (p *SQLProvider) Provides() *SQLDB { return p.db }
func (p *SQLProvider) Connect(ctx context.Context) error { /* ... */ return nil }
func (p *SQLProvider) Close(ctx context.Context) error   { /* ... */ return nil }

type StreamProvider struct{ db *StreamDB }
func MakeStreamProvider() DatabaseProvider[StreamDB] {
    return &StreamProvider{db: &StreamDB{}}
}
func (p *StreamProvider) Provides() *StreamDB { return p.db }
func (p *StreamProvider) Connect(ctx context.Context) error { /* ... */ return nil }
func (p *StreamProvider) Close(ctx context.Context) error   { /* ... */ return nil }

// Unique to StreamDB, not part of the interface
func (db *StreamDB) StreamChanges(ctx context.Context) (<-chan Change, error) {
    // Stream changes logic...
    return nil, nil
}

Here’s how you’d use it:

package main

import "fmt"

func main() {
    db := NewDB(MakeSQLProvider())
    fmt.Println(db) // &{}

    // or

    streamDB := NewDB(MakeStreamProvider())
    changes, _ := streamDB.StreamChanges(context.Background())
    fmt.Println(changes)
}

Both providers implement the DatabaseProvider[T] interface, so you can swap them easily. Need the streaming feature? Just call NewDB(MakeStreamProvider()) to get a StreamDB and use StreamChanges() with full compile-time confidence.

Why It Works

This pattern gives you the best of both worlds:

  • Portability: Code against the smaller and necessary DatabaseProvider[T] interface, and you can swap implementations (or mock them in tests) without breaking a sweat.
  • Power: Use NewDB to instantly access the concrete type’s specialized features, backed by Go’s compile-time type safety. For example, if you’re using a streaming database, you can tap into StreamChanges() without any runtime surprises, or weird work arounds.

The Shaded Interface makes this tradeoff explicit: write portable code when you can, and dive into specialized features when you need them. If you’re using a special feature, like streaming changelogs, you’ll have to rethink(db™) that code when you switch databases anyway.

Analysis

The Shaded Interface pattern leans heavily on flexibility: its small interface is easy to mock, test, and swap, making it a breeze to switch between implementations like SQLProvider and StreamProvider. The Provides() method, accessible via NewDB, gives you instant access to the concrete type’s advanced features with compile-time guarantees, ensuring type safety and expressiveness. The interface itself stays lean, avoiding the brittleness of bloated interfaces that try to cover every possible feature. The main downside is a slight learning curve; it may take some time to get comfortable deciding when to stick with the interface for portability versus using NewDB to tap into specialized functionality.

Wrapping Up

Shaded Interfaces are a simple but powerful twist on Go’s interface philosophy. They align perfectly with “accept interfaces, return structs” by keeping the shared interface lean and letting you access the concrete type when needed. They also avoid the trap of bloated interfaces that overpromise and underdeliver.

This pattern shines in systems with a stable core and specialized extensions, think databases, messaging systems, or caches. It’s not about eliminating complexity but shading it: keeping the portable parts simple while letting you tap into the full power of the concrete type when it counts.

Got thoughts or questions? I’d love to hear how you’re using interfaces in Go, drop a comment below!