Interfaces Go : accept interfaces, return structs — et quand ne pas le faire

Pendant une code review sur ClaudeGate, un collègue m'a posé la question : "pourquoi ton interface Store n'est pas dans le package consommateur ?" Il citait la convention Go "accept interfaces, return structs". Il avait raison sur la convention — et tort sur l'application dans ce cas précis.

C'est une de ces situations où connaître la règle ne suffit pas. Il faut aussi comprendre pourquoi elle existe pour savoir quand l'ignorer sans culpabilité.

La convention expliquée depuis zéro

En Java ou C#, quand on écrit une classe, on déclare explicitement les interfaces qu'elle implémente. C'est le producteur qui décide : class SQLiteStore implements Store. L'interface est généralement dans le même package que l'implémentation.

Go fonctionne à l'envers. L'implémentation des interfaces est implicite : si un type a les bonnes méthodes, il satisfait l'interface — sans déclaration, sans implements. Et la convention qui découle de ça, c'est que c'est le consommateur qui définit l'interface dont il a besoin, pas le producteur.

// ❌ Approche Java-esque : le producteur déclare l'interface
// package storage
type Store interface {
    Create(ctx context.Context, j *Job) error
    Get(ctx context.Context, id string) (*Job, error)
    List(ctx context.Context, limit, offset int) ([]*Job, int, error)
    Delete(ctx context.Context, id string) error
    UpdateStatus(ctx context.Context, id, status, result, errMsg string) error
    MarkProcessing(ctx context.Context, id string) error
    ResetProcessing(ctx context.Context) ([]string, error)
    Count(ctx context.Context) (int, error)
}

type SQLiteStore struct{ db *sql.DB }
func (s *SQLiteStore) Create(...) error { ... }
// etc.
// ✅ Approche Go : chaque consommateur déclare ce dont il a besoin

// package api — a besoin de l'API complète
type JobStore interface {
    Create(ctx context.Context, j *Job) error
    Get(ctx context.Context, id string) (*Job, error)
    List(ctx context.Context, limit, offset int) ([]*Job, int, error)
    Delete(ctx context.Context, id string) error
    UpdateStatus(ctx context.Context, id, status, result, errMsg string) error
    MarkProcessing(ctx context.Context, id string) error
}

// package queue — a besoin de moins
type QueueStore interface {
    Get(ctx context.Context, id string) (*Job, error)
    MarkProcessing(ctx context.Context, id string) error
    UpdateStatus(ctx context.Context, id, status, result, errMsg string) error
    ResetProcessing(ctx context.Context) ([]string, error)
}

SQLiteStore implémente les deux interfaces silencieusement — pas de déclaration, pas de couplage explicite entre les packages. C'est la puissance du système de types Go.

Quand cette approche apporte une vraie valeur

Si les packages api et queue ont des besoins réellement différents, découper en deux interfaces plus petites apporte trois choses concrètes.

D'abord, les mocks allégés. Dans les tests du package queue, le mock n'implémente que 4 méthodes au lieu de 8. Moins de bruit, moins de maintenance.

Ensuite, la ségrégation des dépendances. Chaque package dépend uniquement de ce qu'il utilise — c'est l'Interface Segregation Principle appliqué naturellement. Un changement dans une méthode que seul api utilise ne force pas queue à recompiler ses mocks.

Enfin, la substitution facilitée. Si demain on veut deux implémentations concrètes différentes — une SQLite pour les tests, une Redis pour la queue en production — les contrats sont déjà séparés. Pas de chirurgie sur l'architecture.

Pourquoi on ne l'a pas fait dans ClaudeGate

ClaudeGate a une seule implémentation concrète : SQLiteStore. Une seule. L'interface Store vit dans le package job — le package domaine central du projet. C'est lisible, c'est naturel, tout développeur qui ouvre le code sait immédiatement où chercher.

Découper en deux interfaces séparées dans deux packages différents aurait apporté :

  • Plus de fichiers à naviguer
  • Duplication de signatures de méthodes
  • Un lecteur qui ouvre le projet pour la première fois et qui cherche d'où vient QueueStore

Pour zéro bénéfice. Le seul mock qui existerait serait dans les tests — et avec une seule implémentation, les tests d'intégration sur SQLite en mémoire (:memory:) sont plus honnêtes qu'un mock de toute façon.

Le code actuel est correct tel quel. Appliquer la convention à la lettre aurait été de l'over-engineering au nom d'une règle.

La règle pratique

Après quelques projets Go, j'ai convergé vers une règle simple à deux branches.

Déplace les interfaces vers les consommateurs quand :

  • 2+ implémentations concrètes existent (ou sont clairement prévues à court terme)
  • L'interface globale est trop grosse et les consommateurs n'en utilisent qu'une fraction
  • Deux consommateurs ont des besoins suffisamment différents pour justifier des contrats séparés

Garde l'interface dans le package domaine quand :

  • Une seule implémentation, un seul usage réel
  • Le code est lisible tel quel et les interfaces ne feront qu'ajouter de l'indirection
  • Le projet est encore jeune — les vrais besoins de découpage n'ont pas encore émergé

Ce que les interfaces canoniques Go enseignent

La bibliothèque standard Go est construite autour de petites interfaces à une ou deux méthodes. L'exemple canonique :

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

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

Un seul comportement par interface. os.File, bytes.Buffer, net.Conn, strings.Reader satisfont tous io.Reader implicitement — sans coordination entre leurs packages respectifs. C'est le découplage maximal.

Mais attention à la mauvaise lecture de cet exemple : io.Reader est si petite parce qu'elle modélise un comportement fondamental et universel, pas parce qu'on a découpé mécaniquement une interface plus grosse. La taille de l'interface découle du besoin réel, pas d'une règle stylistique.

La composition est l'étape suivante :

// La stdlib compose ses petites interfaces quand elle en a besoin
type ReadWriter interface {
    Reader
    Writer
}

// Et le consommateur déclare exactement ce dont il a besoin
func compress(r io.Reader, w io.Writer) error {
    // r peut être un fichier, un buffer, une réponse HTTP...
    // w peut être un fichier, un buffer réseau...
    // Cette fonction ne sait rien de leur type concret.
}

Conclusion

"Accept interfaces, return structs" est une bonne règle — mais c'est une heuristique, pas un dogme. Elle pousse vers un design où les consommateurs définissent leurs besoins plutôt que de subir les décisions du producteur. C'est sain.

Ce qui n'est pas sain, c'est d'appliquer la règle mécaniquement sans se demander ce qu'elle apporte dans le contexte précis. Découper une interface en deux pour une seule implémentation concrète, c'est ajouter de la complexité sans valeur ajoutée — exactement ce que Go essaie de décourager.

Le vrai ennemi, ce n'est pas l'interface mal placée. C'est l'over-engineering au nom d'une convention. La bonne interface est la plus petite qui répond au vrai besoin du consommateur — pas la plus conforme à une règle lue dans un article de blog.

Commentaires (0)