Go Idioms

Accept Interfaces, Return Structs

Accept interfaces as function parameters for flexibility and return concrete structs for stable, predictable outputs.

The problem with rigid contracts

You write a function that reads a configuration file. It takes a *os.File as an argument. It works perfectly in production. Then you need to write a test. You realize you cannot easily pass a string or a mock object to the function. You have to refactor the signature, extract a reader, or duplicate logic. The function is too tightly coupled to one specific type. This happens constantly when you start building systems that outgrow a single file or a single database.

Go solves this with a single design rule that appears in almost every standard library package and well-structured application. The rule is simple to state and easy to break by accident. Accept interfaces. Return structs.

What the idiom actually means

An interface in Go describes behavior. It lists methods that a type must provide. A struct describes state. It holds fields and provides concrete implementations of methods. When you accept an interface as a function parameter, you are saying the function only cares about what the argument can do. You do not care what it is made of. When you return a struct, you are handing the caller a concrete value with a fixed shape. The caller knows exactly what fields exist and what methods are available.

Think of a universal power adapter. The wall socket is the interface. It defines two holes and a ground pin. Your laptop charger, phone brick, and lamp all fit into it because they implement the physical standard. The socket does not care which device you plug in. It only cares that the device follows the pin layout. The device itself is the struct. It has internal circuitry, a specific weight, and a fixed shape. You buy the device, not the socket.

Go enforces this pattern through implicit interface satisfaction. You do not write implements or extends. The compiler checks whether a type has the required methods. If it does, it satisfies the interface. This keeps your code decoupled. You can swap implementations without changing the function signature.

Flexibility belongs in the input. Stability belongs in the output.

A minimal example

Here is a function that calculates a checksum for any data source. It accepts io.Reader and returns a concrete ChecksumResult struct.

// ChecksumResult holds the output of a hashing operation.
type ChecksumResult struct {
    Algorithm string
    Hash      string
    Size      int64
}

// ComputeChecksum reads data from r and returns a typed result.
func ComputeChecksum(r io.Reader) (ChecksumResult, error) {
    // io.Reader is an interface. Any type with a Read method works here.
    // We accept the interface so callers can pass files, strings, or network streams.
    hash := sha256.New()
    size, err := io.Copy(hash, r)
    if err != nil {
        // Return early on failure. The error type is concrete.
        // We propagate the original error so the caller can inspect it.
        return ChecksumResult{}, err
    }
    // Return a concrete struct. The caller knows exactly what fields exist.
    // Returning a struct prevents the caller from needing type assertions.
    return ChecksumResult{
        Algorithm: "sha256",
        Hash:      hex.EncodeToString(hash.Sum(nil)),
        Size:      size,
    }, nil
}

The function signature tells you everything you need to know. It takes anything that implements io.Reader. It returns a ChecksumResult and an error. The caller does not need to know whether the data came from a file, a network connection, or an in-memory buffer. The caller only needs to know how to read the returned struct fields.

Go handles the interface conversion at compile time. When you pass a *os.File to ComputeChecksum, the compiler verifies that *os.File has a Read(p []byte) (n int, err error) method. It does. The compiler generates a small wrapper that routes the method call to the concrete type. At runtime, the interface value holds two pointers: one to the type description and one to the actual data. This is why interfaces have a tiny memory cost, but the cost is negligible compared to the design benefits.

The compiler checks the contract. You design the boundary.

How the compiler and runtime handle it

Understanding what happens under the hood removes the mystery around interfaces. When you declare a variable of an interface type, Go allocates space for two words. The first word stores a pointer to the type information. The second word stores a pointer to the actual data. If you assign a nil concrete value to an interface, the interface itself is not nil. The type pointer is set, but the data pointer is nil. This trips up beginners who check if reader == nil and find it evaluates to false. You must check the underlying data pointer or use a helper function to avoid silent panics.

Method calls on interfaces use dynamic dispatch. The compiler looks up the method in the type information table and jumps to the correct implementation. This adds a tiny indirection compared to calling a method on a concrete struct. The performance difference is measurable in microbenchmarks but irrelevant in real applications. The design clarity outweighs the nanosecond cost.

When you return a struct, the compiler knows the exact memory layout. It can inline the return value, optimize field access, and eliminate indirection. Returning a struct gives the compiler more information to work with. Returning an interface hides that information behind a type table lookup. You trade compile-time certainty for runtime flexibility. The idiom tells you to keep flexibility on the input side and certainty on the output side.

Don't leak your internals. Hand back a finished product.

How it plays out in real code

Real applications rarely deal with single functions. They deal with layers. A database layer, a caching layer, an HTTP handler layer. Each layer needs to talk to the next without dragging in unnecessary dependencies.

Consider a service that fetches user profiles. The service needs to read from a database, but you want to test it with a fake store. You define an interface for the data source. You implement the interface with a real database client and a test double. The service function accepts the interface. It returns a concrete UserProfile struct.

// UserStore defines how to retrieve user data.
type UserStore interface {
    GetUserByID(ctx context.Context, id string) (map[string]string, error)
}

// UserProfile holds the final shape of user data.
type UserProfile struct {
    ID     string
    Name   string
    Email  string
    Active bool
}

// FetchProfile retrieves a user and transforms the raw data into a typed struct.
func FetchProfile(store UserStore, id string) (UserProfile, error) {
    // ctx is passed first by convention. The store handles cancellation.
    // We attach a timeout to prevent hanging on slow databases.
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    raw, err := store.GetUserByID(ctx, id)
    if err != nil {
        // Propagate the error. Do not wrap it unless you add context.
        // Returning early keeps the happy path unindented and readable.
        return UserProfile{}, err
    }

    // Transform the map into a concrete struct.
    // We map fields explicitly so missing keys result in zero values, not panics.
    return UserProfile{
        ID:     raw["id"],
        Name:   raw["name"],
        Email:  raw["email"],
        Active: raw["active"] == "true",
    }, nil
}

The FetchProfile function does not care if store is a PostgreSQL client, a Redis wrapper, or an in-memory slice. It only cares that store provides GetUserByID. The function returns UserProfile. The caller can access .Name and .Email directly. No type assertions. No reflection. No guessing.

This pattern scales because it isolates change. If you switch from PostgreSQL to MongoDB, you only change the implementation of UserStore. You do not touch FetchProfile. You do not touch the callers of FetchProfile. The interface absorbs the change. The struct guarantees the output shape stays the same.

Convention aside: receiver names in Go are usually one or two letters that match the type. Write (s *Store) GetUserByID(...), not (this *Store) or (self *Store). It keeps method signatures short and readable. Public names start with a capital letter. Private names start lowercase. The compiler enforces visibility through capitalization, not keywords.

The worst design bug is the one that forces callers to guess your implementation.

Where things go wrong

The idiom is simple, but developers break it in predictable ways. The most common mistake is returning an interface when a struct would work. You write a function that returns io.Reader or a custom interface like Shape. The caller receives the interface and immediately tries to access a field or method that only exists on one specific implementation. The compiler rejects the program with interface Shape does not implement method GetArea or cannot call method on interface type. You are forced to add a type assertion, which panics at runtime if the underlying type is wrong.

Another mistake is creating interfaces before you need them. You define a DatabaseConnector interface with five methods. Only one package uses it. You end up writing boilerplate implementations just to satisfy the interface. Go favors concrete types until you actually have two different implementations. If you only have one, keep it a struct. Interfaces are cheap to add later because Go satisfies them implicitly. You do not need to refactor existing code to add an interface. You just define the interface in the package that uses it.

You also run into trouble when you ignore error handling conventions. Go expects you to check errors immediately. The verbose if err != nil { return err } pattern exists because it makes failure paths explicit. Hiding errors behind interfaces or swallowing them breaks the chain of responsibility. The community accepts the boilerplate because it prevents silent failures.

Empty interfaces (interface{}) are another trap. They accept any type, which sounds convenient until you realize the caller has no idea what they received. You lose compile-time safety entirely. Use any (the modern alias for interface{}) only when you genuinely need to store heterogeneous data, like in a JSON parser or a generic cache. For application logic, prefer specific interfaces or concrete structs.

Trust the type system. Wrap the value or change the design.

When to follow the rule and when to bend it

Use an interface parameter when you need to swap implementations without changing the function signature. Use a struct parameter when the function only works with one specific type and adding an interface would add zero flexibility. Use a concrete struct return value when the caller needs to inspect fields or pass the value to other functions that expect a specific shape. Use an interface return value only when the function is a factory that produces multiple unrelated types and the caller only needs to call a single method on the result. Keep the return type concrete when you want to guarantee stability across version upgrades.

The rule is not a law. It is a boundary marker. Interfaces define what a package needs. Structs define what a package provides. When you mix them up, you create coupling where there should be none. When you follow them, your code becomes easier to test, easier to extend, and easier to read.

Goroutines are cheap. Interfaces are contracts. Keep them where they belong.

Where to go next