Interfaces in Go: small, implicit, and simpler than they look

Implicit interfaces in Go: composition, testing, decoupling, and common design mistakes. Think from the consumer.

Cover for Interfaces in Go: small, implicit, and simpler than they look

In Java, you declare interfaces upfront. You create the contract before writing a single line of implementation. In Go, interfaces are discovered after writing the code. This difference isn’t a syntax detail. It’s a completely different way of thinking about software design.

I come from working with Kotlin and Java for years. Explicit interfaces, implements, formal contracts, dependency injection with frameworks. Very ceremonious. When I started with Go, implicit interfaces felt strange. After using them in production, they feel like one of the best design decisions in the language.

If you come from the JVM or any language with explicit interfaces, this article is going to change how you think about decoupling. Because in Go, the question isn’t “what contract do I want to impose”, but “what behavior do I need to consume”.


Implicit satisfaction: no implements keyword

The first difference you notice coming from other languages is that Go doesn’t have the word implements. A type satisfies an interface automatically if it has all the methods the interface defines. Nothing more.

type Speaker interface {
	Speak() string
}

type Dog struct {
	Name string
}

func (d Dog) Speak() string {
	return d.Name + " says woof!"
}

type Cat struct {
	Name string
}

func (c Cat) Speak() string {
	return c.Name + " says meow!"
}

Dog and Cat satisfy Speaker without declaring it anywhere. No annotation, no registration, no explicit relationship. The compiler verifies it when you try to use a Dog where a Speaker is expected:

func MakeNoise(s Speaker) {
	fmt.Println(s.Speak())
}

func main() {
	MakeNoise(Dog{Name: "Rex"})  // works
	MakeNoise(Cat{Name: "Misi"}) // works
}

This has deep implications. You don’t need the type’s author to know what interfaces it will satisfy. You can define an interface in your package that a type from another package (or from the standard library) already satisfies without having been designed for it. It’s a kind of decoupling you simply can’t achieve with explicit interfaces.

Think of it this way: in Java, if you want a class to implement your interface, you need to modify that class or create a wrapper. In Go, you simply define the interface with the methods you need and any type that already has those methods automatically satisfies it.

// This works without os.File knowing anything about your interface
type ReadCloser interface {
	Read(p []byte) (n int, err error)
	Close() error
}

// *os.File already has Read and Close, so it satisfies ReadCloser
var rc ReadCloser = os.Stdout

Why small interfaces win

If you look at Go’s standard library, the most-used interfaces are tiny. One method. Two at most. This isn’t a coincidence. It’s a design decision that permeates the entire ecosystem.

io.Reader and io.Writer

The two most important interfaces in Go have a single method each:

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

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

One method. That’s all. And yet, Go’s entire I/O ecosystem is built on these two interfaces. Files, network connections, buffers, compression, encryption, HTTP bodies… everything implements io.Reader, io.Writer, or both.

Why does it work so well? Because a single-method interface is extremely easy to satisfy. Any type that can read bytes can be a Reader. Any type that can write bytes can be a Writer. The barrier to entry is minimal and the value of composition is maximal.

fmt.Stringer

type Stringer interface {
	String() string
}

If your type has a String() string method, you can use it directly with fmt.Println, fmt.Sprintf, and any formatting function. Without registering anything, without inheriting from any base class.

type Money struct {
	Amount   int
	Currency string
}

func (m Money) String() string {
	return fmt.Sprintf("%d %s", m.Amount, m.Currency)
}

func main() {
	price := Money{Amount: 42, Currency: "EUR"}
	fmt.Println(price) // "42 EUR"
}

error

The error interface is another perfect example:

type error interface {
	Error() string
}

One method. Any type that has Error() string is an error in Go. You can create custom errors without inheriting from a base class, without implementing ten methods you don’t need:

type NotFoundError struct {
	Resource string
	ID       string
}

func (e NotFoundError) Error() string {
	return fmt.Sprintf("%s with ID %s not found", e.Resource, e.ID)
}

The lesson is clear: the smaller the interface, the more types satisfy it, the more components you can compose, and the more flexible your system is. Rob Pike said it best: “The bigger the interface, the weaker the abstraction.”


Interface composition: combining small pieces

Go has no inheritance. You can’t extend an interface like in Java with extends. What you have is composition: embedding interfaces inside others to build larger contracts from small pieces.

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

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

type Closer interface {
	Close() error
}

// Composition: ReadWriter is a Reader AND a Writer
type ReadWriter interface {
	Reader
	Writer
}

// Broader composition
type ReadWriteCloser interface {
	Reader
	Writer
	Closer
}

This is exactly what the standard library does in the io package. Large interfaces are built by composing small ones. A type that satisfies ReadWriteCloser also satisfies Reader, Writer, Closer, ReadWriter, and any other combination. All implicitly.

The advantage over classical inheritance is that there’s no rigid hierarchy. You can create whatever combinations you need without depending on a predefined taxonomy:

// In your package, define exactly what you need
type ReadFlusher interface {
	io.Reader
	Flush() error
}

No one needed to have anticipated this combination. If a type has Read and Flush, it satisfies ReadFlusher. Period. This is what makes design in Go so organic: contracts are discovered, not imposed.


The empty interface: interface and any

The empty interface defines no methods. Therefore, all types satisfy it. It’s the equivalent of Object in Java or Any in Kotlin:

// Before Go 1.18
func Print(v interface{}) {
	fmt.Println(v)
}

// Since Go 1.18, any is an alias for interface{}
func Print(v any) {
	fmt.Println(v)
}

You can pass anything: an int, a string, a struct, a slice. Everything is any.

When to use any

It makes sense in very specific cases:

  • Logging or debugging functions: that accept anything
  • Generic serialization/deserialization: like json.Marshal(v any)
  • Generic containers: before generics existed

When not to use any

Most of the time. If you use any to avoid thinking about the type, you’re throwing away Go’s main advantage: the static type system. Every time you use any, you’re moving errors from the compiler to runtime.

// Bad: you lose all type safety
func Sum(a, b any) any {
	// You need type assertions, error handling...
	return a.(int) + b.(int) // panic if not int
}

// Good: with generics (Go 1.18+)
func Sum[T int | float64](a, b T) T {
	return a + b
}

Since Go 1.18 with generics, the need for any has decreased significantly. If you can express the type with generics, do it. The compiler will thank you with compile-time errors instead of production panics.

Every use of any should make you ask: “Can I express this with a specific interface or generics?” If the answer is yes, use that instead.


Accept interfaces, return structs

This is the most important design principle for interfaces in Go, and the hardest to internalize when coming from Java or Kotlin.

The idea is simple: functions should accept interfaces as parameters and return concrete types as results.

// Good: accepts an interface, returns a concrete type
func NewUserService(repo UserRepository) *UserService {
	return &UserService{repo: repo}
}

// Bad: returns an interface
func NewUserService(repo UserRepository) UserServiceInterface {
	return &UserService{repo: repo}
}

Why? Because accepting interfaces gives flexibility to the caller: they can pass any implementation that satisfies the contract. But returning concrete structs gives information to the receiver: they know exactly what type they have, can access all its methods (not just the interface’s), and the compiler can optimize better.

Returning interfaces hides information unnecessarily. The receiver has to work with a reduced contract when they could have access to everything. Only return interfaces when there’s a real reason to hide the implementation, such as in factories where the concrete implementation is an internal detail.

This principle applies especially to constructors and creation functions. If your NewX function returns an interface, you’re adding a layer of abstraction that probably nobody needs.


Interfaces for testing: mocking without frameworks

This is where Go’s implicit interfaces shine the brightest. In Java or Kotlin, to mock a dependency you need frameworks like Mockito, or generate dynamic proxies, or create manual implementations of explicit interfaces. In Go, you define an interface with the methods you use and create a struct that implements them. Nothing more.

Imagine a service that gets data from an external API:

// In your package, define the interface you need
type WeatherClient interface {
	GetTemperature(city string) (float64, error)
}

// Your service depends on the interface, not the implementation
type AlertService struct {
	weather WeatherClient
}

func NewAlertService(w WeatherClient) *AlertService {
	return &AlertService{weather: w}
}

func (s *AlertService) CheckHeatAlert(city string) (bool, error) {
	temp, err := s.weather.GetTemperature(city)
	if err != nil {
		return false, fmt.Errorf("getting temperature for %s: %w", city, err)
	}
	return temp > 40.0, nil
}

In production you use the real client that calls the API. In tests, you create a trivial mock:

type mockWeather struct {
	temp float64
	err  error
}

func (m *mockWeather) GetTemperature(city string) (float64, error) {
	return m.temp, m.err
}

func TestCheckHeatAlert_HighTemp(t *testing.T) {
	mock := &mockWeather{temp: 42.0}
	service := NewAlertService(mock)

	alert, err := service.CheckHeatAlert("Seville")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !alert {
		t.Error("expected heat alert for 42 degrees")
	}
}

func TestCheckHeatAlert_LowTemp(t *testing.T) {
	mock := &mockWeather{temp: 22.0}
	service := NewAlertService(mock)

	alert, err := service.CheckHeatAlert("Santiago")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if alert {
		t.Error("did not expect heat alert for 22 degrees")
	}
}

func TestCheckHeatAlert_Error(t *testing.T) {
	mock := &mockWeather{err: fmt.Errorf("API down")}
	service := NewAlertService(mock)

	_, err := service.CheckHeatAlert("Madrid")
	if err == nil {
		t.Error("expected error when API fails")
	}
}

No Mockito. No reflection. No code generation. A struct with the methods you need. The compiler verifies your mock satisfies the interface. If the interface changes tomorrow, your mock won’t compile and you know immediately.

The key is that the interface is defined by the consumer, not the provider. The AlertService service defines what it needs (WeatherClient) and any type that satisfies those methods works. The real HTTP client doesn’t know that interface exists. It doesn’t need to.

For deeper patterns on testing with interfaces, check what I explain in Go testing.


Common mistakes: premature, fat, and polluting interfaces

After seeing a lot of Go code from teams coming from Java or C#, there are three patterns that repeat themselves and that you should avoid.

1. Premature interfaces

The most frequent mistake is creating the interface before having a second implementation.

// Bad: you define the interface before needing it
type UserRepository interface {
	GetByID(id int) (*User, error)
	Create(user *User) error
	Update(user *User) error
	Delete(id int) error
	List(filter Filter) ([]*User, error)
	Count(filter Filter) (int, error)
}

// And then you only have one implementation
type PostgresUserRepository struct { ... }

If you only have one implementation, you don’t need the interface yet. Wait until the real need appears: a second storage, a mock for tests, a cache that wraps the repository. Then extract the interface with the methods you actually need.

The exception: if you already know you’ll need mocks for testing, define the interface from the start. But define only the methods your consumer needs, not all the ones the repository offers.

2. Fat interfaces

Directly related to the above. An interface with ten methods is a warning sign:

// Bad: interface is too large
type DataStore interface {
	GetUser(id int) (*User, error)
	CreateUser(user *User) error
	UpdateUser(user *User) error
	DeleteUser(id int) error
	GetOrder(id int) (*Order, error)
	CreateOrder(order *Order) error
	UpdateOrder(order *Order) error
	DeleteOrder(id int) error
	GetProduct(id int) (*Product, error)
	CreateProduct(product *Product) error
	// ... and it goes on
}

This is what happens when you think about the interface from the provider’s perspective (“what can my store do”) instead of from the consumer’s (“what does this handler need”).

The solution: segregated interfaces, defined where they’re consumed.

// Good: each consumer defines what it needs
type UserGetter interface {
	GetUser(id int) (*User, error)
}

type UserCreator interface {
	CreateUser(user *User) error
}

// Your handler only depends on what it uses
type UserHandler struct {
	getter  UserGetter
	creator UserCreator
}

If your database struct implements GetUser and CreateUser, it satisfies both interfaces implicitly. You don’t need to split the implementation. You only split the contract at the point of consumption.

3. Interface pollution

This happens when you create an interface for every struct “just in case”. It’s a very common anti-pattern in teams coming from Java where Spring forces you to have a Service and ServiceImpl for everything.

// Bad: one interface per struct, no justification
type UserService interface {
	GetUser(id int) (*User, error)
}

type userServiceImpl struct { ... }

// And there's only one implementation, used in one place

In Go, if you don’t need polymorphism or testing with mocks, you don’t need the interface. Use the concrete type directly. You can extract the interface later without changing the implementation, thanks to implicit satisfaction.

The golden rule: don’t design interfaces, discover them. When you have two implementations or need a mock, that’s the moment to extract the interface. Not before.


Real example: designing interfaces for a backend service

Let’s look at a practical case. You have a task management service with a REST API. You need database access, a notification system, and logging. Let’s design the interfaces thinking from the consumer.

The handler defines what it needs

package task

import (
	"context"
	"time"
)

type Task struct {
	ID          string
	Title       string
	Description string
	Status      string
	AssignedTo  string
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

// The creation handler needs to store and notify
type TaskCreator interface {
	Create(ctx context.Context, task *Task) error
}

type Notifier interface {
	Notify(ctx context.Context, userID string, message string) error
}

The handler

type CreateHandler struct {
	store    TaskCreator
	notifier Notifier
}

func NewCreateHandler(store TaskCreator, notifier Notifier) *CreateHandler {
	return &CreateHandler{store: store, notifier: notifier}
}

func (h *CreateHandler) Handle(ctx context.Context, task *Task) error {
	if task.Title == "" {
		return fmt.Errorf("task title cannot be empty")
	}

	task.ID = generateID()
	task.Status = "pending"
	task.CreatedAt = time.Now()
	task.UpdatedAt = task.CreatedAt

	if err := h.store.Create(ctx, task); err != nil {
		return fmt.Errorf("storing task: %w", err)
	}

	if task.AssignedTo != "" {
		msg := fmt.Sprintf("New task assigned: %s", task.Title)
		if err := h.notifier.Notify(ctx, task.AssignedTo, msg); err != nil {
			// Log but don't fail: the task is already created
			log.Printf("failed to notify user %s: %v", task.AssignedTo, err)
		}
	}

	return nil
}

The real implementations

// PostgreSQL store - satisfies TaskCreator implicitly
type PostgresStore struct {
	db *sql.DB
}

func (s *PostgresStore) Create(ctx context.Context, task *Task) error {
	query := `INSERT INTO tasks (id, title, description, status, assigned_to, created_at, updated_at)
	           VALUES ($1, $2, $3, $4, $5, $6, $7)`
	_, err := s.db.ExecContext(ctx, query,
		task.ID, task.Title, task.Description, task.Status,
		task.AssignedTo, task.CreatedAt, task.UpdatedAt)
	return err
}

// Email notifier - satisfies Notifier implicitly
type EmailNotifier struct {
	smtpAddr string
}

func (n *EmailNotifier) Notify(ctx context.Context, userID string, message string) error {
	// Send real email...
	return nil
}

The tests

type mockStore struct {
	tasks []*Task
	err   error
}

func (m *mockStore) Create(_ context.Context, task *Task) error {
	if m.err != nil {
		return m.err
	}
	m.tasks = append(m.tasks, task)
	return nil
}

type mockNotifier struct {
	notifications []string
	err           error
}

func (m *mockNotifier) Notify(_ context.Context, userID string, message string) error {
	if m.err != nil {
		return m.err
	}
	m.notifications = append(m.notifications, fmt.Sprintf("%s: %s", userID, message))
	return nil
}

func TestCreateHandler_Success(t *testing.T) {
	store := &mockStore{}
	notifier := &mockNotifier{}
	handler := NewCreateHandler(store, notifier)

	task := &Task{Title: "Deploy v2", AssignedTo: "user-123"}
	err := handler.Handle(context.Background(), task)

	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(store.tasks) != 1 {
		t.Errorf("expected 1 stored task, got %d", len(store.tasks))
	}
	if len(notifier.notifications) != 1 {
		t.Errorf("expected 1 notification, got %d", len(notifier.notifications))
	}
	if task.Status != "pending" {
		t.Errorf("expected status pending, got %s", task.Status)
	}
}

func TestCreateHandler_EmptyTitle(t *testing.T) {
	handler := NewCreateHandler(&mockStore{}, &mockNotifier{})

	err := handler.Handle(context.Background(), &Task{})
	if err == nil {
		t.Error("expected error for empty title")
	}
}

func TestCreateHandler_StoreError(t *testing.T) {
	store := &mockStore{err: fmt.Errorf("connection refused")}
	handler := NewCreateHandler(store, &mockNotifier{})

	err := handler.Handle(context.Background(), &Task{Title: "Test"})
	if err == nil {
		t.Error("expected error when store fails")
	}
}

Notice how each piece is independent. The handler doesn’t know if the store is PostgreSQL, MongoDB, or an in-memory map. It doesn’t know if the notifier sends emails, push notifications, or Slack messages. It only knows what methods it needs. This is clean architecture in Go in practice, and implicit interfaces make it possible without ceremony.


Interfaces vs generics: when to use each

Since Go 1.18, generics are available and there’s confusion about when to use interfaces and when to use generics. The answer is simpler than it looks: they solve different problems.

Interfaces: behavior

Use interfaces when you care about what a type can do:

// I care that it can read bytes
type Reader interface {
	Read(p []byte) (n int, err error)
}

// I care that it can be saved
type Saver interface {
	Save(ctx context.Context) error
}

Generics: types

Use generics when you need to operate on multiple types with the same logic:

// I need to find an element in a slice, regardless of the type
func Contains[T comparable](slice []T, target T) bool {
	for _, v := range slice {
		if v == target {
			return true
		}
	}
	return false
}

// I need a concurrent map that works with any key and value
type SafeMap[K comparable, V any] struct {
	mu sync.RWMutex
	m  map[K]V
}

The dividing line

SituationUse
You need runtime polymorphismInterfaces
You need the same logic for different typesGenerics
You need decoupling for testingInterfaces
You need type-safe collectionsGenerics
You need dependency injectionInterfaces
You need generic algorithms (sort, filter, map)Generics

You can combine both. Generic constraint interfaces are interfaces that define both methods and allowed types:

type Number interface {
	~int | ~int64 | ~float64
}

func Sum[T Number](values []T) T {
	var total T
	for _, v := range values {
		total += v
	}
	return total
}

The practical advice: start with concrete types. If you need polymorphism or decoupling, extract an interface. If you need to reuse logic for different types, use generics. Don’t start with the abstraction. For more details on generics, check Go generics.


Comparison with interfaces in Java and Kotlin

If you come from Java or Kotlin, there are conceptual differences that change how you design.

Java: explicit and rigid contracts

// Java: the interface is defined first
public interface UserRepository {
    User findById(int id);
    void save(User user);
    void delete(int id);
    List<User> findAll();
}

// The implementation explicitly declares that it fulfills it
public class PostgresUserRepository implements UserRepository {
    @Override
    public User findById(int id) { ... }
    @Override
    public void save(User user) { ... }
    @Override
    public void delete(int id) { ... }
    @Override
    public List<User> findAll() { ... }
}

The problem: if you only need findById at one point in the code, you still drag along all four methods. To segregate, you need to create new interfaces and have the implementation explicitly declare them. This discourages segregation because every new interface requires changes in the implementing class.

Kotlin: better than Java, same model

// Kotlin: cleaner, same model
interface UserRepository {
    fun findById(id: Int): User?
    fun save(user: User)
}

class PostgresUserRepository : UserRepository {
    override fun findById(id: Int): User? { ... }
    override fun save(user: User) { ... }
}

Kotlin improves ergonomics with interface properties, default methods, and delegation. But the model is still explicit: the class has to declare what interfaces it implements.

Go: implicit and discovered contracts

// Go: define the interface where you need it
type UserFinder interface {
	FindByID(ctx context.Context, id int) (*User, error)
}

// PostgresStore already has FindByID, so it satisfies UserFinder
// without needing to modify anything
type PostgresStore struct {
	db *sql.DB
}

func (s *PostgresStore) FindByID(ctx context.Context, id int) (*User, error) {
	// ...
}

func (s *PostgresStore) Save(ctx context.Context, user *User) error {
	// ...
}

func (s *PostgresStore) Delete(ctx context.Context, id int) error {
	// ...
}

The key difference: in Go each consumer can define its own interface with only the methods it needs. A handler that only reads users depends on UserFinder. Another handler that creates users depends on UserCreator. Both interfaces are satisfied by the same PostgresStore without it knowing they exist.

Comparison table

AspectJavaKotlinGo
SatisfactionExplicit (implements)Explicit (:)Implicit
DefinitionProvider decidesProvider decidesConsumer decides
SegregationRequires changes in implementationRequires changes in implementationNo changes in implementation
Interface inheritanceextends:Composition (embedding)
Default methodsYes (Java 8+)YesNo
Functional interfacesYes (@FunctionalInterface)Yes (lambdas)Not needed (functions are first-class citizens)
Fields in interfacesNoYes (properties)No

Go’s model is more restrictive in features (no default methods, no fields) but more flexible in composition and decoupling. You can’t do everything you can do with interfaces in Kotlin, but what you can do is simpler and more decoupled.


Principles for designing interfaces in Go

After everything above, these are the principles I follow in production:

1. Define interfaces where they’re consumed, not where they’re implemented. The package that uses the dependency is the one that defines the interface. Not the package that provides it.

2. Start without interfaces. Use concrete types until you need real decoupling. Extracting an interface later is trivial in Go.

3. Keep interfaces small. One or two methods is ideal. If it has more than three, ask yourself if you can split it.

4. Accept interfaces, return structs. Your public functions accept interfaces for flexibility and return concrete types for clarity.

5. Name interfaces by what they do, not what they are. Reader, Writer, Closer, Stringer. The -er suffix for single-method interfaces is a convention that works.

6. Don’t create interfaces “just in case”. Every interface adds a layer of indirection. If you don’t need it today, don’t create it.

7. Use composition to build larger interfaces. Combine small interfaces instead of creating monolithic ones.

These principles align with the Go project structure I describe in another article: small packages with clear responsibilities and dependencies expressed through interfaces defined by the consumer.


Don’t design interfaces, discover them

Go’s interfaces are one of those things that seem too simple until you use them in a real project. No implements, no dependency injection frameworks, no code generation for mocks. Just an implicit contract that the compiler verifies.

The key is changing your mindset. Don’t think “what contract do I want my implementation to fulfill”. Think “what behavior does my function need to do its job”. Define that behavior as a one- or two-method interface in the package that consumes it. Let implicit satisfaction do the rest.

If you come from Java or Kotlin, this is going to feel uncomfortable for the first few weeks. Not having explicit implements feels like driving without a seatbelt. But after a while you realize the compiler is still verifying everything, tests are written faster, decoupling is more real, and the code is simpler.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved