Interfaces en Go: pequeñas, implícitas y de uso correcto
1. Descripción general
Las interfaces en Go difieren de Java o C# en un punto clave: no existe la palabra clave implements. Un tipo satisface automáticamente una interface si posee los métodos requeridos. Esta característica lleva a un enfoque de diseño único: interfaces pequeñas, declaradas donde se usan, no donde se implementan.
Al terminar este artículo, sabrás cómo escribir interfaces al estilo Go, usarlas para hacer tests sin librerías de mock, y evitar errores comunes como “nil que no es nil”.
2. Las interfaces se satisfacen implícitamente
Comencemos con una interface para enviar notificaciones a clientes:
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("[email desde %s] para %s: %s\n", e.From, customer, message)
return nil
}
type SMSNotifier struct{}
func (SMSNotifier) Notify(customer, message string) error {
fmt.Printf("[sms] para %s: %s\n", customer, message)
return nil
}
func main() {
notifiers := []Notifier{
EmailNotifier{From: "shop@nonla.dev"},
SMSNotifier{},
}
for _, n := range notifiers {
_ = n.Notify("An", "Pedido #42 confirmado")
}
}
Resultado:
[email desde shop@nonla.dev] para An: Pedido #42 confirmado
[sms] para An: Pedido #42 confirmado
En ningún lado de EmailNotifier o SMSNotifier se menciona Notifier. El compilador verifica automáticamente: cualquier tipo con un método Notify(string, string) error puede asignarse a una variable de tipo Notifier.
Una consecuencia importante es que el lado de la implementación no necesita saber que la interface existe. Puedes declarar una interface nueva para un tipo existente en la librería estándar o de terceros sin modificar su código.
3. Mantén las interfaces pequeñas
La librería estándar de Go es el mejor ejemplo. io.Reader e io.Writer tienen exactamente un método cada una:
type Writer interface {
Write(p []byte) (n int, err error)
}
Debido a este pequeño tamaño, muchos tipos la satisfacen: *os.File, *bytes.Buffer, *strings.Builder, http.ResponseWriter, *gzip.Writer… Una función que recibe io.Writer funciona con todos estos tipos:
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, "Pedido #%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) // imprimir a terminal
var sb strings.Builder
_ = WriteReceipt(&sb, order) // escribir a memoria
fmt.Printf("Longitud del recibo: %d bytes\n", sb.Len())
}
Pedido #42 - An - 350000 VND
Longitud del recibo: 26 bytes
WriteReceipt solo necesita la capacidad de “escribir bytes”, así que solo pide exactamente eso. Quieras escribir a un archivo, una respuesta HTTP o un archivo comprimido, no necesitas modificar la función.
Cuando necesites múltiples comportamientos, compone interfaces pequeñas en lugar de escribir una interfaz grande desde el principio:
type OrderFinder interface {
FindOrder(id int) (Order, error)
}
type OrderSaver interface {
SaveOrder(o Order) error
}
type OrderStore interface {
OrderFinder
OrderSaver
}
La función que muestra un pedido solo recibe OrderFinder. La función que actualiza el estado recibe OrderStore. Cada función declara claramente qué necesita, y escribir fakes para tests también es mucho más simple.
4. Declara interfaces en el lado del consumidor
En Java, típicamente escribirías PaymentGateway y luego StripePaymentGateway implements PaymentGateway, ambas lado a lado. En Go, el enfoque es opuesto: el paquete de implementación devuelve un struct concreto, el paquete consumidor declara la interface que necesita.
Supongamos que el paquete payment proporciona un cliente con muchos métodos:
// 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 }
El paquete checkout solo necesita cobrar, así que declara una interface de un método:
// package checkout
var ErrInvalidTotal = errors.New("el total debe ser mayor a 0")
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("cobro del pedido #%d: %w", o.ID, err)
}
return txnID, nil
}
Al inicializar en 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 satisface checkout.PaymentGateway implícitamente. El paquete payment no importa checkout y tampoco necesita saber que existe.
Este principio se resume comúnmente como “accept interfaces, return structs”: las funciones reciben interfaces para ser flexibles, pero devuelven tipos concretos para que quien llama tenga acceso a todos los métodos y campos.
5. Tests con fakes, sin necesidad de librerías de mock
Las interfaces pequeñas en el lado del consumidor hacen que los tests sean muy simples. Solo necesitas escribir un struct con exactamente un método:
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("error inesperado: %v", err)
}
if txn != "txn-test" {
t.Errorf("txn = %q, esperado %q", txn, "txn-test")
}
if len(gw.charged) != 1 || gw.charged[0] != 200000 {
t.Errorf("charged = %v, esperado [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, esperado ErrInvalidTotal", err)
}
if len(gw.charged) != 0 {
t.Errorf("Charge no debe ser llamado, pero charged = %v", gw.charged)
}
}
func TestCheckout_WrapsGatewayError(t *testing.T) {
gwErr := errors.New("tarjeta rechazada")
svc := NewService(&fakeGateway{err: gwErr})
_, err := svc.Checkout(Order{ID: 3, Customer: "Chi", Total: 100000})
if !errors.Is(err, gwErr) {
t.Fatalf("err = %v, esperado envuelto %v", err, gwErr)
}
}
Ejecutar tests:
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
Si PaymentGateway tuviera diez métodos, el fake también tendría que implementar los diez. Eso es una señal clara de que la interface es demasiado grande.
6. Type assertion, type switch y verificación en tiempo de compilación
6.1. Interfaces opcionales con type assertion
A veces quieres usar una capacidad adicional si el tipo concreto la soporta. Go usa type assertions con dos valores:
type Refunder interface {
Refund(txnID string) error
}
func (s *Service) Cancel(txnID string) error {
r, ok := s.gateway.(Refunder)
if !ok {
return errors.New("la puerta de pago no soporta reembolsos")
}
return r.Refund(txnID)
}
Con *payment.Client, ok es true porque tiene el método Refund. Con fakeGateway, ok es false y la función devuelve un error en lugar de hacer panic. La librería estándar usa esta técnica extensamente, por ejemplo io.Copy verifica si el parámetro es io.WriterTo para elegir una ruta más rápida.
Nota: La forma de un valor
r := s.gateway.(Refunder)va a hacer panic si el tipo no coincide. Siempre usa la formar, ok := …a menos que estés completamente seguro.
6.2. Type switch
Cuando necesitas manejar muchos tipos diferentes, usa type switch:
func describe(n Notifier) string {
switch v := n.(type) {
case EmailNotifier:
return "email desde " + v.From
case SMSNotifier:
return "sms"
default:
return fmt.Sprintf("desconocido: %T", v)
}
}
Si encuentras que escribes type switch en tipos comerciales en muchos lugares, probablemente te falte un método en la interface.
6.3. Asegurar la satisfacción de la interface en tiempo de compilación
Porque la satisfacción es implícita, si alguien cambia la firma del método, el error solo aparece en la asignación. Para detectar errores temprano en el archivo de implementación, añade esta línea:
var _ checkout.PaymentGateway = (*payment.Client)(nil)
Esta línea no crea ningún valor cuando se ejecuta. Solo obliga al compilador a verificar si *payment.Client satisface PaymentGateway. Úsalo solo cuando el paquete de implementación realmente quiera comprometerse con esa interface. Si no, creará una importación circular innecesaria.
7. Errores comunes
7.1. Una interface que contiene un nil pointer no es nil
Este es el error más clásico de Go:
type ValidationError struct{ Field string }
func (e *ValidationError) Error() string {
return "campo no válido: " + e.Field
}
func validate(o Order) error {
var verr *ValidationError
if o.Total <= 0 {
verr = &ValidationError{Field: "Total"}
}
return verr // INCORRECTO
}
func main() {
err := validate(Order{Total: 100})
fmt.Println(err == nil)
}
false
Un valor de interface consta de dos partes: tipo y valor. Una interface solo es nil cuando ambas partes están vacías. Aquí el tipo es *ValidationError y el valor es un puntero nil, por lo que err != nil. La forma de arreglarlo es devolver nil explícitamente:
func validate(o Order) error {
if o.Total <= 0 {
return &ValidationError{Field: "Total"}
}
return nil
}
7.2. Pointer receiver y method set
type AuditNotifier struct{ count int }
func (a *AuditNotifier) Notify(customer, message string) error {
a.count++
return nil
}
var n Notifier = AuditNotifier{} // error de compilación
cannot use AuditNotifier{} (value of struct type AuditNotifier) as Notifier value
in variable declaration: AuditNotifier does not implement Notifier
(method Notify has pointer receiver)
Un método con pointer receiver solo pertenece al method set de *AuditNotifier. Necesitas asignar &AuditNotifier{}. Inversamente, un método con value receiver funciona tanto para values como para pointers.
7.3. Escribir interfaces antes de necesitarlas
El tipo de código con hábitos de Java suele verse así:
type OrderService interface {
Create(o Order) error
Cancel(id int) error
Find(id int) (Order, error)
// … más 10 métodos
}
type orderServiceImpl struct{ /* … */ }
func NewOrderService() OrderService { return &orderServiceImpl{} }
Este enfoque tiene tres problemas. La interface tiene solo una implementación. Está en el lado de la implementación en lugar del lado del consumidor. El constructor devuelve una interface, por lo que quien llama pierde acceso al tipo concreto. Devuelve un struct *OrderService, y deja que cada paquete consumidor declare su propia interfaz pequeña cuando realmente necesita reemplazarlo, por ejemplo para tests.
7.4. Abusar de any
any (alias de interface{}) acepta cualquier tipo, así que el compilador no puede ayudarte más. A partir de Go 1.18, si necesitas una función que funcione en múltiples tipos, considera primero genéricos:
func SumTotals[T interface{ GetTotal() int64 }](items []T) int64 {
var sum int64
for _, it := range items {
sum += it.GetTotal()
}
return sum
}
Solo usa any cuando los datos realmente no conocen el tipo de antemano, como al decodificar JSON arbitrario.
8. Conclusión
Las interfaces en Go son poderosas precisamente porque son simples. Puntos a recordar:
- Los tipos satisfacen interfaces implícitamente, sin necesidad de
implements. - Las interfaces deben ser pequeñas, idealmente uno o dos métodos. Cuando necesites más, compone interfaces pequeñas.
- Declara interfaces en el lado del consumidor. Las funciones reciben interfaces, los constructores devuelven structs.
- Las interfaces pequeñas permiten tests con fakes escritos a mano, sin necesidad de librerías de mock.
- Cuidado con interfaces que contienen nil pointers y con el method set de pointer receivers.
La próxima vez que vayas a escribir una interface, pregúntate: “¿Quién la usa y qué métodos realmente necesita?” La respuesta suele ser menos de lo que piensas.