Accept Interfaces, Return Structs: The Golden Rule of Go Design
Hey Everyone, Welcome to the New Blog. In today’s blog we are going to learn about The Golden Rule of Go Design. So without wasting time let’s get started.
The one principle that separates Go beginners from Go engineers who sleep well at night
There’s a piece of wisdom in the Go community that sounds almost too simple to be profound:
Accept interfaces, return structs.
Four words. One rule. It shows up in code reviews, design discussions, conference talks, and the mental models of every senior Go engineer you’ll meet. And yet, it’s one of the most misunderstood and misapplied principles for developers coming from Java, Python, or even other Go backgrounds.
This post breaks it down the intermediate way: not just the what, but the why it matters, when to apply it, and where it can go wrong if you cargo-cult it.
First: What Does It Actually Mean?
Let’s be concrete before we get philosophical.
Accept interfaces means: when you write a function or method that needs to receive some behaviour, express that requirement as an interface not a concrete type.
Return structs means: when your function produces something, return the concrete type (the struct), not an interface.
// The Golden Rule in practice
// Accepts behaviour via interface, returns the real thing
type Reader interface {
Read(p []byte) (n int, err error)
}
// Accept: interface (we only care that it can be Read from)
// Return: concrete struct (caller gets the full, real value)
func ParseConfig(r Reader) (*Config, error) {
// read, parse, return
return &Config{...}, nil
}Simple enough on the surface. But there’s a lot packed into this pattern. Let’s unpack it.
Why Accept Interfaces? The Power of “I Don’t Care How”
When a function accepts a concrete type, it’s making a very specific claim:
I only work with this exact thing. Nothing else.
// ❌ Overly specific couples the function to one concrete type
func ProcessLogs(f *os.File) error {
// reads from file...
}This works fine until you want to write a unit test. Suddenly you need an actual file on disk. Or you want to process logs from an HTTP response body. Or from a buffer in memory during a hot-reload cycle. You’re stuck.
Now look at the same function accepting an interface:
// ✅ Flexible and testable decoupled from implementation
func ProcessLogs(r io.Reader) error {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
// process line...
}
return scanner.Err()
}Now ProcessLogs works with:
*os.File— reading from disk*bytes.Buffer— reading from memory (great for tests!)*http.Response.Body— reading from an HTTP response- Any custom type that implements
io.Reader
Your function got smarter without changing a single line of its logic. That’s the power of accepting interfaces.
The key insight: interfaces define requirements, not implementations
When you write func Foo(r io.Reader), you're saying: "I require the ability to read bytes. I don't care how you provide it." This is Dependency Inversion done the Go way no frameworks, no DI containers, just interface types.
Why Return Structs? The Power of “Here’s Everything”
The flip side is equally important, and more often violated.
When you return an interface, you’re hiding information from the caller:
// ❌ Returning an interface what can the caller actually DO with this?
func NewDatabase(dsn string) Database {
return &PostgresDB{conn: connect(dsn)}
}The caller gets a Database. They can only do what Database defines. But what if PostgresDB has useful methods like Ping(), Stats(), or Close()?
Those are invisible behind the interface. The caller can't use them without a type assertion which is exactly the kind of awkward code Go's design is meant to avoid.
Now with a concrete return:
// ✅ Returning the struct caller gets full access, full power
func NewDatabase(dsn string) (*PostgresDB, error) {
db, err := connect(dsn)
if err != nil {
return nil, fmt.Errorf("connecting to postgres: %w", err)
}
return &PostgresDB{conn: db}, nil
}The caller now has the real thing. If they want to treat it as a Database interface for polymorphism, they can but that decision belongs to them, not to your function.
“Accept interfaces” gives the caller freedom to pass anything in. “Return structs” gives the caller freedom to use everything you give them.
A Real-World Intermediate Example: Building a Notification System
Let’s build something realistic. A notification service that can send via email, Slack, or SMS and we want it to be testable and extensible.
Step 1: Define the interface at the consumer site
// notifier.go
// Notifier defines what we need from any notification backend.
// Small, focused, and defined where it's consumed - not where it's implemented.
type Notifier interface {
Send(ctx context.Context, msg Message) error
}
type Message struct {
To string
Subject string
Body string
}💡 Go tip: Define interfaces in the package that uses them, not the package that implements them. This is the opposite of Java-style interface hierarchies, and it’s intentional in Go’s design.
Step 2: Implement concrete types that satisfy the interface (without knowing it)
// email/email.go
type Client struct {
host string
port int
apiKey string
timeout time.Duration
// ... other useful fields the caller might want
}
// Client satisfies Notifier but it doesn't declare that. Duck typing.
func (c *Client) Send(ctx context.Context, msg Message) error {
// ... real SMTP or API logic
return nil
}
// NewClient returns the concrete struct not an interface.
// The caller gets everything: Send(), Close(), Stats(), whatever we add later.
func NewClient(host string, port int, apiKey string) (*Client, error) {
if apiKey == "" {
return nil, errors.New("email: apiKey is required")
}
return &Client{
host: host,
port: port,
apiKey: apiKey,
timeout: 10 * time.Second,
}, nil
}Step 3: The service accepts the interface
// service/notifications.go
// NotificationService doesn't know or care if it's email, Slack, or a mock.
// All it knows: "I need something that can Send a message."
type NotificationService struct {
primary Notifier
fallback Notifier // if primary fails, try this
logger *slog.Logger
}
func NewNotificationService(primary, fallback Notifier, logger *slog.Logger) *NotificationService {
return &NotificationService{
primary: primary,
fallback: fallback,
logger: logger,
}
}
func (s *NotificationService) Notify(ctx context.Context, msg Message) error {
if err := s.primary.Send(ctx, msg); err != nil {
s.logger.Warn("primary notifier failed, trying fallback", "err", err)
return s.fallback.Send(ctx, msg)
}
return nil
}Step 4: Testing becomes trivially easy
// service/notifications_test.go
// MockNotifier is our test double zero dependencies, zero I/O.
type MockNotifier struct {
sent []Message
failOn int // fail on the Nth call, -1 = never fail
calls int
}
func (m *MockNotifier) Send(_ context.Context, msg Message) error {
m.calls++
if m.calls == m.failOn {
return errors.New("mock: simulated failure")
}
m.sent = append(m.sent, msg)
return nil
}
func TestNotificationService_FallsBackOnPrimaryFailure(t *testing.T) {
primary := &MockNotifier{failOn: 1} // always fails
fallback := &MockNotifier{failOn: -1} // never fails
svc := NewNotificationService(primary, fallback, slog.Default())
msg := Message{To: "yash@example.com", Subject: "Hello", Body: "Test"}
err := svc.Notify(context.Background(), msg)
// No real email sent. No network. No flakiness. Just logic.
assert.NoError(t, err)
assert.Len(t, fallback.sent, 1, "fallback should have received the message")
assert.Equal(t, msg, fallback.sent[0])
}This test runs in microseconds. No SMTP server. No credentials. No environment variables. This is the payoff.
The Anti-Patterns to Avoid
❌ Anti-pattern 1: The Mega Interface
// Don't do this you're describing a whole object, not a behaviour
type Storage interface {
Read(key string) ([]byte, error)
Write(key string, val []byte) error
Delete(key string) error
List(prefix string) ([]string, error)
Flush() error
Ping() error
Stats() StorageStats
// ... 12 more methods
}If your interface has more than 3–5 methods, pause and ask: does a single function really need all of these?
Get Yash Sanjivkumar Pawar’s stories in your inbox
Join Medium for free to get updates from this writer.
Usually the answer is no. Break it up:
// ✅ Focused, composable interfaces
type Reader interface {
Read(key string) ([]byte, error)
}
type Writer interface {
Write(key string, val []byte) error
}
// Compose when you need both - Go interfaces are free to embed
type ReadWriter interface {
Reader
Writer
}❌ Anti-pattern 2: Preemptive interface in the wrong place
// ❌ Defining the interface in the implementation package
// package postgres
type Database interface { // <-- wrong place
Query(sql string) (*Rows, error)
}
type DB struct{}
func (d *DB) Query(sql string) (*Rows, error) { ... }This forces callers to import your postgres package just to get the interface type. The whole point is that the consumer defines what it needs. Put the interface where it's used.
❌ Anti-pattern 3: Returning interface{} / any
// ❌ Never do this unless you absolutely must
func NewCache(kind string) interface{} {
switch kind {
case "redis":
return &RedisCache{}
case "memory":
return &MemCache{}
}
return nil
}The caller now has to type-assert everything. You’ve thrown away Go’s type system. Return a concrete type, or if you need polymorphism, define a proper focused interface.
When the Rule Bends
No rule is universal. Here are legitimate cases where you might deviate:
1. Constructor functions for truly polymorphic returns
If you’re building a driver/plugin system where the concrete type genuinely varies based on runtime config:
// A factory that returns an interface is okay here the whole
// *point* is that the caller shouldn't know which backend they got.
func NewStore(cfg Config) (Store, error) {
switch cfg.Backend {
case "s3":
return s3.NewClient(cfg.S3)
case "gcs":
return gcs.NewClient(cfg.GCS)
default:
return nil, fmt.Errorf("unknown backend: %s", cfg.Backend)
}
}This is acceptable. Just make sure the interface is small and well-defined, not a grab-bag.
2. error is an interface and that's correct
func DoSomething() error // error is interface{Error() string} and it's righterror is the canonical example of a well-designed interface return. It works because the interface is tiny (one method), universally understood, and the concrete types (*os.PathError, *url.Error, etc.) add richer context without leaking implementation detail. Follow that model.
The Go Standard Library Does This Everywhere
This isn’t just a community best-practice it’s baked into the standard library’s design:
// io package: tiny interfaces accepted everywhere
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
// Concrete types returned from constructors
os.Open(name) (*os.File, error) // not io.ReadCloser
bufio.NewReader(r) *bufio.Reader // not io.Reader
bytes.NewBuffer(buf) *bytes.Buffer // not io.ReadWriterThe Go team could have returned io.ReadCloser from os.Open. They didn't. They return *os.File so you get access to Stat(), Seek(), Sync(), Chmod() the full picture. Whether you choose to narrow it to io.Reader is your decision, not theirs.
Conclusion: The Rule Is About Respect
Accept interfaces, return structs at its core is about respecting boundaries.
When you accept interfaces, you’re saying to your callers: “I respect your right to implement this however you want. I’ll only demand what I actually need.”
When you return structs, you’re saying: “I respect your right to use what I’ve built however you need. I won’t hide things from you prematurely.”
This is Go’s philosophy of orthogonality made tangible. Small, composable, honest pieces. Functions that are easy to test because their dependencies are explicit. Code that’s easy to extend because coupling is minimized.
It won’t solve every design problem you’ll face in a real codebase. But it’ll solve more of them than you’d expect and the ones it doesn’t solve, it’ll make easier to think about.
Next Steps
Now that the principle is clear, here’s how to take it further:
1. Audit your current codebase Look for functions that accept *sql.DB, *os.File, or other concrete types. Ask: "Do I actually need the full concrete type, or just one method?" If just one method extract a tiny interface.
2. Read the io package source Seriously. io.Reader, io.Writer, io.Closer, io.Seekerthese tiny single-method interfaces compose into the entire I/O model of Go. It's the best example of interface design in any standard library.
3. Practice the “mock it in tests” test Whenever you’re writing a function, ask: “Can I test this without spinning up a real database/server/file?” If not, your dependencies are probably too concrete. Introduce an interface.
4. Explore gomock or testify/mock Once your code accepts interfaces, you can use code generation tools like gomock or mockery to auto-generate test doubles zero boilerplate.
5. Read: “Go Proverbs” by Rob Pike The broader philosophy behind this rule lives in Go’s proverbs: “The bigger the interface, the weaker the abstraction.” Understanding all of them will level up your Go design instincts significantly.
Happy to discuss edge cases and tradeoffs in the comments especially if you’ve run into a situation where this rule felt wrong. Those are usually the most interesting conversations.
Thank you so much for reading 💙
Like | Follow | Subscribe to the newsletter.
Catch me on my socials here: https://bio.link/yashpawar







