DEV Community

Shiyam
Shiyam

Posted on

Why Go Interfaces Are Different: A Deep Dive for OO Developers

Abstract representation of Go implicit interfaces

If you are transitioning to Go from object-oriented heavyweights like Java, C#, or C++, you will quickly notice that Go feels different. There are no classes, no inheritance hierarchies, and no implements keyword. Yet, Go is deeply polymorphic.

The secret lies in Go interfaces. They are arguably the most powerful and defining feature of the language. But because they don't behave like interfaces in traditional object-oriented programming (OOP), they often trip up newcomers.

In this article, we will explore Go vs Java interfaces, dig into the Go interface architecture to see how they work under the hood, and demonstrate how Go's unique design fundamentally changes the way you write and test software.


The Paradigm Shift: Go vs Java Interfaces

In languages like Java or C#, interfaces are explicit. A class must explicitly declare that it implements an interface.

Here is how you might define and implement an interface in Java:

// Java Explicit Interface
public interface DataWriter {
    void write(String data);
}

// The class MUST declare "implements DataWriter"
public class ConsoleWriter implements DataWriter {
    @Override
    public void write(String data) {
        System.out.println(data);
    }
}
Enter fullscreen mode Exit fullscreen mode

This creates a rigid, top-down dependency. The ConsoleWriter must know about the DataWriter interface at compile time.

Go, on the other hand, uses implicit interfaces. A type implements an interface simply by implementing its methods. There is no implements keyword.

// Go Implicit Interface
type DataWriter interface {
    Write(data string)
}

// ConsoleWriter knows NOTHING about DataWriter
type ConsoleWriter struct{}

// It implicitly implements DataWriter by having this method
func (c ConsoleWriter) Write(data string) {
    fmt.Println(data)
}
Enter fullscreen mode Exit fullscreen mode

Because of implicit interfaces in Go, the dependency graph is inverted. The consumer of the behavior defines the interface, not the producer. A package providing a ConsoleWriter doesn't need to know how other packages will use it. If it walks like a duck and quacks like a duck, Go treats it like a duck.


Go Interface Under the Hood

To truly master Go, you need to understand the Go interface under the hood. How does the runtime know a struct satisfies an interface if it was never explicitly declared?

In the Go runtime code (written in C and Go), an interface is not just a pointer to an object. It is a two-word data structure known as an iface (for interfaces with methods) or an eface (for the empty interface any / interface{}).

When you assign a concrete type to an interface variable, Go constructs an iface struct that contains two pointers:

  1. tab (Type Word): A pointer to an itab (interface table) which holds metadata about the concrete type and a list of function pointers (the virtual method table) that map the interface's methods to the concrete type's methods.
  2. data (Data Word): A pointer to the actual concrete value in memory.
classDiagram
    class iface {
        *itab tab
        *void data
    }
    class itab {
        *interfacetype inter
        *_type _type
        uint32 hash
        [4]byte _
        [1]uintptr fun
    }
    class ConcreteValue {
        "Actual data (e.g., struct fields)"
    }

    iface --> itab : tab
    iface --> ConcreteValue : data

When you call a method on an interface, the Go runtime follows the tab pointer to find the correct function in the itab, and passes the data pointer to it as the receiver.

The magic of the Go interface architecture is that the itab is generated on the fly (and then cached) the first time a specific concrete type is assigned to a specific interface type. This dynamic generation is what makes implicit interfaces possible without sacrificing the performance of static typing.


The Power of Go Small Interfaces

Rob Pike, one of Go's creators, famously coined the proverb: "The bigger the interface, the weaker the abstraction."

In Java or C++, it is common to see massive interfaces with a dozen methods (IUserService, IDatabaseManager). In Go, the standard library sets a different precedent: Go small interfaces.

Contrasting a massive multi-tool with precision single-purpose tools

The most famous interfaces in Go are just one method long:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}
Enter fullscreen mode Exit fullscreen mode

Why is this so powerful? Because small interfaces are highly composable. If your function only needs to write data, it should accept an io.Writer, not a massive os.File or NetworkConnection struct.

By keeping interfaces small (1-3 methods), you maximize the number of types that can implicitly satisfy them, making your code vastly more reusable.


How Go Makes Testing Easier

The combination of implicit implementation and small interfaces revolutionizes Go unit testing interfaces.

In traditional OOP, if you want to mock a third-party class, that class must implement an interface provided by the third-party library. If they didn't provide one, you are stuck writing wrappers.

In Go, because interfaces are satisfied implicitly, you define the interface in your own testing package based strictly on the subset of methods you need.

Imagine you are using a massive third-party PaymentGateway struct with 50 methods, but your function only uses Charge().

// Third-party package (you can't change this)
package stripe

type PaymentGateway struct { /* ... */ }
func (p *PaymentGateway) Charge(amount float64) error { /* ... */ }
func (p *PaymentGateway) Refund(id string) error { /* ... */ }
// ... 48 more methods
Enter fullscreen mode Exit fullscreen mode

To test your code, you don't need to mock all 50 methods. You define a tiny, local interface right where you need it:

// Your package
package billing

// 1. Define the interface locally
type Charger interface {
    Charge(amount float64) error
}

// 2. Depend on the interface, not the third-party struct
func ProcessOrder(c Charger, amount float64) error {
    return c.Charge(amount)
}
Enter fullscreen mode Exit fullscreen mode

Now, the third-party stripe.PaymentGateway implicitly satisfies your local Charger interface, but in your test file, you can easily swap it out for a simple test double.


Fakes vs Mocks Go: A Pragmatic Approach

When writing test doubles for those interfaces, developers coming from other languages usually reach for Mocks (using tools like gomock or testify/mock). Mocks are dynamically generated objects that record how they were called and assert expectations (e.g., "Expect Charge() to be called exactly once with 50.00").

However, the Go community strongly leans towards fakes over mocks.

A Fake is a lightweight, working implementation of an interface built specifically for tests. It doesn't use reflection or assertion frameworks; it just uses plain Go logic.

Here is how you would write a fake for our Charger interface:

// billing_test.go
package billing

import "testing"

// Fake implementation
type FakeCharger struct {
    ChargedAmount float64
    ShouldFail    bool
}

func (f *FakeCharger) Charge(amount float64) error {
    if f.ShouldFail {
        return errors.New("insufficient funds")
    }
    f.ChargedAmount += amount
    return nil
}

func TestProcessOrder(t *testing.T) {
    // Arrange
    fake := &FakeCharger{}

    // Act
    err := ProcessOrder(fake, 50.00)

    // Assert
    if err != nil {
        t.Fatalf("expected no error, got %v", err)
    }
    if fake.ChargedAmount != 50.00 {
        t.Errorf("expected 50.00 to be charged, got %v", fake.ChargedAmount)
    }
}
Enter fullscreen mode Exit fullscreen mode

Why Fakes over Mocks in Go?

  1. Type Safety and Refactoring: Mocks often rely on string-based method names and interface{} arguments, which break silently during refactoring. Fakes are standard Go code—if the interface changes, the compiler catches the broken fake immediately.
  2. Readability: When comparing fakes vs mocks Go, fakes are much easier to read. You are just reading Go code, not deciphering a domain-specific mocking language.
  3. Statefulness: Fakes can easily maintain state (like an in-memory map replacing a database), allowing you to test complex state transitions that are notoriously difficult to set up with mock expectations.

Conclusion

Go interfaces are fundamentally different from what you might be used to in Java or C#. By removing the explicit implements keyword, Go encourages a bottom-up architecture where abstractions are discovered rather than designed upfront.

By understanding the two-word iface structure under the hood, embracing small single-method interfaces, and utilizing fakes for testing, you can write Go code that is highly decoupled, incredibly easy to test, and true to the idiomatic spirit of the language.

Top comments (2)

Collapse
 
officialmailkr profile image
오피셜메일 •

Charger를 소비자 쪽의 작은 인터페이스로 정의한 예제가 의도를 잘 보여주네요. FakeCharger의 ShouldFail 경로도 테스트에 추가해서, 오류가 반환될 때 ChargedAmount가 그대로인지 확인하면 성공 사례와 실패 시 상태 보존을 함께 설명할 수 있겠습니다. 중복 호출을 허용하는 업무라면 호출 횟수를 기록하는 필드도 별도로 둘지 궁금합니다.

Collapse
 
shyam-s00 profile image
Shiyam •

Thanks, good points! The article is mainly about how Go interfaces differ, so I kept the fake minimal, but you’re right that ShouldFail deserves a test. In a real suite I’d add a failure case asserting the error is propagated and nothing is charged. On duplicate calls: yes, a running sum can’t distinguish one charge of 50 from two of 25. Recording attempts in a slice (Calls []float64) would let the test assert both count and amounts, while staying a plain Go fake with no mocking framework. That might make a good follow-up post on testing patterns. :)