sch39
Part 5 of 6 — Go Fundamentals for Developers

Go Interfaces: Implicit Implementation and Practical Design

Oct 2026 · 5 min read

Go interfaces are satisfied implicitly by types with a matching method set, without an implements declaration. Small interfaces on the consumer side keep dependencies narrow, while the method set difference between T and *T determines which value satisfies the contract.

TL;DR: Go interfaces are satisfied implicitly by types with a matching method set, without an implements declaration. Interfaces on the consumer side keep dependencies narrow, while the method set difference between T and *T determines which value satisfies the contract. Full code: https://github.com/Sch39/go-series

Problem

Interfaces designed from the implementer side often grow too large and bind consumers to methods they don't use. Pointer receivers can also make a value T fail to satisfy an interface even though *T succeeds.

Outcome

Readers can:

  • Explain how a type satisfies an interface implicitly.

  • Design small interfaces based on consumer needs.

  • Distinguish the method sets of T and *T, and recognize interface values that are truly nil.

Implementation

Folder Structure

text
go-series/G01-05-interfaces/
├── go.mod
└── contracts/
    ├── contracts.go
    └── contracts_test.go

Setup

Initialize a single module (run from the go-series/ folder):

bash
mkdir -p G01-05-interfaces
cd G01-05-interfaces

go mod init github.com/sch39/go-series/g0105/interfaces

Code Blocks

Block 1: Implicit implementation — the method set satisfies the contract

go-series/G01-05-interfaces/contracts/contracts.go:

go
package contracts

import "fmt"

type Notifier interface {
	Notify(message string) string
}

type EmailNotifier struct {
	Address string
}

func (e EmailNotifier) Notify(message string) string {
	return fmt.Sprintf("email to %s: %s", e.Address, message)
}

var _ Notifier = EmailNotifier{}

Goal: Prove that EmailNotifier satisfies Notifier with only the matching method and a compile-time assertion.

Block 2: Interface on the consumer side — minimal dependency

go-series/G01-05-interfaces/contracts/contracts.go:

go
type AlertService struct{}

func (AlertService) Send(notifier Notifier, message string) string {
	return notifier.Notify(message)
}

type MessageSender interface {
	Notify(message string) string
}

func Deliver(sender MessageSender, message string) string {
	return sender.Notify(message)
}

Goal: Show that the consumer only needs to define a contract as small as the behavior it actually uses.

Block 3: Pointer receiver — only the pointer satisfies the interface

go-series/G01-05-interfaces/contracts/contracts.go:

go
type PushNotifier struct {
	Token string
}

func (p *PushNotifier) Notify(message string) string {
	return fmt.Sprintf("push to %s: %s", p.Token, message)
}

var _ Notifier = (*PushNotifier)(nil)

Goal: Prove that a method with a pointer receiver puts Notify into the method set of *PushNotifier, not PushNotifier.

Block 4: Nil interface — type and value determine the comparison result

go-series/G01-05-interfaces/contracts/contracts.go:

go
type StatusError struct {
	Code int
}

func (e *StatusError) Error() string {
	return fmt.Sprintf("status %d", e.Code)
}

func LoadStatus(ok bool) error {
	if ok {
		return nil
	}
	return &StatusError{Code: 503}
}

Goal: Show that the function must return an explicit nil so that the error interface has neither a dynamic type nor a dynamic value.

Test Blocks

Test 1: Implicit implementation and consumer-owned interface

go-series/G01-05-interfaces/contracts/contracts_test.go:

go
package contracts_test

import (
	"testing"

	"github.com/sch39/go-series/g0105/interfaces/contracts"
)

type recordingNotifier struct {
	message string
}

func (r *recordingNotifier) Notify(message string) string {
	r.message = message
	return "recorded: " + message
}

func TestImplicitImplementationAndConsumerInterface(t *testing.T) {
	service := contracts.AlertService{}
	email := contracts.EmailNotifier{Address: "dev@example.com"}

	gotEmail := service.Send(email, "build failed")
	recorder := &recordingNotifier{}
	gotRecord := contracts.Deliver(recorder, "deploy ready")

	t.Logf("email=%q", gotEmail)
	t.Logf("record=%q captured=%q", gotRecord, recorder.message)

	if gotEmail != "email to dev@example.com: build failed" {
		t.Errorf("Send() = %q, want email notification", gotEmail)
	}
	if recorder.message != "deploy ready" {
		t.Errorf("Deliver() captured %q, want %q", recorder.message, "deploy ready")
	}
}

Goal: Prove that both the production concrete type and the test double can satisfy the same interface without any explicit relationship declaration.

Test 2: Pointer receiver method set

go-series/G01-05-interfaces/contracts/contracts_test.go:

go
func TestPointerReceiverMethodSet(t *testing.T) {
	service := contracts.AlertService{}
	push := &contracts.PushNotifier{Token: "device-42"}

	got := service.Send(push, "maintenance")
	t.Logf("notification=%q concrete=%T", got, push)

	if got != "push to device-42: maintenance" {
		t.Errorf("Send() = %q, want push notification", got)
	}
}

Goal: Prove that *PushNotifier satisfies Notifier when its contract method uses a pointer receiver.

Test 3: Nil interface and concrete errors

go-series/G01-05-interfaces/contracts/contracts_test.go:

go
func TestNilInterface(t *testing.T) {
	var err error
	okErr := contracts.LoadStatus(true)
	failedErr := contracts.LoadStatus(false)

	t.Logf("zero interface: value=%v type=%T nil=%t", err, err, err == nil)
	t.Logf("success: value=%v type=%T nil=%t", okErr, okErr, okErr == nil)
	t.Logf("failure: value=%v type=%T nil=%t", failedErr, failedErr, failedErr == nil)

	if err != nil || okErr != nil {
		t.Errorf("nil errors must compare equal to nil: zero=%v success=%v", err, okErr)
	}
	if failedErr == nil {
		t.Error("LoadStatus(false) returned nil error")
	}
}

Goal: Prove that an interface without a dynamic type and value is nil, whereas a concrete error produces a non-nil interface.

Run Test

Run from the go-series/G01-05-interfaces/ folder:

bash
go test -count=1 -v ./...

Output:

text
=== RUN   TestImplicitImplementationAndConsumerInterface
    contracts_test.go:26: email="email to dev@example.com: build failed"
    contracts_test.go:27: record="recorded: deploy ready" captured="deploy ready"
--- PASS: TestImplicitImplementationAndConsumerInterface (0.00s)
=== RUN   TestPointerReceiverMethodSet
    contracts_test.go:42: notification="push to device-42: maintenance" concrete=*contracts.PushNotifier
--- PASS: TestPointerReceiverMethodSet (0.00s)
=== RUN   TestNilInterface
    contracts_test.go:54: zero interface: value=<nil> type=<nil> nil=true
    contracts_test.go:55: success: value=<nil> type=<nil> nil=true
    contracts_test.go:56: failure: value=status 503 type=*contracts.StatusError nil=false
--- PASS: TestNilInterface (0.00s)
PASS
ok  	github.com/sch39/go-series/g0105/interfaces/contracts	3.974s

Why It Works

Implicit implementation: A type satisfies an interface when its method set includes all of that interface's methods. There is no implements declaration, so EmailNotifier and the test double can satisfy the same contract without knowing each other.

Consumer-owned interface: The consumer defines the minimum behavior it uses. A one-method contract means both the production implementation and the test double only need to provide Notify, without depending on additional methods of the concrete type.

Method set: The method set of T contains methods with receiver T, while the method set of *T contains methods with receivers T and *T. Because PushNotifier.Notify uses the *PushNotifier receiver, only its pointer satisfies Notifier.

Nil interface: An interface is nil only when both the dynamic type and dynamic value are absent. An interface holding a typed pointer remains non-nil even if that pointer value is nil, so the success path returns an explicit nil.

Edge Cases

Signatures must be identical: Having a method with the same name is not enough; the parameters and return types must be identical to the contract. A method that returns a concrete type does not satisfy an interface that requires a different interface return type.

Typed nil inside an interface: var p *StatusError = nil; var err error = p results in err != nil because the dynamic type *StatusError is still stored.

Interface too large: Adding a single method breaks all implementations, test doubles, and adapters that previously satisfied the contract.

Trade-offs

Strategy

Pro

Con

Small interface on the consumer

Narrow dependency and simple test doubles

Similar interfaces can appear in multiple packages

Large interface on the implementer

A single centralized contract

Consumers depend on methods they don't use

Concrete type parameter

Direct API with all capabilities available

Hard to swap implementations without an adapter or signature change

Best practice: Define the interface in the consumer package when there is more than one real implementation or a useful test double; if the consumer only needs a concrete type, don't create a premature interface.

Production Checklist

  • Does the consumer only use part of the behavior? Define an interface as small as that need.

  • Does the interface receiver use a pointer? Make sure the call site provides *T, not T.

  • Does the function return an interface such as error? Return an explicit nil on the success path.

  • Must an important contract fail when the implementation changes? Add a var _ Interface = (*Type)(nil) assertion.

References