Das Idiom „Accept interfaces, return structs" ist eine der prägnantesten Design-Regeln in Go — und eine, die Anfänger fast immer falsch herum lernen. Wer aus Java, C# oder TypeScript kommt, baut reflexartig ein UserServiceInterface direkt neben jeden UserService und gibt aus Konstruktoren das Interface zurück. In Go ist das ein Anti-Pattern. Die idiomatische Asymmetrie lautet: Parameter sind Interfaces (damit der Aufrufer entscheidet, welche Abhängigkeit er reicht), Returns sind konkrete Typen (damit der Konsument die volle API sieht und nichts vorzeitig abgeschnitten wird). Dieser Artikel zeigt, warum die Regel so formuliert ist, woher sie kommt, an welchen Stdlib-Beispielen sie sich beweist und welche wenigen Ausnahmen sie hat.

Die Regel in einem Satz

Funktionen und Methoden akzeptieren Interfaces als Parameter, geben aber konkrete Typen (Structs oder Pointer auf Structs) zurück.

Die Formulierung stammt aus der Go-Community und wird in den offiziellen Code-Review-Comments mit folgenden Worten umschrieben:

Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values. The implementing package should return concrete (usually pointer or struct) types: that way, new methods can be added to implementations without requiring extensive refactoring.

Zwei Hälften, zwei Begründungen — und beide sind unabhängig voneinander wichtig.

Hälfte eins — warum Interfaces als Parameter

Wenn deine Funktion ein Interface entgegennimmt, gibst du dem Aufrufer drei Dinge gleichzeitig:

1) Decoupling. Die Funktion weiß nichts über den konkreten Typ. Sie kennt nur die Methoden, die sie braucht. Tausche den konkreten Typ aus — die Funktion bleibt unverändert.

2) Testbarkeit. Im Test reicht der Aufrufer einen Mock oder einen einfachen In-Memory-Stub statt der echten Implementierung. Kein Monkey-Patching, kein DI-Container, kein Reflection-Trick — nur ein Typ, der dieselben Methoden hat.

3) Substitutability. Mehrere konkrete Typen können dieselbe Rolle spielen. io.Copy nimmt jeden io.Reader — eine Datei, eine HTTP-Response, ein In-Memory-Buffer, ein TCP-Stream, ein gzip-Decoder. Eine Funktion, viele Quellen.

Go parameter-interface.go
package main

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

// countBytes nimmt io.Reader — beliebige Quelle akzeptabel.
func countBytes(r io.Reader) (int, error) {
    buf := make([]byte, 1024)
    total := 0
    for {
        n, err := r.Read(buf)
        total += n
        if err == io.EOF {
            return total, nil
        }
        if err != nil {
            return total, err
        }
    }
}

func main() {
    // strings.Reader implementiert io.Reader.
    r := strings.NewReader("Hallo, Welt!")
    n, _ := countBytes(r)
    fmt.Println("Bytes gelesen:", n)
}
Output
Bytes gelesen: 12

countBytes interessiert sich nicht für die Datenquelle. Heute ein strings.Reader, morgen ein *os.File, übermorgen ein *bytes.Buffer — die Funktion bleibt identisch. Das ist Decoupling im präzisesten Sinn: die Funktion hängt von einer Fähigkeit ab, nicht von einem Typ.

Hälfte zwei — warum konkrete Typen als Return

Hier kommt die Intuition aus anderen Sprachen meist ins Stolpern. „Sollte ich nicht das Interface zurückgeben, damit ich die Implementierung später tauschen kann?" — Nein. In Go nicht. Drei Gründe:

1) Keine vorzeitige Abstraktion. Wer das Interface zurückgibt, zwingt alle Konsumenten, nur die Interface-Methoden zu sehen. Wenn dein konkreter Typ später eine nützliche Methode bekommt — sagen wir Stats() auf einem Cache — sehen die Aufrufer sie nicht, ohne ein Type-Assertion. Das ist API-Verlust durch unnötige Verengung.

2) Konsumenten-Information ist wertvoll. Ein *sql.DB, ein *http.Request, ein *bytes.Buffer haben dutzende Methoden. Der Aufrufer profitiert davon, sie alle sehen zu können — und entscheidet selbst, welche Untermenge er nutzt. Das Interface kann er sich bei Bedarf selbst definieren.

3) Neue Methoden ohne Breaking Changes. Wenn du den konkreten Typ zurückgibst, kannst du jederzeit neue Methoden anhängen, ohne ein Interface zu erweitern. Erweiterst du ein Interface, brechen alle Implementierungen — eine klassische Stelle für „interface pollution".

Go return-concrete.go
package store

// Store ist der konkrete Typ.
type Store struct {
    // ... interne Felder
}

// NewStore gibt *Store zurück, NICHT ein Interface.
// Der Aufrufer sieht alle Methoden — Get, Set, Stats, Snapshot, ...
func NewStore() *Store {
    return &Store{}
}

func (s *Store) Get(key string) (string, bool) { /* ... */ return "", false }
func (s *Store) Set(key, value string)         { /* ... */ }

// Später hinzugefügt — keine Änderung an Aufrufern nötig.
func (s *Store) Stats() Stats { /* ... */ return Stats{} }

type Stats struct{ Hits, Misses int64 }

Hätte NewStore() ein Storer-Interface mit nur Get und Set zurückgegeben, wäre Stats() für alle Aufrufer unsichtbar geblieben — sie hätten ein Type-Assertion s.(*Store).Stats() schreiben müssen, was alle Vorteile der Abstraktion sofort wieder einreißt.

Consumer-defined interfaces

Die zweite Schlüsselregel ergibt sich aus der ersten: das Interface gehört zum Aufrufer, nicht zum Produzenten. Der Code-Review-Comment-Eintrag formuliert das mit einem ausführlichen Beispiel. In Go gibt es keine implements-Klausel. Ein Typ implementiert ein Interface allein dadurch, dass er dessen Methoden hat — egal in welchem Paket das Interface definiert ist.

Go consumer-interface.go
package main

import "fmt"

// ---- Paket "store": stellt einen konkreten Typ bereit. ----
type UserStore struct {
    users map[int64]string
}

func NewUserStore() *UserStore {
    return &UserStore{users: map[int64]string{1: "Alice", 2: "Bob"}}
}

func (s *UserStore) FindName(id int64) (string, bool) {
    name, ok := s.users[id]
    return name, ok
}

// ---- Paket "greeter": braucht NUR FindName, definiert das eigene Interface. ----
type nameFinder interface {
    FindName(id int64) (string, bool)
}

func Greet(f nameFinder, id int64) string {
    name, ok := f.FindName(id)
    if !ok {
        return "Hallo, Fremder!"
    }
    return "Hallo, " + name + "!"
}

func main() {
    store := NewUserStore() // *UserStore — KEIN Interface
    fmt.Println(Greet(store, 1))
    fmt.Println(Greet(store, 99))
}
Output
Hallo, Alice!
Hallo, Fremder!

Beachte: nameFinder ist im Greeter-Paket definiert, nicht im Store-Paket. Der Greeter sagt „ich brauche etwas mit FindName" — und Go prüft beim Aufruf, ob *UserStore das erfüllt. Der Store muss nameFinder nicht kennen und nicht importieren.

Das hat zwei direkte Konsequenzen:

  • Interfaces bleiben klein. Jeder Konsument definiert genau die Methoden, die er braucht — meist eine oder zwei. Keine Mega-Interfaces mit zwanzig Methoden, die kein Mock je vollständig nachbaut.
  • Keine Zirkular-Imports. Das Store-Paket muss das Greeter-Paket nicht kennen. Die Abhängigkeit fließt nur in eine Richtung.

Anti-Pattern — das „MyServiceInterface"-Symptom

Aus Java/C#-Sprachen schleppen viele die Gewohnheit ein, neben jeden Service ein gleichnamiges Interface zu legen:

Go anti-pattern.go
// ANTI-PATTERN — bitte NICHT so machen.

package userservice

// Interface direkt neben dem Struct, im selben Paket.
type UserServiceInterface interface {
    FindByID(id int64) (*User, error)
    Save(u *User) error
    Delete(id int64) error
    Count() (int, error)
    // ... 15 weitere Methoden
}

type userServiceImpl struct {
    db *sql.DB
}

// Konstruktor gibt das Interface zurück, nicht den Struct.
func New(db *sql.DB) UserServiceInterface {
    return &userServiceImpl{db: db}
}

func (s *userServiceImpl) FindByID(id int64) (*User, error) { /* ... */ }
// ...

Das Muster ist in jeder einzelnen Hinsicht das Gegenteil dessen, was Go will. Die Code-Review-Comments markieren es explizit mit „DO NOT DO IT!!!". Konkret:

  • Das Interface ist auf der falschen Seite. Es lebt beim Produzenten, nicht beim Konsumenten.
  • Es zwingt jeden Aufrufer auf das volle Method-Set. Statt dass jeder Aufrufer nur sieht, was er braucht, kriegt er ein 19-Methoden-Interface.
  • Es ist ein Mock-Magnet. Im Test musst du alle Methoden mocken, auch die, die der Test nicht aufruft — sonst hast du keinen vollständigen UserServiceInterface-Wert.
  • Es verschleiert die Implementierung. Konsumenten sehen niemals den konkreten Typ und können keine Stats-, Debug- oder Inspect-Methoden nutzen, die später dazukommen.

Die idiomatische Version dreht es um:

Go idiomatic-version.go
// OK — Idiomatic Go.

package userservice

// UserService ist der konkrete Typ. Kein Interface daneben.
type UserService struct {
    db *sql.DB
}

// Konstruktor gibt *UserService zurück.
func New(db *sql.DB) *UserService {
    return &UserService{db: db}
}

func (s *UserService) FindByID(id int64) (*User, error) { /* ... */ }
func (s *UserService) Save(u *User) error               { /* ... */ }
// ...

// Konsumenten definieren bei sich ein winziges Interface mit den
// Methoden, die sie tatsächlich brauchen — z. B. im Handler-Paket:
//
//   type userFinder interface {
//       FindByID(id int64) (*User, error)
//   }

Stdlib-Beweis — das Idiom im Vorbild

Wer die Regel im Original sehen will: die Standard-Bibliothek wendet sie konsequent an. Zwei kanonische Beispiele:

io.Copy — Parameter sind Interfaces.

Go io-copy-signature.go
// Aus dem Paket io:
func Copy(dst Writer, src Reader) (written int64, err error)

Beide Parameter sind Interfaces (io.Writer, io.Reader) — die kleinstmöglichen, jeweils mit einer Methode. Du kannst Datei zu Datei, HTTP-Body zu gzip, Buffer zu Netzwerk-Socket kopieren — io.Copy ist eine Funktion für hunderte konkrete Quell-/Ziel-Kombinationen.

http.NewRequest — Return ist konkret.

Go http-newrequest-signature.go
// Aus dem Paket net/http:
func NewRequest(method, url string, body io.Reader) (*Request, error)

Beachte beide Hälften gleichzeitig: der Body-Parameter ist io.Reader (Interface — beliebige Quelle), die Rückgabe ist *http.Request (konkret — der Aufrufer sieht alle 30+ Methoden und Felder des Request-Typs, kann Header setzen, Context anheften, Trailer prüfen, was auch immer).

Das Muster zieht sich quer durch die Stdlib: os.Open → *os.File, bytes.NewBuffer → *bytes.Buffer, sql.Open → *sql.DB, tls.Dial → *tls.Conn. Returns sind Pointer auf konkrete Structs — niemals Interfaces.

Die wenigen legitimen Ausnahmen

Die Regel ist eine Faustregel, kein Naturgesetz. Es gibt vier wiederkehrende Situationen, in denen ein Konstruktor sinnvoll ein Interface zurückgibt:

1) Mehrere konkrete Typen aus einer Factory. Wenn der Konstruktor je nach Eingabe unterschiedliche konkrete Typen produziert, ist das Interface der einzige gemeinsame Nenner. crypto/cipher.NewCTR gibt ein cipher.Stream zurück — die konkrete Implementation hängt vom übergebenen Block ab.

Go ausnahme-factory.go
// Aus crypto/cipher:
func NewCTR(block Block, iv []byte) Stream
// → Stream ist ein Interface, weil intern verschiedene
//   Stream-Cipher-Implementierungen zurückgegeben werden können.

2) Sentinel-Werte / Singletons. context.Background() und context.TODO() geben Context (Interface) zurück. Die konkrete Implementierung ist privat und soll es bleiben — der Aufrufer braucht ohnehin nur die Interface-Methoden.

3) Errors. errors.New(...) gibt error (Interface) zurück. Die konkrete Struktur ist unwichtig — der Aufrufer behandelt sie über error.Error() oder errors.Is/As.

4) Hashes und ähnliche Plugin-Familien. sha256.New() → hash.Hash, md5.New() → hash.Hash — alle Hash-Konstruktoren liefern denselben Interface-Typ, damit Caller-Code austauschbar bleibt.

In allen vier Fällen gilt: Interface-Return ist die Ausnahme, weil die Vielfalt der konkreten Typen Teil des Designs ist. Wenn dein Konstruktor immer denselben konkreten Typ erzeugt — gib den konkreten Typ zurück.

Test-Mocking durch Interface-Parameter

Der Test-Aspekt verdient eine eigene Betrachtung, weil er der häufigste Grund ist, warum Leute trotzdem Interfaces beim Produzenten definieren wollen. Die idiomatische Lösung schließt den Kreis: der Konsument definiert das Interface, der Test reicht einen Mock.

Go mock-im-test.go
package main

import "fmt"

// ---- Production-Code: konsumierendes Paket ----

type userFinder interface {
    FindName(id int64) (string, bool)
}

func Greet(f userFinder, id int64) string {
    name, ok := f.FindName(id)
    if !ok {
        return "Hallo, Fremder!"
    }
    return "Hallo, " + name + "!"
}

// ---- Test-Code: eigener Mock ----

type stubFinder struct {
    data map[int64]string
}

func (s stubFinder) FindName(id int64) (string, bool) {
    name, ok := s.data[id]
    return name, ok
}

func main() {
    // „Test": mit Stub statt echtem Store.
    stub := stubFinder{data: map[int64]string{42: "Test-User"}}
    fmt.Println(Greet(stub, 42))
    fmt.Println(Greet(stub, 7))
}
Output
Hallo, Test-User!
Hallo, Fremder!

Der Mock ist drei Zeilen — keine Mocking-Library, kein Code-Generator, kein DI-Framework. Das funktioniert, weil das Interface beim Konsumenten klein ist (eine Methode), und weil Go-Interfaces implizit erfüllt werden.

Die Code-Review-Comments ergänzen einen weiteren wichtigen Punkt: definiere Interfaces nicht beim Implementierer „für Mocking". Statt im Production-Paket ein Interface anzulegen, damit der Test es mocken kann, gehört die Mocking-Verantwortung zum Test selbst — und das Interface zum Konsumenten.

Praxis-Beispiel 1 — User-Service mit injiziertem Repository

Realistische Architektur: ein UserService braucht ein Repository, um User aus einer Datenquelle zu holen. Wir wenden „Accept interfaces, return structs" konsequent an.

Go user-service.go
package main

import (
    "errors"
    "fmt"
)

// ---- Domain-Typen ----

type User struct {
    ID    int64
    Name  string
    Email string
}

// ---- Konsumenten-Paket: UserService ----
//
// Der Service definiert das Interface, das ER braucht.
// Klein, präzise, zwei Methoden.

type userRepo interface {
    FindByID(id int64) (*User, error)
    Save(u *User) error
}

// UserService ist konkret — Returns geben *UserService zurück.
type UserService struct {
    repo userRepo
}

// New nimmt das Interface entgegen, gibt den konkreten Typ zurück.
func New(repo userRepo) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) Rename(id int64, newName string) error {
    u, err := s.repo.FindByID(id)
    if err != nil {
        return err
    }
    u.Name = newName
    return s.repo.Save(u)
}

// ---- Produzent-Paket: in-memory Repository ----
//
// Das Repo ist konkret. Es kennt das userRepo-Interface NICHT
// — es erfüllt es nur strukturell.

type InMemoryRepo struct {
    users map[int64]*User
}

func NewInMemoryRepo() *InMemoryRepo {
    return &InMemoryRepo{users: map[int64]*User{}}
}

func (r *InMemoryRepo) FindByID(id int64) (*User, error) {
    u, ok := r.users[id]
    if !ok {
        return nil, errors.New("user nicht gefunden")
    }
    return u, nil
}

func (r *InMemoryRepo) Save(u *User) error {
    r.users[u.ID] = u
    return nil
}

// InMemoryRepo hat zusätzlich eine Debug-Methode, die der Service
// nicht sieht — und das ist gut so. Das Test-Setup nutzt sie.
func (r *InMemoryRepo) Snapshot() map[int64]*User {
    return r.users
}

// ---- Verdrahtung ----

func main() {
    repo := NewInMemoryRepo() // *InMemoryRepo
    repo.Save(&User{ID: 1, Name: "Alice", Email: "a@example.com"})

    svc := New(repo) // *UserService, repo als userRepo akzeptiert
    svc.Rename(1, "Alice II")

    u, _ := repo.FindByID(1)
    fmt.Printf("nach Rename: %+v\n", u)

    // Snapshot ist auf *InMemoryRepo direkt verfügbar —
    // wir haben den konkreten Typ NICHT hinter einem Interface versteckt.
    fmt.Println("Snapshot-Größe:", len(repo.Snapshot()))
}

Drei Beobachtungen am fertigen Code:

  • New(repo userRepo) *UserServiceParameter Interface, Return konkret. Lehrbuch-Asymmetrie.
  • userRepo lebt im Service-Paket, ist lowerCase (paket-privat) und enthält exakt die zwei Methoden, die der Service braucht.
  • InMemoryRepo hat eine Zusatz-Methode Snapshot(), die der Service nicht kennt. Würde NewInMemoryRepo ein Interface zurückgeben, wäre Snapshot() für den Aufrufer unerreichbar.

Praxis-Beispiel 2 — Refactoring eines Services mit konkretem Parameter

Häufiger Ausgangspunkt: ein bestehender Service nimmt einen konkreten Repo-Typ als Parameter. Das funktioniert, ist aber schwer zu testen und stark gekoppelt. Wir refaktorieren in zwei Schritten.

Schritt 0 — Ausgangslage.

Go vorher.go
package report

import "database/sql"

// Direkt vom konkreten Typ abhängig.
type PostgresRepo struct {
    db *sql.DB
}

func (r *PostgresRepo) CountActive() (int, error) { /* ... */ return 0, nil }

// Service nimmt den konkreten Repo-Typ.
// → Im Test brauchst du eine echte Postgres-Instanz oder eine
//   docker-Test-DB. Mocken ist nicht möglich.
type ActivityReport struct {
    repo *PostgresRepo
}

func New(repo *PostgresRepo) *ActivityReport {
    return &ActivityReport{repo: repo}
}

func (a *ActivityReport) Render() (string, error) {
    n, err := a.repo.CountActive()
    if err != nil {
        return "", err
    }
    return fmt.Sprintf("Aktive User: %d", n), nil
}

Schritt 1 — Konsumenten-Interface einführen. Wir schauen, welche Methode ActivityReport von PostgresRepo wirklich nutzt: genau eine, CountActive. Daraus wird ein winziges, lokales Interface.

Go schritt-1.go
package report

// Konsumenten-Interface — paket-privat, eine Methode.
type activityCounter interface {
    CountActive() (int, error)
}

type ActivityReport struct {
    repo activityCounter // jetzt das Interface, nicht der konkrete Typ
}

func New(repo activityCounter) *ActivityReport {
    return &ActivityReport{repo: repo}
}

// Render bleibt unverändert.

Schritt 2 — Test mit Stub. Jetzt ist der Service testbar, ohne Datenbank.

Go schritt-2-test.go
package report

import "testing"

type stubCounter struct {
    n   int
    err error
}

func (s stubCounter) CountActive() (int, error) {
    return s.n, s.err
}

func TestRender_zaehltKorrekt(t *testing.T) {
    r := New(stubCounter{n: 42})
    out, err := r.Render()
    if err != nil {
        t.Fatal(err)
    }
    if out != "Aktive User: 42" {
        t.Errorf("unerwartete Ausgabe: %q", out)
    }
}

Was hat sich verändert? Drei Dinge — alle drei eine direkte Konsequenz aus dem Idiom:

  • Der Parameter ist jetzt ein Interface (activityCounter), nicht der konkrete Typ. Aufrufer können beliebige Implementierungen reichen.
  • New gibt weiterhin *ActivityReport zurück — der konkrete Typ. Konsumenten sehen alle Methoden, auch zukünftige.
  • Das Interface lebt im Konsumenten-Paket (report), nicht beim Produzenten (db). Produzent muss activityCounter nicht kennen.

Interessantes

Interfaces gehören zum Konsumenten, Implementierungen zum Produzenten.

Die Code-Review-Comments sind explizit: „Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values." Heißt — wenn du ein neues Interface schreibst, frag dich, wer es benutzt. Lebt das Paket dort? Wenn nein, ist das Interface vermutlich am falschen Ort.

Klein ist besser als groß — `io.Reader` ist das Maß.

Idiomatische Go-Interfaces haben eine, zwei, selten drei Methoden. io.Reader, io.Writer, io.Closer, fmt.Stringer, sort.Interface — alle winzig. Je kleiner das Interface, desto einfacher der Mock, desto austauschbarer die Implementierung. Mega-Interfaces mit 20 Methoden sind ein Hinweis auf Java-Reflex und gehören aufgeteilt.

„DO NOT DO IT!!!" — Interface neben Struct im gleichen Paket.

Das ist das namentlich aufgerufene Anti-Pattern aus den Code-Review-Comments. Ein Paket, das einen konkreten Typ produziert, soll nicht daneben ein Interface mit denselben Methoden definieren. Konsumenten definieren sich ihre eigenen Interfaces — meist viel kleiner, meist mit anderen Methoden-Untermengen pro Konsument.

Returns sind fast immer `*T`, nicht `T` oder Interface.

Konstruktoren in der Stdlib geben fast durchgängig *T zurück — os.Open → *os.File, bytes.NewBuffer → *bytes.Buffer, sql.Open → *sql.DB. Pointer-Return passt zum Pointer-Receiver-Idiom: die Methoden mutieren intern, der Konsument hält eine Instanz. Interface-Returns sind die Ausnahme, nicht die Regel.

Definiere keine Interfaces, bevor du sie brauchst.

Die Code-Review-Comments warnen explizit: „Don't define interfaces before they are used". Ohne realistische Verwendung weißt du nicht, welche Methoden ins Interface gehören und welche nicht. Schreib erst den konkreten Typ und den ersten Konsumenten — das Interface ergibt sich aus dem, was der Konsument tatsächlich anfasst.

Tests brauchen keine Interfaces beim Produzenten — sie brauchen kleine Konsumenten-Interfaces.

Wer im Produzenten-Paket ein Interface „nur für Mocks" definiert, hat das Idiom auf links gedreht. Die Lösung: der Konsument definiert sein Interface, der Test reicht einen Stub — drei Zeilen pro Mock, kein Code-Generator, keine Mock-Library.

Interface-Return ist die Ausnahme — Polymorphie, Sentinels, Errors, Hash-Familien.

Es gibt legitime Fälle für Interface-Returns: crypto/cipher.NewCTR → Stream (verschiedene Cipher-Implementierungen), context.Background() → Context (private Sentinels), errors.New → error (opake Werte), sha256.New → hash.Hash (austauschbare Hash-Familien). Allen gemeinsam: die Vielfalt der konkreten Typen ist Teil des Designs.

`interface{}` (jetzt `any`) ist KEINE Anwendung dieses Idioms.

„Accept interfaces" meint spezifische Interfaces mit definierten Methoden — nicht das leere interface{} / any, das alles akzeptiert. Wer any als Parameter nimmt, verschiebt die Typprüfung zur Laufzeit und verliert genau die Typsicherheit, die Go-Interfaces eigentlich liefern sollen. Generics oder konkrete Interfaces sind fast immer die bessere Wahl.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Interfaces

Zur Übersicht