Interfícies a Go: petites, implícites i més simples del que semblen

Interfícies implícites a Go: composició, testing, desacoblament i errors habituals de disseny. Pensa des del consumidor.

Cover for Interfícies a Go: petites, implícites i més simples del que semblen

A Java, declares les interfícies per endavant. Crees el contracte abans d’escriure una sola línia d’implementació. A Go, les interfícies es descobreixen després d’escriure el codi. Aquesta diferència no és un detall de sintaxi. És una forma completament diferent de pensar el disseny de programari.

Vinc de treballar amb Kotlin i Java durant anys. Interfícies explícites, implements, contractes formals, injecció de dependències amb frameworks. Tot molt cerimoniós. Quan vaig començar amb Go, les interfícies implícites em van semblar estranyes. Després d’usar-les en producció, em semblen una de les millors decisions de disseny del llenguatge.

Si véns de la JVM o de qualsevol llenguatge amb interfícies explícites, aquest article canviarà la teva forma de pensar el desacoblament. Perquè a Go, la pregunta no és “quin contracte vull imposar”, sinó “quin comportament necessito consumir”.


Satisfacció implícita: no hi ha keyword implements

La primera diferència que notes venint d’altres llenguatges és que a Go no existeix la paraula implements. Un tipus satisfà una interfície automàticament si té tots els mètodes que la interfície defineix. Res més.

type Speaker interface {
	Speak() string
}

type Dog struct {
	Name string
}

func (d Dog) Speak() string {
	return d.Name + \" says woof!\"
}

type Cat struct {
	Name string
}

func (c Cat) Speak() string {
	return c.Name + \" says meow!\"
}

Dog i Cat satisfan Speaker sense declarar-ho en cap lloc. No hi ha anotació, no hi ha registre, no hi ha relació explícita. El compilador ho verifica quan intentes usar un Dog on s’espera un Speaker:

func MakeNoise(s Speaker) {
	fmt.Println(s.Speak())
}

func main() {
	MakeNoise(Dog{Name: \"Rex\"})   // funciona
	MakeNoise(Cat{Name: \"Misi\"})  // funciona
}

Això té implicacions profundes. No necessites que l’autor del tipus sàpiga quines interfícies satisfarà. Pots definir una interfície al teu paquet que un tipus d’un altre paquet (o de la biblioteca estàndard) ja satisfà sense haver estat dissenyat per a això. És un desacoblament que no pots aconseguir amb interfícies explícites.

Pensa-ho així: a Java, si vols que una classe implementi la teva interfície, necessites modificar aquella classe o crear un wrapper. A Go, simplement defineixes la interfície amb els mètodes que necessites i qualsevol tipus que ja tingui aquells mètodes la compleix automàticament.

// Això funciona sense que os.File sàpiga res de la teva interfície
type ReadCloser interface {
	Read(p []byte) (n int, err error)
	Close() error
}

// *os.File ja té Read i Close, de manera que satisfà ReadCloser
var rc ReadCloser = os.Stdout

Per què les interfícies petites guanyen

Si mires la biblioteca estàndard de Go, les interfícies més usades són diminutes. Un mètode. Dos com a màxim. Això no és casualitat. És una decisió de disseny que impregna tot l’ecosistema.

io.Reader i io.Writer

Les dues interfícies més importants de Go tenen un sol mètode cadascuna:

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

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

Un mètode. Això és tot. I tanmateix, tot l’ecosistema d’I/O de Go es construeix sobre aquestes dues interfícies. Fitxers, connexions de xarxa, buffers, compressió, xifratge, cossos HTTP… tot implementa io.Reader, io.Writer o tots dos.

Per què funciona tan bé? Perquè una interfície d’un sol mètode és extremadament fàcil de satisfer. Qualsevol tipus que pugui llegir bytes pot ser un Reader. Qualsevol tipus que pugui escriure bytes pot ser un Writer. La barrera d’entrada és mínima i el valor de la composició és màxim.

fmt.Stringer

type Stringer interface {
	String() string
}

Si el teu tipus té un mètode String() string, pots usar-lo directament amb fmt.Println, fmt.Sprintf i qualsevol funció de formatació. Sense registrar res, sense heretar de cap classe base.

type Money struct {
	Amount   int
	Currency string
}

func (m Money) String() string {
	return fmt.Sprintf(\"%d %s\", m.Amount, m.Currency)
}

func main() {
	price := Money{Amount: 42, Currency: \"EUR\"}
	fmt.Println(price) // \"42 EUR\"
}

error

La interfície error és un altre exemple perfecte:

type error interface {
	Error() string
}

Un mètode. Qualsevol tipus que tingui Error() string és un error a Go. Pots crear errors personalitzats sense heretar d’una classe base, sense implementar deu mètodes que no necessites:

type NotFoundError struct {
	Resource string
	ID       string
}

func (e NotFoundError) Error() string {
	return fmt.Sprintf(\"%s with ID %s not found\", e.Resource, e.ID)
}

La lliçó és clara: com més petita és la interfície, més tipus la satisfan, més components pots compondre i més flexible és el teu sistema. Rob Pike ho va dir millor: “The bigger the interface, the weaker the abstraction.”


Composició d’interfícies: combinant peces petites

Go no té herència. No pots estendre una interfície com a Java amb extends. El que tens és composició: inserir interfícies dins d’altres per construir contractes més grans a partir de peces petites.

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

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

type Closer interface {
	Close() error
}

// Composició: ReadWriter és un Reader I un Writer
type ReadWriter interface {
	Reader
	Writer
}

// Composició més àmplia
type ReadWriteCloser interface {
	Reader
	Writer
	Closer
}

Això és exactament el que fa la biblioteca estàndard al paquet io. Les interfícies grans es construeixen component interfícies petites. Un tipus que satisfà ReadWriteCloser també satisfà Reader, Writer, Closer, ReadWriter i qualsevol altra combinació. Tot implícitament.

L’avantatge sobre l’herència clàssica és que no hi ha jerarquia rígida. Pots crear les combinacions que necessitis sense dependre d’una taxonomia predefinida:

// Al teu paquet, defineixes exactament el que necessites
type ReadFlusher interface {
	io.Reader
	Flush() error
}

Ningú necessitava haver previst aquesta combinació. Si un tipus té Read i Flush, satisfà ReadFlusher. Punt. Això és el que fa que el disseny a Go sigui tan orgànic: els contractes es descobreixen, no s’imposen.


La interfície buida: interface i any

La interfície buida no defineix cap mètode. Per tant, tots els tipus la satisfan. És l’equivalent de Object a Java o Any a Kotlin:

// Abans de Go 1.18
func Print(v interface{}) {
	fmt.Println(v)
}

// Des de Go 1.18, any és un àlies de interface{}
func Print(v any) {
	fmt.Println(v)
}

Pots passar el que vulguis: un int, un string, un struct, un slice. Tot és any.

Quan usar any

Té sentit en casos molt específics:

  • Funcions de logging o depuració: que accepten qualsevol cosa
  • Serialització/deserialització genèrica: com json.Marshal(v any)
  • Contenidors genèrics: abans que existissin els generics

Quan no usar any

La majoria del temps. Si uses any per evitar pensar en el tipus, estàs perdent el principal avantatge de Go: el sistema de tipus estàtic. Cada vegada que uses any, estàs movent errors del compilador al runtime.

// Malament: perds tota la seguretat de tipus
func Sum(a, b any) any {
	// Necessites type assertions, gestió d'errors...
	return a.(int) + b.(int) // panic si no són int
}

// Bé: amb generics (Go 1.18+)
func Sum[T int | float64](a, b T) T {
	return a + b
}

Des de Go 1.18 amb generics, la necessitat de any ha disminuït significativament. Si pots expressar el tipus amb generics, fes-ho. El compilador t’ho agrairà amb errors en temps de compilació en lloc de panics en producció.

Cada ús de any hauria de fer-te preguntar: “Puc expressar això amb una interfície específica o amb generics?” Si la resposta és sí, usa això en el seu lloc.


Accepta interfícies, retorna structs

Aquest és el principi de disseny més important per a interfícies a Go i el que més costa interioritzar venint de Java o Kotlin.

La idea és simple: les funcions haurien d’acceptar interfícies com a paràmetres i retornar tipus concrets com a resultat.

// Bé: accepta una interfície, retorna un tipus concret
func NewUserService(repo UserRepository) *UserService {
	return &UserService{repo: repo}
}

// Malament: retorna una interfície
func NewUserService(repo UserRepository) UserServiceInterface {
	return &UserService{repo: repo}
}

Per què? Perquè acceptar interfícies dóna flexibilitat al que crida: pot passar qualsevol implementació que satisfaci el contracte. Però retornar structs concrets dóna informació al que rep: sap exactament quin tipus té, pot accedir a tots els seus mètodes (no només els de la interfície) i el compilador pot optimitzar millor.

Retornar interfícies amaga informació sense necessitat. El que rep el valor ha de treballar amb un contracte reduït quan podria tenir accés a tot. Només retorna interfícies quan hi hagi una raó real per amagar la implementació, com en factories on la implementació concreta és un detall intern.

Aquest principi s’aplica especialment a constructors i funcions de creació. Si la teva funció NewX retorna una interfície, estàs afegint una capa d’abstracció que probablement ningú necessita.


Interfícies per a testing: mocking sense frameworks

Aquí és on les interfícies implícites de Go brillen amb més força. A Java o Kotlin, per fer mock d’una dependència necessites frameworks com Mockito, o generar proxies dinàmics, o crear implementacions manuals d’interfícies explícites. A Go, defineixes una interfície amb els mètodes que uses i crees un struct que els implementi. Res més.

Imaginem un servei que obté dades d’una API externa:

// Al teu paquet, defineixes la interfície que necessites
type WeatherClient interface {
	GetTemperature(city string) (float64, error)
}

// El teu servei depèn de la interfície, no de la implementació
type AlertService struct {
	weather WeatherClient
}

func NewAlertService(w WeatherClient) *AlertService {
	return &AlertService{weather: w}
}

func (s *AlertService) CheckHeatAlert(city string) (bool, error) {
	temp, err := s.weather.GetTemperature(city)
	if err != nil {
		return false, fmt.Errorf(\"getting temperature for %s: %w\", city, err)
	}
	return temp > 40.0, nil
}

En producció uses el client real que crida a l’API. En els tests, crees un mock trivial:

type mockWeather struct {
	temp float64
	err  error
}

func (m *mockWeather) GetTemperature(city string) (float64, error) {
	return m.temp, m.err
}

func TestCheckHeatAlert_HighTemp(t *testing.T) {
	mock := &mockWeather{temp: 42.0}
	service := NewAlertService(mock)

	alert, err := service.CheckHeatAlert(\"Sevilla\")
	if err != nil {
		t.Fatalf(\"unexpected error: %v\", err)
	}
	if !alert {
		t.Error(\"expected heat alert for 42 degrees\")
	}
}

func TestCheckHeatAlert_LowTemp(t *testing.T) {
	mock := &mockWeather{temp: 22.0}
	service := NewAlertService(mock)

	alert, err := service.CheckHeatAlert(\"Santiago\")
	if err != nil {
		t.Fatalf(\"unexpected error: %v\", err)
	}
	if alert {
		t.Error(\"did not expect heat alert for 22 degrees\")
	}
}

func TestCheckHeatAlert_Error(t *testing.T) {
	mock := &mockWeather{err: fmt.Errorf(\"API down\")}
	service := NewAlertService(mock)

	_, err := service.CheckHeatAlert(\"Madrid\")
	if err == nil {
		t.Error(\"expected error when API fails\")
	}
}

Sense Mockito. Sense reflexió. Sense generació de codi. Un struct amb els mètodes que necessites. El compilador verifica que el teu mock satisfà la interfície. Si demà la interfície canvia, el teu mock no compila i ho saps immediatament.

La clau és que la interfície la defineix el consumidor, no el proveïdor. El servei AlertService defineix què necessita (WeatherClient) i qualsevol tipus que satisfaci aquells mètodes serveix. El client HTTP real no sap que existeix aquella interfície. No li cal.

Per aprofundir en patrons de test amb interfícies, mira el que explico a testing a Go.


Errors comuns: interfícies prematures, grasses i contaminació

Després de veure molt codi Go d’equips que venen de Java o C#, hi ha tres patrons que es repeteixen i que hauries d’evitar.

1. Interfícies prematures

L’error més freqüent és crear la interfície abans de tenir una segona implementació.

// Malament: defineixes la interfície abans de necessitar-la
type UserRepository interface {
	GetByID(id int) (*User, error)
	Create(user *User) error
	Update(user *User) error
	Delete(id int) error
	List(filter Filter) ([]*User, error)
	Count(filter Filter) (int, error)
}

// I llavors només tens una implementació
type PostgresUserRepository struct { ... }

Si només tens una implementació, no necessites la interfície encara. Espera que aparegui la necessitat real: un segon storage, un mock per a tests, una caché que embolcalla el repositori. Llavors extreu la interfície amb els mètodes que realment necessites.

L’excepció: si ja saps que necessitaràs mocks per a testing, defineix la interfície des del principi. Però defineix només els mètodes que el teu consumidor necessita, no tots els que el repositori ofereix.

2. Interfícies grasses (fat interfaces)

Directament relacionat amb l’anterior. Una interfície amb deu mètodes és un senyal d’alerta:

// Malament: interfície massa gran
type DataStore interface {
	GetUser(id int) (*User, error)
	CreateUser(user *User) error
	UpdateUser(user *User) error
	DeleteUser(id int) error
	GetOrder(id int) (*Order, error)
	CreateOrder(order *Order) error
	UpdateOrder(order *Order) error
	DeleteOrder(id int) error
	GetProduct(id int) (*Product, error)
	CreateProduct(product *Product) error
	// ... i continua
}

Això és el que passa quan penses la interfície des del proveïdor (“què pot fer el meu store”) en lloc de des del consumidor (“què necessita aquest handler”).

La solució: interfícies segregades, definides on es consumeixen.

// Bé: cada consumidor defineix el que necessita
type UserGetter interface {
	GetUser(id int) (*User, error)
}

type UserCreator interface {
	CreateUser(user *User) error
}

// El teu handler només depèn del que usa
type UserHandler struct {
	getter  UserGetter
	creator UserCreator
}

Si el teu struct de base de dades implementa GetUser i CreateUser, satisfà ambdues interfícies implícitament. No necessites dividir la implementació. Només divideixes el contracte en el punt de consum.

3. Contaminació d’interfícies (interface pollution)

Això passa quan crees una interfície per a cada struct, “per si de cas”. És un antipatró molt comú en equips que venen de Java on Spring t’obliga a tenir un Service i un ServiceImpl per a tot.

// Malament: una interfície per cada struct, sense justificació
type UserService interface {
	GetUser(id int) (*User, error)
}

type userServiceImpl struct { ... }

// I només hi ha una implementació, usada en un sol lloc

A Go, si no necessites polimorfisme ni testing amb mocks, no necessites la interfície. Usa el tipus concret directament. Pots extreure la interfície més tard sense canviar la implementació, gràcies a la satisfacció implícita.

La regla d’or: no dissenyes interfícies, les descobreixes. Quan tinguis dues implementacions o necessitis un mock, aquell és el moment d’extreure la interfície. No abans.


Exemple real: dissenyant interfícies per a un servei backend

Vegem un cas pràctic. Tens un servei de gestió de tasques amb una API REST. Necessites accés a base de dades, un sistema de notificacions i logging. Anem a dissenyar les interfícies pensant des del consumidor.

El handler defineix el que necessita

package task

import (
	\"context\"
	\"time\"
)

type Task struct {
	ID          string
	Title       string
	Description string
	Status      string
	AssignedTo  string
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

// El handler de creació necessita guardar i notificar
type TaskCreator interface {
	Create(ctx context.Context, task *Task) error
}

type Notifier interface {
	Notify(ctx context.Context, userID string, message string) error
}

El handler

type CreateHandler struct {
	store    TaskCreator
	notifier Notifier
}

func NewCreateHandler(store TaskCreator, notifier Notifier) *CreateHandler {
	return &CreateHandler{store: store, notifier: notifier}
}

func (h *CreateHandler) Handle(ctx context.Context, task *Task) error {
	if task.Title == \"\" {
		return fmt.Errorf(\"task title cannot be empty\")
	}

	task.ID = generateID()
	task.Status = \"pending\"
	task.CreatedAt = time.Now()
	task.UpdatedAt = task.CreatedAt

	if err := h.store.Create(ctx, task); err != nil {
		return fmt.Errorf(\"storing task: %w\", err)
	}

	if task.AssignedTo != \"\" {
		msg := fmt.Sprintf(\"New task assigned: %s\", task.Title)
		if err := h.notifier.Notify(ctx, task.AssignedTo, msg); err != nil {
			// Registrem però no fallem: la tasca ja està creada
			log.Printf(\"failed to notify user %s: %v\", task.AssignedTo, err)
		}
	}

	return nil
}

Les implementacions reals

// PostgreSQL store - satisfà TaskCreator implícitament
type PostgresStore struct {
	db *sql.DB
}

func (s *PostgresStore) Create(ctx context.Context, task *Task) error {
	query := `INSERT INTO tasks (id, title, description, status, assigned_to, created_at, updated_at)
	           VALUES ($1, $2, $3, $4, $5, $6, $7)`
	_, err := s.db.ExecContext(ctx, query,
		task.ID, task.Title, task.Description, task.Status,
		task.AssignedTo, task.CreatedAt, task.UpdatedAt)
	return err
}

// Email notifier - satisfà Notifier implícitament
type EmailNotifier struct {
	smtpAddr string
}

func (n *EmailNotifier) Notify(ctx context.Context, userID string, message string) error {
	// Enviar email real...
	return nil
}

Els tests

type mockStore struct {
	tasks []*Task
	err   error
}

func (m *mockStore) Create(_ context.Context, task *Task) error {
	if m.err != nil {
		return m.err
	}
	m.tasks = append(m.tasks, task)
	return nil
}

type mockNotifier struct {
	notifications []string
	err           error
}

func (m *mockNotifier) Notify(_ context.Context, userID string, message string) error {
	if m.err != nil {
		return m.err
	}
	m.notifications = append(m.notifications, fmt.Sprintf(\"%s: %s\", userID, message))
	return nil
}

func TestCreateHandler_Success(t *testing.T) {
	store := &mockStore{}
	notifier := &mockNotifier{}
	handler := NewCreateHandler(store, notifier)

	task := &Task{Title: \"Deploy v2\", AssignedTo: \"user-123\"}
	err := handler.Handle(context.Background(), task)

	if err != nil {
		t.Fatalf(\"unexpected error: %v\", err)
	}
	if len(store.tasks) != 1 {
		t.Errorf(\"expected 1 stored task, got %d\", len(store.tasks))
	}
	if len(notifier.notifications) != 1 {
		t.Errorf(\"expected 1 notification, got %d\", len(notifier.notifications))
	}
	if task.Status != \"pending\" {
		t.Errorf(\"expected status pending, got %s\", task.Status)
	}
}

func TestCreateHandler_EmptyTitle(t *testing.T) {
	handler := NewCreateHandler(&mockStore{}, &mockNotifier{})

	err := handler.Handle(context.Background(), &Task{})
	if err == nil {
		t.Error(\"expected error for empty title\")
	}
}

func TestCreateHandler_StoreError(t *testing.T) {
	store := &mockStore{err: fmt.Errorf(\"connection refused\")}
	handler := NewCreateHandler(store, &mockNotifier{})

	err := handler.Handle(context.Background(), &Task{Title: \"Test\"})
	if err == nil {
		t.Error(\"expected error when store fails\")
	}
}

Fixa’t en com cada peça és independent. El handler no sap si el store és PostgreSQL, MongoDB o un map en memòria. No sap si el notifier envia emails, push notifications o missatges a Slack. Només sap quins mètodes necessita. Això és arquitectura neta a Go a la pràctica, i les interfícies implícites ho fan possible sense cerimònies.


Interfícies vs generics: quan usar cadascun

Des de Go 1.18, els generics estan disponibles i hi ha confusió sobre quan usar interfícies i quan generics. La resposta és més simple del que sembla: resolen problemes diferents.

Interfícies: comportament

Usa interfícies quan t’importa què pot fer un tipus:

// M'importa que pugui llegir bytes
type Reader interface {
	Read(p []byte) (n int, err error)
}

// M'importa que es pugui guardar
type Saver interface {
	Save(ctx context.Context) error
}

Generics: tipus

Usa generics quan necessites operar sobre múltiples tipus amb la mateixa lògica:

// Necessito trobar un element en un slice, sigui quin sigui el tipus
func Contains[T comparable](slice []T, target T) bool {
	for _, v := range slice {
		if v == target {
			return true
		}
	}
	return false
}

// Necessito un map concurrent que funcioni amb qualsevol clau i valor
type SafeMap[K comparable, V any] struct {
	mu sync.RWMutex
	m  map[K]V
}

La línia divisòria

SituacióUsa
Necessites polimorfisme en runtimeInterfícies
Necessites la mateixa lògica per a tipus diferentsGenerics
Necessites desacoblament per a testingInterfícies
Necessites col·leccions type-safeGenerics
Necessites injecció de dependènciesInterfícies
Necessites algorismes genèrics (sort, filter, map)Generics

Pots combinar tots dos. Les constraint interfaces de generics són interfícies que defineixen tant mètodes com tipus permesos:

type Number interface {
	~int | ~int64 | ~float64
}

func Sum[T Number](values []T) T {
	var total T
	for _, v := range values {
		total += v
	}
	return total
}

El consell pràctic: comença amb tipus concrets. Si necessites polimorfisme o desacoblament, extreu una interfície. Si necessites reutilitzar lògica per a tipus diferents, usa generics. No comencis per l’abstracció. Per a més detalls sobre generics, mira generics a Go.


Comparació amb interfícies a Java i Kotlin

Si véns de Java o Kotlin, hi ha diferències conceptuals que canvien la forma de dissenyar.

Java: contractes explícits i rígids

// Java: la interfície es defineix primer
public interface UserRepository {
    User findById(int id);
    void save(User user);
    void delete(int id);
    List<User> findAll();
}

// La implementació declara explícitament que la compleix
public class PostgresUserRepository implements UserRepository {
    @Override
    public User findById(int id) { ... }
    @Override
    public void save(User user) { ... }
    @Override
    public void delete(int id) { ... }
    @Override
    public List<User> findAll() { ... }
}

El problema: si només necessites findById en un punt del codi, continues arrossegant els quatre mètodes. Per segregar, necessites crear noves interfícies i que la implementació les declari explícitament. Això desincentiva la segregació perquè cada nova interfície requereix canvis a la classe que implementa.

Kotlin: millor que Java, mateix model

// Kotlin: més net, mateix model
interface UserRepository {
    fun findById(id: Int): User?
    fun save(user: User)
}

class PostgresUserRepository : UserRepository {
    override fun findById(id: Int): User? { ... }
    override fun save(user: User) { ... }
}

Kotlin millora l’ergonomia amb propietats en interfícies, mètodes per defecte i delegació. Però el model continua sent explícit: la classe ha de declarar quines interfícies implementa.

Go: contractes implícits i descoberts

// Go: defineixes la interfície on la necessites
type UserFinder interface {
	FindByID(ctx context.Context, id int) (*User, error)
}

// PostgresStore ja té FindByID, de manera que satisfà UserFinder
// sense necessitat de modificar res
type PostgresStore struct {
	db *sql.DB
}

func (s *PostgresStore) FindByID(ctx context.Context, id int) (*User, error) {
	// ...
}

func (s *PostgresStore) Save(ctx context.Context, user *User) error {
	// ...
}

func (s *PostgresStore) Delete(ctx context.Context, id int) error {
	// ...
}

La diferència clau: a Go cada consumidor pot definir la seva pròpia interfície amb només els mètodes que necessita. Un handler que només llegeix usuaris depèn de UserFinder. Un altre handler que crea usuaris depèn de UserCreator. Ambdues interfícies són satisfetes pel mateix PostgresStore sense que aquest sàpiga que existeixen.

Taula comparativa

AspecteJavaKotlinGo
SatisfaccióExplícita (implements)Explícita (:)Implícita
DefinicióEl proveïdor decideixEl proveïdor decideixEl consumidor decideix
SegregacióRequereix canvis en la implementacióRequereix canvis en la implementacióSense canvis en la implementació
Herència d’interfíciesextends:Composició (embedding)
Mètodes per defecteSí (Java 8+)No
Interfícies funcionalsSí (@FunctionalInterface)Sí (lambdas)No necessàries (les funcions són ciutadanes de primera classe)
Camps en interfíciesNoSí (propietats)No

El model de Go és més restrictiu en features (no hi ha mètodes per defecte, no hi ha camps) però més flexible en composició i desacoblament. No pots fer tot el que fas amb interfícies a Kotlin, però el que pots fer és més simple i més desacoblat.


Principis per dissenyar interfícies a Go

Després de tot l’anterior, aquests són els principis que segueixo en producció:

1. Defineix interfícies on es consumeixen, no on s’implementen. El paquet que usa la dependència és el que defineix la interfície. No el paquet que la proveeix.

2. Comença sense interfícies. Usa tipus concrets fins que necessitis desacoblament real. Extreure una interfície després és trivial a Go.

3. Mantén les interfícies petites. Un o dos mètodes és l’ideal. Si en té més de tres, pregunta’t si pots dividir-la.

4. Accepta interfícies, retorna structs. Les teves funcions públiques accepten interfícies per a flexibilitat i retornen tipus concrets per a claredat.

5. Anomena les interfícies pel que fan, no pel que són. Reader, Writer, Closer, Stringer. El sufix -er per a interfícies d’un mètode és una convenció que funciona.

6. No crees interfícies “per si de cas”. Cada interfície afegeix una capa d’indireccions. Si no la necessites avui, no la crees.

7. Usa composició per construir interfícies més grans. Combina interfícies petites en lloc de crear interfícies monolítiques.

Aquests principis s’alineen amb l’estructura de projecte Go que descric en un altre article: paquets petits amb responsabilitats clares i dependències expressades a través d’interfícies definides pel consumidor.


No dissenyes interfícies, les descobreixes

Les interfícies de Go són una d’aquelles coses que semblen massa simples fins que les uses en un projecte real. Sense implements, sense frameworks d’injecció de dependències, sense generació de codi per a mocks. Només un contracte implícit que el compilador verifica.

La clau és canviar la mentalitat. No pensis “quin contracte vull que compleixi la meva implementació”. Pensa “quin comportament necessita la meva funció per fer la seva feina”. Defineix aquell comportament com una interfície d’un o dos mètodes al paquet que el consumeix. Deixa que la satisfacció implícita faci la resta.

Si véns de Java o Kotlin, això et resultarà incòmode les primeres setmanes. No tenir implements explícit es nota com conduir sense cinturó. Però després d’un temps entens que el compilador continua verificant tot, que els tests s’escriuen més ràpid, que el desacoblament és més real i que el codi és més simple.

OshyTech

Enginyeria backend i de dades orientada a sistemes escalables, automatització i IA.

Navegació

Copyright 2026 OshyTech. Tots els drets reservats