Interfaces in Go: klein, implizit und richtig angewendet

1. Übersicht

Interfaces in Go unterscheiden sich von Java oder C# in einem wesentlichen Punkt: es gibt kein implements-Schlüsselwort. Ein Typ erfüllt eine Interface automatisch, wenn er die notwendigen Methoden hat. Diese Besonderheit führt zu einem eigenen Design-Ansatz: kleine Interfaces, deklariert am Ort der Verwendung, nicht der Implementierung.

Nach diesem Artikel wirst du wissen, wie man Interfaces im Go-Stil schreibt, wie man sie zum Testen ohne Mock-Bibliotheken nutzt, und wie man häufige Fehler wie “nil, aber nicht nil” vermeidet.

2. Interfaces werden implizit erfüllt

Schauen wir uns zuerst ein Interface an, das Benachrichtigungen an Kunden versendet:

package main

import "fmt"

type Notifier interface {
	Notify(customer, message string) error
}

type EmailNotifier struct {
	From string
}

func (e EmailNotifier) Notify(customer, message string) error {
	fmt.Printf("[E-Mail von %s] an %s: %s\n", e.From, customer, message)
	return nil
}

type SMSNotifier struct{}

func (SMSNotifier) Notify(customer, message string) error {
	fmt.Printf("[SMS] an %s: %s\n", customer, message)
	return nil
}

func main() {
	notifiers := []Notifier{
		EmailNotifier{From: "shop@nonla.dev"},
		SMSNotifier{},
	}
	for _, n := range notifiers {
		_ = n.Notify("An", "Bestellung #42 wurde bestätigt")
	}
}

Ergebnis:

[E-Mail von shop@nonla.dev] an An: Bestellung #42 wurde bestätigt
[SMS] an An: Bestellung #42 wurde bestätigt

Nirgendwo in EmailNotifier oder SMSNotifier wird die Notifier-Interface erwähnt. Der Compiler überprüft selbstständig: jeder Typ mit einer Methode Notify(string, string) error kann einer Variablen vom Typ Notifier zugewiesen werden.

Die wichtige Konsequenz ist: die Implementierungsseite muss die Interface gar nicht kennen. Du kannst eine neue Interface für einen vorhandenen Typ aus der Standardbibliothek oder einer Drittanbieter-Bibliothek deklarieren, ohne deren Code zu ändern.

3. Interfaces klein halten

Die Go-Standardbibliothek ist dafür das beste Beispiel. io.Reader und io.Writer haben jeweils nur genau eine Methode:

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

Weil sie so klein sind, erfüllen sehr viele Typen sie: *os.File, *bytes.Buffer, *strings.Builder, http.ResponseWriter, *gzip.Writer… Eine Funktion, die io.Writer akzeptiert, funktioniert mit all diesen Typen:

package main

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

type Order struct {
	ID       int
	Customer string
	Total    int64
}

func WriteReceipt(w io.Writer, o Order) error {
	_, err := fmt.Fprintf(w, "Bestellung #%d - %s - %d VND\n", o.ID, o.Customer, o.Total)
	return err
}

func main() {
	order := Order{ID: 42, Customer: "An", Total: 350000}

	_ = WriteReceipt(os.Stdout, order) // Ausgabe ins Terminal

	var sb strings.Builder
	_ = WriteReceipt(&sb, order) // In den Speicher schreiben
	fmt.Printf("Länge der Quittung: %d Bytes\n", sb.Len())
}
Bestellung #42 - An - 350000 VND
Länge der Quittung: 26 Bytes

WriteReceipt benötigt nur die Möglichkeit, “Bytes zu schreiben”, also fordert sie nur genau das an. Ob du in eine Datei, HTTP-Response oder eine gezippte Datei schreibst – die Funktion muss sich nicht ändern.

Wenn du mehrere Verhaltensweisen brauchst, kombiniere kleine Interfaces statt gleich eine große zu schreiben:

type OrderFinder interface {
	FindOrder(id int) (Order, error)
}

type OrderSaver interface {
	SaveOrder(o Order) error
}

type OrderStore interface {
	OrderFinder
	OrderSaver
}

Eine Funktion zum Anzeigen einer Bestellung akzeptiert nur OrderFinder. Eine zum Aktualisieren des Status akzeptiert OrderStore. Jede Funktion deklariert deutlich, was sie braucht, und das Schreiben von Fakes zum Testen wird viel leichter.

4. Interfaces auf der Konsumentenseite deklarieren

In Java schreibst du üblicherweise PaymentGateway und dann StripePaymentGateway implements PaymentGateway, beide nebeneinander. In Go ist es oft anders herum: Das Implementierungs-Package gibt einen konkreten Struct zurück, das nutzende Package deklariert die Interface, die es braucht.

Nehmen wir an, Package payment stellt einen Client mit vielen Methoden zur Verfügung:

// package payment
type Client struct {
	apiKey string
}

func NewClient(apiKey string) *Client { return &Client{apiKey: apiKey} }

func (c *Client) Charge(customer string, amount int64) (string, error) {
	return fmt.Sprintf("txn-%s-%d", customer, amount), nil
}

func (c *Client) Refund(txnID string) error         { return nil }
func (c *Client) ListTransactions() ([]string, error) { return nil, nil }

Package checkout braucht nur Zahlungen einzuziehen, also deklariert es eine Interface mit einer Methode:

// package checkout
var ErrInvalidTotal = errors.New("Gesamtbetrag muss größer als 0 sein")

type PaymentGateway interface {
	Charge(customer string, amount int64) (string, error)
}

type Service struct {
	gateway PaymentGateway
}

func NewService(gw PaymentGateway) *Service {
	return &Service{gateway: gw}
}

func (s *Service) Checkout(o Order) (string, error) {
	if o.Total <= 0 {
		return "", ErrInvalidTotal
	}
	txnID, err := s.gateway.Charge(o.Customer, o.Total)
	if err != nil {
		return "", fmt.Errorf("Zahlung für Bestellung #%d: %w", o.ID, err)
	}
	return txnID, nil
}

Beim Initialisieren in main:

svc := checkout.NewService(payment.NewClient("sk_test_123"))
txn, err := svc.Checkout(Order{ID: 42, Customer: "An", Total: 350000})
fmt.Println(txn, err)
txn-An-350000 <nil>

*payment.Client erfüllt checkout.PaymentGateway implizit. Package payment importiert checkout nicht und muss gar nicht wissen, dass es existiert.

Dieses Prinzip wird oft zusammengefasst als “accept interfaces, return structs”: Funktionen akzeptieren Interfaces für Flexibilität, geben aber konkrete Typen zurück, damit der Aufrufer alle Methoden und Felder nutzen kann.

5. Testen mit Fakes, ohne Mock-Bibliotheken

Kleine Interfaces auf der Konsumentenseite machen Tests sehr elegant. Du brauchst nur einen Struct mit genau einer Methode:

package checkout

import (
	"errors"
	"testing"
)

type fakeGateway struct {
	charged []int64
	err     error
}

func (f *fakeGateway) Charge(customer string, amount int64) (string, error) {
	if f.err != nil {
		return "", f.err
	}
	f.charged = append(f.charged, amount)
	return "txn-test", nil
}

func TestCheckout_ChargesOrderTotal(t *testing.T) {
	gw := &fakeGateway{}
	svc := NewService(gw)

	txn, err := svc.Checkout(Order{ID: 1, Customer: "An", Total: 200000})

	if err != nil {
		t.Fatalf("Fehler nicht erwartet: %v", err)
	}
	if txn != "txn-test" {
		t.Errorf("txn = %q, erwartet %q", txn, "txn-test")
	}
	if len(gw.charged) != 1 || gw.charged[0] != 200000 {
		t.Errorf("charged = %v, erwartet [200000]", gw.charged)
	}
}

func TestCheckout_RejectsZeroTotal(t *testing.T) {
	gw := &fakeGateway{}
	svc := NewService(gw)

	_, err := svc.Checkout(Order{ID: 2, Customer: "Bình", Total: 0})

	if !errors.Is(err, ErrInvalidTotal) {
		t.Fatalf("err = %v, erwartet ErrInvalidTotal", err)
	}
	if len(gw.charged) != 0 {
		t.Errorf("Charge sollte nicht aufgerufen werden, aber charged = %v", gw.charged)
	}
}

func TestCheckout_WrapsGatewayError(t *testing.T) {
	gwErr := errors.New("Karte wurde abgelehnt")
	svc := NewService(&fakeGateway{err: gwErr})

	_, err := svc.Checkout(Order{ID: 3, Customer: "Chi", Total: 100000})

	if !errors.Is(err, gwErr) {
		t.Fatalf("err = %v, erwartet %v eingepackt", err, gwErr)
	}
}

Tests ausführen:

go test ./checkout -v
=== RUN   TestCheckout_ChargesOrderTotal
--- PASS: TestCheckout_ChargesOrderTotal (0.00s)
=== RUN   TestCheckout_RejectsZeroTotal
--- PASS: TestCheckout_RejectsZeroTotal (0.00s)
=== RUN   TestCheckout_WrapsGatewayError
--- PASS: TestCheckout_WrapsGatewayError (0.00s)
PASS

Wenn PaymentGateway zehn Methoden hätte, müsste auch das Fake alle zehn implementieren. Das ist ein klares Zeichen, dass die Interface zu groß ist.

6. Type Assertion, Type Switch und Compile-Zeit-Checks

6.1. Optionale Interfaces mit Type Assertion

Manchmal möchtest du eine zusätzliche Fähigkeit nutzen, aber nur wenn der konkrete Typ sie unterstützt. Go nutzt Type Assertion mit zwei Werten:

type Refunder interface {
	Refund(txnID string) error
}

func (s *Service) Cancel(txnID string) error {
	r, ok := s.gateway.(Refunder)
	if !ok {
		return errors.New("Payment-Gateway unterstützt keine Rückerstattungen")
	}
	return r.Refund(txnID)
}

Mit *payment.Client ist ok true, weil es die Refund-Methode hat. Mit fakeGateway ist ok false und die Funktion gibt einen Fehler zurück, statt zu panic. Die Standardbibliothek nutzt diese Technik sehr oft, z.B. überprüft io.Copy, ob der Parameter io.WriterTo erfüllt, um einen schnelleren Pfad zu wählen.

Hinweis: Die Form r := s.gateway.(Refunder) (ohne ok) panicked, wenn der Typ nicht passt. Verwende immer die Form r, ok := … außer du bist absolut sicher.

6.2. Type Switch

Wenn du mehrere verschiedene Typen unterschiedlich behandeln musst, nutze type switch:

func describe(n Notifier) string {
	switch v := n.(type) {
	case EmailNotifier:
		return "E-Mail von " + v.From
	case SMSNotifier:
		return "SMS"
	default:
		return fmt.Sprintf("unbekannt: %T", v)
	}
}

Wenn du merkst, dass du an vielen Stellen type switches auf geschäftslogischen Typen schreibst, fehlt wahrscheinlich eine Methode in der Interface.

6.3. Interface-Erfüllung zur Compile-Zeit sicherstellen

Weil die Erfüllung implizit ist, kann eine Änderung der Methoden-Signatur nur bei der Zuweisung auffallen. Um Fehler früher zu fangen, direkt in der Implementierungs-Datei:

var _ checkout.PaymentGateway = (*payment.Client)(nil)

Diese Zeile erzeugt zur Laufzeit nichts. Sie zwingt den Compiler nur, zu überprüfen, dass *payment.Client die PaymentGateway-Interface erfüllt. Nutze das nur, wenn das Implementierungs-Package sich wirklich zur Interface verpflichten will. Andernfalls erzeugt es einen unnötigen zirkulären Import.

7. Häufige Fehler

7.1. Interface mit nil Pointer ist nicht nil

Das ist der klassischste Go-Fehler:

type ValidationError struct{ Field string }

func (e *ValidationError) Error() string {
	return "Feld ungültig: " + e.Field
}

func validate(o Order) error {
	var verr *ValidationError
	if o.Total <= 0 {
		verr = &ValidationError{Field: "Total"}
	}
	return verr // FALSCH
}

func main() {
	err := validate(Order{Total: 100})
	fmt.Println(err == nil)
}
false

Eine Interface-Wert besteht aus zwei Teilen: Typ und Wert. Eine Interface ist nur nil, wenn beide Teile leer sind. Hier ist der Typ *ValidationError und der Wert ein nil Pointer – also ist err != nil. Die Lösung ist, nil explizit zurückzugeben:

func validate(o Order) error {
	if o.Total <= 0 {
		return &ValidationError{Field: "Total"}
	}
	return nil
}

7.2. Pointer Receiver und Method Set

type AuditNotifier struct{ count int }

func (a *AuditNotifier) Notify(customer, message string) error {
	a.count++
	return nil
}

var n Notifier = AuditNotifier{} // Compile-Fehler
cannot use AuditNotifier{} (value of struct type AuditNotifier) as Notifier value
in variable declaration: AuditNotifier does not implement Notifier
(method Notify has pointer receiver)

Methoden mit Pointer Receiver gehören nur zur Method Set von *AuditNotifier. Du musst &AuditNotifier{} zuweisen. Umgekehrt gehören Methoden mit Value Receiver zur Method Set von sowohl dem Wert als auch dem Pointer.

7.3. Interfaces schreiben, bevor sie nötig sind

Dieser Anti-Pattern sieht aus, als ob man Java-Gewohnheiten mitbringt:

type OrderService interface {
	Create(o Order) error
	Cancel(id int) error
	Find(id int) (Order, error)
	// … 10 Methoden mehr
}

type orderServiceImpl struct{ /* … */ }

func NewOrderService() OrderService { return &orderServiceImpl{} }

Das hat drei Probleme. Die Interface hat nur eine Implementierung. Sie liegt auf der Implementierungsseite, nicht der Konsumentenseite. Der Constructor gibt eine Interface zurück, also verliert der Aufrufer Zugriff auf den konkreten Typ. Gib statt OrderService den Struct zurück, und jedes nutzende Package deklariert die kleine Interface, wenn es wirklich eine Austausch-Implementierung braucht – zum Beispiel zum Testen.

7.4. Missbrauch von any

any (Alias für interface{}) akzeptiert alle Typen, also kann der Compiler dir nicht mehr helfen. Ab Go 1.18 solltest du Generics bevorzugen, wenn eine Funktion mit mehreren Typen arbeiten soll:

func SumTotals[T interface{ GetTotal() int64 }](items []T) int64 {
	var sum int64
	for _, it := range items {
		sum += it.GetTotal()
	}
	return sum
}

Nutze any nur, wenn die Daten wirklich unbekannten Typ haben, z.B. beim Deserialisieren beliebigen JSONs.

8. Fazit

Interfaces in Go sind mächtig, weil sie einfach sind. Das Wichtigste zum Mitnehmen:

  • Typen erfüllen Interfaces implizit, ohne implements.
  • Interfaces sollten klein sein, idealerweise eine oder zwei Methoden. Brauchst du mehr, kombiniere kleine Interfaces.
  • Deklariere Interfaces auf der Konsumentenseite. Funktionen akzeptieren Interfaces, Konstruktoren geben Structs zurück.
  • Kleine Interfaces ermöglichen Testing mit selbst geschriebenen Fakes ohne Mock-Bibliotheken.
  • Pass auf mit Interfaces, die nil Pointer enthalten, und mit der Method Set von Pointer Receivern.

Beim nächsten Mal, wenn du eine Interface schreiben willst, frag dich: “Wer nutzt das, und welche Methoden brauchen sie wirklich?” Die Antwort ist meistens kleiner als gedacht.

golanginterfacedesigntesting