DEV Community

Cover image for The Secret Life of Go: Interfaces
Aaron Rose
Aaron Rose

Posted on

The Secret Life of Go: Interfaces

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)
    }
}

Enter fullscreen mode Exit fullscreen mode

"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."

The Implicit Contract

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
}

Enter fullscreen mode Exit fullscreen mode

"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)
    }
}

Enter fullscreen mode Exit fullscreen mode

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."

Accept Interfaces, Return Structs

"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
}

Enter fullscreen mode Exit fullscreen mode

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

The Empty Interface

"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)
}

Enter fullscreen mode Exit fullscreen mode

"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."

Small is Beautiful

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?'"


Key Concepts from Chapter 14

Implicit Implementation:
Go types satisfy interfaces automatically. There is no implements keyword. If a struct has the methods, it fits the interface.

  • Metaphor: Duck Typing. (If it quacks, it's a duck).

Defining Interfaces:
Define interfaces where you use them, not where you define the types.

type Saver interface {
    Save() error
}

Enter fullscreen mode Exit fullscreen mode

The Golden Rule: "Accept Interfaces, Return Structs."

  • Accept: Function inputs should be interfaces (e.g., func Do(r io.Reader)). This allows flexibility.
  • Return: Function outputs (constructors) should be concrete types (e.g., func New() *MyStruct). This gives the caller full access.

Small Interfaces:
Prefer single-method interfaces (io.Reader, fmt.Stringer). Small interfaces are easier to satisfy and compose.

The Empty Interface (any):
interface{} (or alias any) is satisfied by all types. Use it only when necessary (like generic printing or containers), as it bypasses compile-time type checking.

Decoupling:
Interfaces allow you to write logic (like ProcessUser) that works with future types you haven't even invented yet.


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.


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

Top comments (0)