Free Ebook cover Go (Golang) Fundamentals: Simple, Fast Programs for Beginners

Go (Golang) Fundamentals: Simple, Fast Programs for Beginners

New course

12 pages

Interfaces in Go: Decoupling Code with Small, Focused Contracts

Capítulo 8

Estimated reading time: 8 minutes

+ Exercise

1) Defining Interfaces and Implicit Implementation

An interface in Go is a set of method signatures. Any type that has those methods automatically satisfies the interface—there is no implements keyword. This is called implicit implementation, and it enables loose coupling: your code can depend on behavior (methods) instead of concrete types.

Define an interface by listing the methods you need:

package main

import "fmt"

type Greeter interface {
	Greet(name string) string
}

type EnglishGreeter struct{}

func (EnglishGreeter) Greet(name string) string {
	return "Hello, " + name
}

type SpanishGreeter struct{}

func (SpanishGreeter) Greet(name string) string {
	return "Hola, " + name
}

func SayHello(g Greeter, name string) {
	fmt.Println(g.Greet(name))
}

func main() {
	SayHello(EnglishGreeter{}, "Ada")
	SayHello(SpanishGreeter{}, "Ada")
}

EnglishGreeter and SpanishGreeter satisfy Greeter simply by having a Greet method with the same signature. This makes it easy to add new implementations without changing the code that uses the interface.

Interface-driven design mindset

Instead of starting with a big struct and exposing it everywhere, start by asking: “What behavior does this part of the program need?” Then define an interface that captures only that behavior. Your functions and components depend on the interface, not the implementation details.

2) Prefer Small Interfaces (io.Reader-style Thinking)

Go encourages small, focused interfaces. A classic example is io.Reader from the standard library:

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

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

That’s it: one method. Because it’s tiny, many types can implement it: files, network connections, in-memory buffers, compressed streams, and more. This is powerful because you can write one function that works with all of them.

Designing your own small interfaces

Suppose you’re writing code that needs to fetch bytes from somewhere. A “large” interface might try to cover every possible operation:

type DataSource interface {
	Open() error
	Close() error
	ReadAll() ([]byte, error)
	Size() int64
	Name() string
}

This is hard to satisfy and forces implementations to provide methods they may not naturally have. Instead, define the smallest interface your function needs. If you only need reading, use io.Reader or something similarly small:

import "io"

type ByteSource interface {
	io.Reader
}

Often you don’t even need a new interface—reusing standard library interfaces improves interoperability.

Composing small interfaces

When you need more behavior, compose interfaces rather than creating a monolith. The standard library does this frequently (for example, io.ReadWriter is a combination of io.Reader and io.Writer):

type ReadWriter interface {
	Reader
	Writer
}

In your own code, you can do the same: define minimal interfaces and combine them only where required.

3) Accept Interfaces, Return Concrete Types

A common Go guideline is: accept interfaces as parameters, but return concrete types from constructors and factories. This gives callers flexibility (they can pass anything that matches the interface) while keeping your API simple and discoverable (callers get a real type with known methods and fields).

Accept interfaces: flexible inputs

If a function only needs to read data, accept an io.Reader:

import "io"

func CountBytes(r io.Reader) (int64, error) {
	// implementation omitted
	return 0, nil
}

This function can now work with a file, a network stream, a string reader, or a test double.

Return concrete types: stable outputs

When you create something, returning a concrete type helps users understand what they got and avoids forcing them into interface assertions. For example, if you build a custom reader, return the concrete type (or a pointer to it):

type LogReader struct {
	// internal fields
}

func NewLogReader(/* params */) *LogReader {
	return &LogReader{}
}

Callers can still treat it as an io.Reader when needed:

var r io.Reader = NewLogReader()

Why not return an interface? Because returning an interface can hide useful methods and make future extensions harder. If you return a concrete type, you can add methods later without breaking callers.

4) Type Assertions and Type Switches

Interfaces store a dynamic value and its dynamic type. Sometimes you need to access behavior that is not part of the interface you accepted. Go provides type assertions and type switches for this, but they should be used carefully: frequent assertions can be a sign that your interface is missing a needed method or that you’re mixing responsibilities.

Type assertion with the ok pattern

A type assertion checks whether an interface value holds a specific concrete type (or another interface type). The safe form uses the ok result:

import "io"

func TryGetSeeker(r io.Reader) {
	// Check whether r also supports seeking
	if s, ok := r.(io.Seeker); ok {
		_, _ = s.Seek(0, 0)
		return
	}
	// If not, continue without seeking
}

Use this when the extra capability is optional and you can provide a fallback path.

Avoid the panic form unless you truly require the type

This form panics if the assertion fails:

s := r.(io.Seeker) // panics if r is not an io.Seeker

Prefer the ok pattern unless failing is a programmer error and you want an immediate crash in development.

Type switches for multiple cases

When you need to branch based on several possible dynamic types, use a type switch:

func Describe(v any) string {
	switch x := v.(type) {
	case string:
		return "string of length " + fmt.Sprint(len(x))
	case int:
		return "int: " + fmt.Sprint(x)
	case nil:
		return "nil"
	default:
		return "unknown"
	}
}

Type switches are useful at boundaries (for example, decoding unknown JSON shapes, handling any inputs, or adapting to optional interfaces). Inside core business logic, prefer designing the right interface instead of switching on types.

When to avoid overusing assertions

  • If you repeatedly assert r.(SomeConcreteType), you likely should accept that concrete type directly or redesign the interface.
  • If you assert for optional capabilities in many places, consider accepting a richer interface in the specific function that needs it (for example, accept io.ReadSeeker instead of io.Reader).
  • If you use type switches to implement “manual polymorphism,” consider whether a method on an interface would be clearer.

5) Practical Lab: Process Data from Any io.Reader

You will build a function that reads from any io.Reader, processes the data, and returns a result. Then you will provide two concrete sources: a string-based reader and a file-based reader. The key goal is substitutability: the processing function should not care where the bytes come from.

Step 1: Define the processing function

This function reads all data from an io.Reader, counts lines and bytes, and returns a small report. It accepts an interface (io.Reader) and returns a concrete struct.

package main

import (
	"bufio"
	"fmt"
	"io"
)

type Report struct {
	Bytes int
	Lines int
}

func Process(r io.Reader) (Report, error) {
	scanner := bufio.NewScanner(r)

	lines := 0
	bytes := 0

	for scanner.Scan() {
		lines++
		// Scanner drops the newline; count the line bytes only.
		bytes += len(scanner.Bytes())
	}
	if err := scanner.Err(); err != nil {
		return Report{}, err
	}

	return Report{Bytes: bytes, Lines: lines}, nil
}

func main() {
	fmt.Println("Run the examples by wiring different readers.")
}

Notes:

  • bufio.Scanner is convenient for line-based processing.
  • The function does not mention files or strings; it only depends on io.Reader.

Step 2: Implement a string source (in-memory)

For a string, you can use strings.NewReader, which returns a concrete type that implements io.Reader (and more, like io.Seeker).

package main

import (
	"fmt"
	"strings"
)

func ExampleStringSource() {
	input := "first line\nsecond line\nthird line\n"
	r := strings.NewReader(input)

	report, err := Process(r)
	if err != nil {
		panic(err)
	}
	fmt.Printf("string source: %+v\n", report)
}

Because strings.NewReader returns a value that satisfies io.Reader, it can be passed directly to Process.

Step 3: Implement a file source (os.File)

Files also implement io.Reader. Open a file and pass it to the same function. Use defer to close it.

package main

import (
	"fmt"
	"os"
)

func ExampleFileSource(path string) {
	f, err := os.Open(path)
	if err != nil {
		panic(err)
	}
	defer f.Close()

	report, err := Process(f)
	if err != nil {
		panic(err)
	}
	fmt.Printf("file source: %+v\n", report)
}

The processing logic is identical. Only the source changes.

Step 4: Demonstrate substitutability in one main

Wire both examples together to prove that Process is decoupled from the data source:

package main

import (
	"fmt"
	"os"
	"strings"
)

func main() {
	// 1) String reader
	s := strings.NewReader("a\nb\nc\n")
	rep1, err := Process(s)
	if err != nil {
		panic(err)
	}
	fmt.Println("from string:", rep1)

	// 2) File reader (create a temp file for the demo)
	f, err := os.CreateTemp("", "demo-*.txt")
	if err != nil {
		panic(err)
	}
	defer os.Remove(f.Name())
	defer f.Close()

	_, _ = f.WriteString("one\ntwo\n")
	_, _ = f.Seek(0, 0)

	rep2, err := Process(f)
	if err != nil {
		panic(err)
	}
	fmt.Println("from file:", rep2)
}

Step 5: Optional interface check (type assertion) for extra capability

If you want Process to optionally reset the reader to the start (only when supported), you can use a type assertion to io.Seeker with the ok pattern. This keeps Process compatible with readers that cannot seek (like network streams).

import "io"

func ProcessTwiceIfSeekable(r io.Reader) (Report, Report, error) {
	first, err := Process(r)
	if err != nil {
		return Report{}, Report{}, err
	}

	if s, ok := r.(io.Seeker); ok {
		if _, err := s.Seek(0, 0); err != nil {
			return first, Report{}, err
		}
		second, err := Process(r)
		return first, second, err
	}

	// Not seekable: return only the first report; second is zero.
	return first, Report{}, nil
}

This is a good use of type assertions: the extra behavior is optional, and the function still works without it.

Now answer the exercise about the content:

Why is it recommended to define small, focused interfaces in Go (similar to io.Reader) instead of large “do-everything” interfaces?

You are right! Congratulations, now go to the next page

You missed! Try again.

Small interfaces capture only required behavior, so many types can implement them naturally. This improves decoupling and lets the same function work with different sources (files, strings, streams) without changing the processing logic.

Next chapter

Error Handling in Go: Returning Errors, Wrapping, and Clean Control Flow

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.