Designing Go APIs the Standard Library Way: Accept Interfaces, Return Structs

Go’s standard library is a masterclass in clean, pragmatic API design. One of its core principles is accept interfaces, return structs. This approach ensures flexibility for inputs, rich functionality for outputs, and code that’s easy to test and extend. In this article, we’ll dive into what this principle means, why it works, and how you can apply it to write better Go APIs. I will share practical examples, standard library insights, and tips to avoid common pitfalls—so that you can put these insights to use immediately in your projects. Power Outlets and Devices: A Practical Analogy for Go’s Design To grasp why accept interfaces, return structs is so effective, think of a power outlet and an electronic device: Interface as a Power Outlet: A power outlet defines a standard (e.g., voltage and plug shape). Any device with a compatible plug can connect, regardless of whether it’s a lamp, a laptop charger, or a blender. In Go, an interface like io.Reader works the same way—it accepts any type that implements its methods, giving functions flexibility to work with diverse inputs. Struct as a Device: Once plugged in, the device (e.g., a blender) offers its full range of features—blend, pulse, or chop. Similarly, a struct returned by a function provides complete access to its fields and methods, letting users leverage its full capabilities. What Does "Accept Interfaces, Return Structs" Mean in Practice? In Go, APIs often accept interfaces as function parameters and return structs as results. This design choice balances flexibility with functionality: Interfaces as input: Allow any type that implements the interface’s methods, making the function versatile. Structs as output: Provide full access to the returned type’s fields and methods, giving users maximum control. Example: A Simple Processor Here’s a basic example to illustrate: package main import ( "fmt" "io" ) // Processor handles data processing type Processor struct { Data string } // Process reads from an io.Reader and returns a Processor func Process(r io.Reader) (*Processor, error) { data, err := io.ReadAll(r) if err != nil { return nil, err } return &Processor{Data: string(data)}, nil } func main() { // Example with strings.Reader (implements io.Reader) r := strings.NewReader("Hello, Go!") p, err := Process(r) if err != nil { fmt.Println("Error:", err) return } fmt.Println("Processed:", p.Data) } Input: io.Reader interface, allowing any type with a Read method (e.g., strings.Reader, bytes.Buffer, or even a file). Output: *Processor struct, exposing its Data field and any methods you might add later. This pattern lets Process work with diverse inputs while returning a struct with clear, usable functionality. Why This Pattern Works: Interfaces Hide, Structs Expose Interfaces and structs serve complementary roles in Go’s type system. Understanding their strengths helps explain why this principle is effective. Interfaces: Abstraction and Flexibility An interface defines a contract—a set of methods—without revealing how they’re implemented. This makes them ideal for function inputs because: They allow multiple types to satisfy the same requirement. They make mocking easy for testing. They keep APIs decoupled from specific implementations. For example, io.Reader is satisfied by files, network connections, or in-memory buffers, all without changing the function’s signature. Structs: Transparency and Power Structs, on the other hand, are concrete types that expose their fields and methods. Returning a struct gives users: Full access to the type’s capabilities. Extensibility, as new methods can be added without breaking existing code. Predictable behavior, since the type’s implementation is explicit. Comparison Table: Interfaces vs. Structs Aspect Interface Struct Purpose Abstracts behavior Provides concrete implementation Flexibility High (any type implementing methods) Fixed to specific type Extensibility Limited to defined methods New methods/fields can be added Testability Easy to mock May need an interface for mocking Transparency Hides implementation Exposes fields and methods Key takeaway: Use interfaces for inputs to keep functions flexible and testable. Return structs to give users full control over the output. Real-World Examples from Go’s Standard Library The Go standard library consistently applies this principle. Let’s break down some examples to see it in action. 1. http.NewRequest: Flexible Body, Rich Request func NewRequest(method, url string, body io.Reader) (*http.Request, error) Input: io.Reader for the request body, allowing streams like strings.Reader, bytes.Buffer, or even multipart.File. Output: *http.Request, a struct with fields like Method, URL, and Header, plus methods l

Apr 19, 2025 - 18:23
 0
Designing Go APIs the Standard Library Way: Accept Interfaces, Return Structs

Go’s standard library is a masterclass in clean, pragmatic API design.

One of its core principles is accept interfaces, return structs.

This approach ensures flexibility for inputs, rich functionality for outputs, and code that’s easy to test and extend.

In this article, we’ll dive into what this principle means, why it works, and how you can apply it to write better Go APIs.

I will share practical examples, standard library insights, and tips to avoid common pitfalls—so that you can put these insights to use immediately in your projects.

Power Outlets and Devices: A Practical Analogy for Go’s Design

To grasp why accept interfaces, return structs is so effective, think of a power outlet and an electronic device:

  • Interface as a Power Outlet: A power outlet defines a standard (e.g., voltage and plug shape). Any device with a compatible plug can connect, regardless of whether it’s a lamp, a laptop charger, or a blender. In Go, an interface like io.Reader works the same way—it accepts any type that implements its methods, giving functions flexibility to work with diverse inputs.
  • Struct as a Device: Once plugged in, the device (e.g., a blender) offers its full range of features—blend, pulse, or chop. Similarly, a struct returned by a function provides complete access to its fields and methods, letting users leverage its full capabilities.

What Does "Accept Interfaces, Return Structs" Mean in Practice?

In Go, APIs often accept interfaces as function parameters and return structs as results. This design choice balances flexibility with functionality:

  • Interfaces as input: Allow any type that implements the interface’s methods, making the function versatile.
  • Structs as output: Provide full access to the returned type’s fields and methods, giving users maximum control.

Example: A Simple Processor

Here’s a basic example to illustrate:

package main

import (
    "fmt"
    "io"
)

// Processor handles data processing
type Processor struct {
    Data string
}

// Process reads from an io.Reader and returns a Processor
func Process(r io.Reader) (*Processor, error) {
    data, err := io.ReadAll(r)
    if err != nil {
        return nil, err
    }
    return &Processor{Data: string(data)}, nil
}

func main() {
    // Example with strings.Reader (implements io.Reader)
    r := strings.NewReader("Hello, Go!")
    p, err := Process(r)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Processed:", p.Data)
}
  • Input: io.Reader interface, allowing any type with a Read method (e.g., strings.Reader, bytes.Buffer, or even a file).
  • Output: *Processor struct, exposing its Data field and any methods you might add later.

This pattern lets Process work with diverse inputs while returning a struct with clear, usable functionality.

Why This Pattern Works: Interfaces Hide, Structs Expose

Interfaces and structs serve complementary roles in Go’s type system.

Understanding their strengths helps explain why this principle is effective.

Interfaces: Abstraction and Flexibility

An interface defines a contract—a set of methods—without revealing how they’re implemented.

This makes them ideal for function inputs because:

  • They allow multiple types to satisfy the same requirement.
  • They make mocking easy for testing.
  • They keep APIs decoupled from specific implementations.

For example, io.Reader is satisfied by files, network connections, or in-memory buffers, all without changing the function’s signature.

Structs: Transparency and Power

Structs, on the other hand, are concrete types that expose their fields and methods. Returning a struct gives users:

  • Full access to the type’s capabilities.
  • Extensibility, as new methods can be added without breaking existing code.
  • Predictable behavior, since the type’s implementation is explicit.

Comparison Table: Interfaces vs. Structs

Aspect Interface Struct
Purpose Abstracts behavior Provides concrete implementation
Flexibility High (any type implementing methods) Fixed to specific type
Extensibility Limited to defined methods New methods/fields can be added
Testability Easy to mock May need an interface for mocking
Transparency Hides implementation Exposes fields and methods

Key takeaway: Use interfaces for inputs to keep functions flexible and testable. Return structs to give users full control over the output.

Real-World Examples from Go’s Standard Library

The Go standard library consistently applies this principle. Let’s break down some examples to see it in action.

1. http.NewRequest: Flexible Body, Rich Request

func NewRequest(method, url string, body io.Reader) (*http.Request, error)
  • Input: io.Reader for the request body, allowing streams like strings.Reader, bytes.Buffer, or even multipart.File.
  • Output: *http.Request, a struct with fields like Method, URL, and Header, plus methods like AddCookie.

This design lets you pass any readable stream as the body while giving you a fully-featured http.Request to manipulate.

Example usage:

body := strings.NewReader("name=Alice")
req, err := http.NewRequest("POST", "https://api.example.com", body)
if err != nil {
    log.Fatal(err)
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")

2. json.NewDecoder: Stream JSON with Ease

func NewDecoder(r io.Reader) *json.Decoder
  • Input: io.Reader, supporting any source of JSON data (e.g., files, HTTP responses).
  • Output: *json.Decoder, a struct with methods like Decode and Token for fine-grained JSON processing.

Example usage:

data := strings.NewReader(`{"name": "Bob"}`)
decoder := json.NewDecoder(data)
var result map[string]string
if err := decoder.Decode(&result); err != nil {
    log.Fatal(err)
}
fmt.Println(result["name"]) // Output: Bob

3. bufio.NewReader: Buffered Reading Made Simple

func NewReader(rd io.Reader) *bufio.Reader
  • Input: io.Reader, such as a file or network connection.
  • Output: *bufio.Reader, a struct with methods like ReadString and ReadLine for efficient buffered reading.

Example usage:

file, err := os.Open("data.txt")
if err != nil {
    log.Fatal(err)
}
reader := bufio.NewReader(file)
line, err := reader.ReadString('\n')
if err != nil {
    log.Fatal(err)
}
fmt.Println("First line:", line)

4. sort.Sort: Sorting Any Collection

func Sort(data sort.Interface)
  • Input: sort.Interface, requiring Len, Less, and Swap methods, allowing custom types to be sorted.
  • Output: None (sorts in-place), but the interface ensures flexibility.

Example usage:

type ByLength []string
func (s ByLength) Len() int           { return len(s) }
func (s ByLength) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }
func (s ByLength) Less(i, j int) bool { return len(s[i]) < len(s[j]) }

words := []string{"cat", "elephant", "dog"}
sort.Sort(ByLength(words))
fmt.Println(words) // Output: [cat dog elephant]

These examples show how the standard library uses interfaces for broad compatibility and structs for rich functionality.

Learn more: Check out Effective Go for deeper insights into Go’s design philosophy.

Best Practices for Applying This Principle

To design APIs that feel “Go-like,” follow these guidelines:

  1. Accept Narrow Interfaces: Use small interfaces (e.g., io.Reader, io.Writer) to maximize compatibility. A single-method interface is often enough.
  2. Return Structs for Extensibility: Structs allow you to add methods or fields later without breaking existing code.
  3. Prioritize Testability: Interfaces as inputs make it easy to mock dependencies in tests.
  4. Avoid Premature Interfaces: Don’t define interfaces until you have multiple implementations or a clear need for abstraction.
  5. Document Behavior: Clearly document what the returned struct’s methods do and how to use them.

Example: Building a Logging API

Let’s design a logging API following this principle:

package logger

import (
    "io"
    "time"
)

// Logger writes log messages
type Logger struct {
    Writer io.Writer
}

// New creates a new Logger
func New(w io.Writer) *Logger {
    return &Logger{Writer: w}
}

// Log writes a message with a timestamp
func (l *Logger) Log(msg string) error {
    _, err := fmt.Fprintf(l.Writer, "[%s] %s\n", time.Now().Format(time.RFC3339), msg)
    return err
}
  • Input: io.Writer, allowing logs to go to files, stdout, or network streams.
  • Output: *Logger, a struct with a Log method (and room to add more methods later).

Usage:

log := logger.New(os.Stdout)
log.Log("User logged in") // Output: [2025-04-19T12:00:00Z] User logged in

This API is flexible, testable, and extensible, aligning with Go’s philosophy.

Common Pitfalls and How to Avoid Them

While this principle is powerful, it’s easy to misapply. Here are mistakes to watch out for:

  1. Returning Interfaces:

    • Problem: Returning an interface (e.g., io.Reader) limits users to its methods, hiding additional functionality.
    • Solution: Return a struct instead, letting users access all fields and methods.
    • Example:
     // Bad
     func BadProcess() io.Reader { ... }
    
     // Good
     func GoodProcess() *Processor { ... }
    
  2. Overusing Interfaces:

    • Problem: Defining interfaces when only one implementation exists adds complexity.
    • Solution: Let consumers define interfaces if needed, or wait until you have multiple implementations.
    • Example:
     // Unnecessary
     type MyReader interface { Read() }
    
     // Better: Just use io.Reader or a struct
    
  3. Ignoring Testability:

    • Problem: Accepting structs as input makes mocking harder.
    • Solution: Use interfaces to allow mock implementations in tests.

Tip: If you’re unsure, study the standard library—it’s a goldmine of practical examples.

Why This Matters for Your Go Projects

Adopting accept interfaces, return structs makes your Go APIs:

  • Flexible: They work with a wide range of inputs.
  • Testable: Mocking interfaces is straightforward.
  • Extensible: Structs can evolve without breaking changes.
  • User-friendly: Consumers get full access to the returned type’s capabilities.

By mirroring the standard library’s approach, your code will feel idiomatic and integrate smoothly with Go’s ecosystem.

Further reading: Explore this Medium article for additional perspectives on this principle.

Final Thoughts: Make Your APIs Go-idiomatic

The accept interfaces, return structs principle is more than a guideline—it’s a philosophy that shapes robust, flexible, and testable Go APIs.

By studying the standard library and applying these practices, you’ll write code that’s not only functional but also a joy to use.

Next time you design a Go API, ask yourself: Am I accepting interfaces for flexibility and returning structs for power? If so, you’re on the right track.