The Secret Life of Go: Interfaces

Published: (January 11, 2026 at 09:53 PM EST)
6 min read
Source: Dev.to

Source: Dev.to

Chapter 14: The Shape of Behavior

The archive was unusually cold that Thursday. The radiators hissed and clanked, fighting a losing battle against the draft seeping through the century‑old brickwork.

Ethan sat at the table wearing his heavy coat, typing with fingerless gloves. He looked miserable.

“It’s the duplication,” he muttered, staring at his screen. “I feel like I’m writing the same code twice.”

Eleanor placed a small plate on the desk.

“Mille‑feuille,” she said. “Thousands of layers of pastry, separated by cream. Distinct, yet unified.”

Ethan eyed the pastry.

“I wish my code was that organized. Look at this.”

He spun his laptop around.

type Admin struct {
    Name  string
    Level int
}

type Guest struct {
    Name string
}

func SaveAdmin(a Admin) error {
    // Logic to save admin to database...
    return nil
}

func SaveGuest(g Guest) error {
    // Logic to save guest to text file...
    return nil
}

func ProcessAdmin(a Admin) {
    if err := SaveAdmin(a); err != nil {
        log.Println(err)
    }
}

func ProcessGuest(g Guest) {
    if err := SaveGuest(g); err != nil {
        log.Println(err)
    }
}

“I have two types of users,” Ethan explained. “Admins go to the database. Guests go to a log file. But now my manager wants a SuperAdmin, and I’m about to write ProcessSuperAdmin and SaveSuperAdmin. It feels wrong.”

“It feels wrong because you are obsessed with identity,” Eleanor said, pouring tea. “You are asking ‘What is this thing?’ Is it an Admin? Is it a Guest? But the function Process does not care what the thing is. It only cares what the thing does.”

She pointed to the Save calls.

“What is the behavior you actually need?”

“I need it to save.”

“Precisely. In Go, we describe behavior with interfaces.”

Eleanor opened a new file.

“In other languages, you might create a complex hierarchy. AbstractUser inherits from BaseEntity. In Go, we simply describe a method set.”

type Saver interface {
    Save() error
}

“This is it,” she said. “Any type that has a Save() error method is automatically a Saver. You do not need to type implements Saver. You do not need to sign a contract. You just do the job.”

She refactored Ethan’s code:

// 1. Define the behaviors (methods) on the structs
func (a Admin) Save() error {
    fmt.Println("Saving admin to DB...")
    return nil
}

func (g Guest) Save() error {
    fmt.Println("Writing guest to file...")
    return nil
}

// 2. Write ONE function that accepts the interface
func ProcessUser(s Saver) {
    // This function doesn't know if 's' is an Admin or a Guest.
    // It doesn't care. It just knows it can call Save().
    if err := s.Save(); err != nil {
        log.Println(err)
    }
}

Ethan blinked.

“Wait. Admin doesn’t mention Saver anywhere?”

“No. This is called duck typing. If it walks like a duck and quacks like a duck, Go treats it as a duck. Because Admin has the Save method, it is a Saver. This decouples your code. The ProcessUser function no longer depends on Admin or Guest. It depends only on the behavior.”

“So I should make interfaces for everything?” Ethan asked, reaching for the mille‑feuille. “Should I make an AdminInterface?”

“Absolutely not,” Eleanor said sharply. “That is the Java talking. In Go, we have a golden rule: Accept Interfaces, Return Structs.”

She wrote it on a notepad.

  • Accept Interfaces: Functions should ask for the abstract behavior they need (Saver, Reader, Validator). This makes them flexible.
  • Return Structs: Functions that create things should return the concrete type (*Admin, *File, *Server).

“Why?” Ethan asked.

“Because of Postel’s Law,” Eleanor replied. “‘Be conservative in what you do, be liberal in what you accept from others.’ If you return an interface, you strip away functionality. You hide data. If you return a concrete struct, the caller gets everything. But when you accept an argument, you should ask for the minimum behavior required.”

She typed an example:

// Good: Return the concrete struct (pointer)
func NewAdmin(name string) *Admin {
    return &Admin{Name: name}
}

// Good: Accept the interface
func LogUser(s Saver) {
    s.Save()
}

func main() {
    admin := NewAdmin("Eleanor") // We get a concrete *Admin
    LogUser(admin)               // We pass it to a function expecting a Saver
}

“See?” Eleanor traced the lines. “We create concrete things. But we pass them around as abstract behaviors.”

“What if I want to accept anything?” Ethan asked. “Like print(anything)?”

“Then you use the empty interface: interface{}. Or in modern Go, any.”

func PrintAnything(v any) {
    fmt.Println(v)
}

“Since any has zero methods, every single type in Go satisfies it—an integer, a struct, a pointer—they all have at least zero methods.”

“That sounds powerful,” Ethan said.

“It is dangerous,” Eleanor corrected. “When you use any, you throw away all type safety. You are telling the compiler, ‘I don’t care what this is.’ Use it sparingly. Use it only when you truly do not care about the data, like in fmt.Printf or JSON serialization.”

Ethan finished the pastry.

“So, big interfaces are better? Like UserBehavior with Save, Delete, Update, Validate?”

“No. The bigger the interface, the weaker the abstraction,” Eleanor said. “We prefer single‑method interfaces: Reader, Writer, Stringer, Saver. If I ask for a Saver, I can pass in an Admin, a Guest, or even a CloudBackup. If I ask for a massive UserBehavior, I can only pass in things that implement all twenty methods. Keep it small.”

She closed her laptop.

“Do not define the world, Ethan. Just define the behavior you need right now.”

Ethan looked at his refactored code. The duplicate functions were gone, replaced by a single, elegant ProcessUser(s Saver).

“It’s about detachment,” he realized. “The function doesn’t need to know the identity of the data.”

“Exactly,” Eleanor smiled, wrapping her hands around her warm tea. “It is polite software design. We do not ask ‘Who are you?’ We simply ask, ‘Can you save?’”

Implicit Implementation

A struct that implements the required methods fits the interface.
Metaphor: Duck Typing – “If it quacks, it’s a duck.”

Defining Interfaces

Use interfaces where you consume types, not where you define them.

type Saver interface {
    Save() error
}

The Golden Rule

“Accept Interfaces, Return Structs.”

  • Accept: Function inputs should be interfaces.

    func Do(r io.Reader) { /* … */ }

    This gives callers flexibility.

  • Return: Function outputs (especially constructors) should be concrete types.

    func New() *MyStruct { /* … */ }

    This gives the caller full access to the returned value.

Small Interfaces

  • Examples: io.Reader, fmt.Stringer
  • Small interfaces are easier to satisfy and compose.

The Empty Interface (any)

interface{}   // or the alias `any`
  • Satisfied by all types.
  • Use only when necessary (e.g., generic printing, containers) because it bypasses compile‑time type checking.

Decoupling

Design functions that work with future types you haven’t even invented yet.

func ProcessUser(u User) { /* … */ }

Next Chapter: Concurrency

Ethan thinks doing two things at once is easy—until Eleanor introduces him to the chaos of race conditions—and the zen of channels.

About the Author

Aaron Rose is a software engineer and technology writer at tech‑reader.blog and the author of Think Like a Genius.

Back to Blog

Related posts

Read more »

The Secret Life of Go: Testing

Chapter 13: The Table of Truth The Wednesday rain beat a steady rhythm against the archive windows, blurring the Manhattan skyline into smears of gray and slat...