“Acepta interfaces, retorna structs” es una regla de diseño para definir la flexibilidad de tus APIs. Se trata de cómo gestionas la información de tipos y comportamientos en los límites de tus funciones.
En los parámetros (inputs), queremos desacoplamiento. Si una función acepta una interfaz, es fácil de testear porque puedes pasar un objeto simulado (mock) que implemente solo el comportamiento necesario sin depender de implementaciones reales complejas. La función no necesita conocer la identidad completa del objeto, solo su capacidad de actuar.
En los retornos (outputs), queremos información. Al devolver un tipo concreto, le das al consumidor la libertad de decidir. Si devuelves un struct, el llamador tiene acceso a todos sus métodos y propiedades. Si devuelves una interfaz, estás decidiendo por el usuario: le estás imponiendo qué puede y qué no puede hacer con ese objeto. Si el consumidor necesita un método extra que el struct posee pero tu interfaz no incluye, se verá obligado a realizar una aserción de tipo (type assertion) para recuperar el tipo original, lo cual es un síntoma de un diseño de API deficiente.
La única excepción estándar es el tipo error, que es una interfaz y es el estándar idiomático en Go para indicar fallos.
package main
import (
"bytes"
"fmt"
"io"
)
// DataStore es nuestra estructura de datos principal.
type DataStore로 struct {
buffer *bytes.Buffer
}
// NewDataStore es el constructor.
// Retorna el tipo concreto (*DataStore), no una interfaz.
// Esto permite que el caller acceda a toda la información del struct.
func NewDataStore(contenido string) *DataStore {
return &DataStore{
buffer: bytes.NewBufferString(contenido),
}
}
// Process es una función que "acepta interfaces".
// Solo le importa que el input sepa leer (io.Reader).
// Es extremadamente fácil de testear con un mock.
func Process(r io.Reader) (int, error) {
buf := make([]byte, 4)
n, err := r.Read(buf)
return n, err
}
// GetData retorna el tipo concreto (*bytes.Buffer).
// Al devolver el struct real, permitimos que el caller use métodos
// que no están en la interfaz io.Reader, como Seek o Reset.
func (ds *DataStore) GetData() *bytes.Buffer {
return ds.buffer
}
// GetDataAsInterface es un error de diseño: retorna una interfaz.
// Oculta la capacidad de realizar Seek, aunque el objeto sea un buffer.
func (ds *DataStore) GetDataAsInterface() io.Reader {
return ds.buffer
}
func main() {
ds := NewDataStore("datos críticos")
// 1. Uso correcto: Aceptamos interfaz y retornamos struct.
// Pasamos un *bytes.Buffer donde se espera un io.Reader.
n, _ := Process(ds.GetData())
fmt.Printf("Leído: %d bytes\n", n)
// Gracias a que GetData() retorna un *bytes.Buffer,
// podemos usar Seek para mover el puncre del buffer.
buf := ds.GetData()
err := buf.Seek(0, io.SeekStart)
if err != nil {
fmt.Println("Error al resetear:", err)
}
fmt.Println("Buffer reseteado con Seek.")
// 2. El problema de retornar interfaces:
// El usuario recibe un io.Reader y no puede usar Seek.
badReader := ds.GetDataAsInterface()
// Para poder usar Seek, el usuario se ve obligado a la aserción de tipo:
// Es frágil porque depende de que el tipo real sea realmente un io.ReadSeeker.
if seeker, ok := badReader.(io.ReadSeeker); ok {
seeker.Seek(0, io.SeekStart)
fmt.Println("Seek realizado mediante aserción de tipo.")
} else {
fmt.Println("No se pudo realizar Seek.")
}
}
Análisis del código
NewDataStore: Al retornar*DataStore(el tipo concreto), permitimos que el usuario decida si quiere usarlo como un objeto de almacenamiento completo o si quiere tratarlo como una interfaz más simple.Process: Esta función es el ejemplo ideal de “aceptar interfaces”. No le importa si los datos vienen de un*bytes.Buffer, de un archivo en disco o de una conexión de red; mientras implementenRead, la función funcionará. Esto la hace altamente reutilizable.GetData: Al retornar*bytes.Buffer, el llamador no tiene restricciones. En elmain, vemos que podemos llamar abuf.Seek(...)directamente. Si hubiéramos limitado el retorno aio.Reader, el métodoSeeksería inaccesible sin trucos adicionales.GetDataAsInterface: Aquí es donde el diseño falla. Al retornario.Reader, hemos “ocultado” la capacidad de movimiento (Seek) del objeto. Elmainmuestra cómo el programador se ve forzado a realizar una aserción de tipo (badReader.(io.ReadSeeker)) para intentar recuperar la funcionalidad perdida.
El error frecuente
El error más común es intentar ser “genérico” de más en los retornos. Si tu función devuelve una interfaz, estás asumiendo que el consumidor solo necesita las capacidades mínimas de esa interfaz.
// ERROR: Retornar una interfaz que oculta capacidades útiles.
func GetData() io.Reader {
return bytes.NewBufferString("mucha información")
}
// El usuario intentará hacer esto y fallará:
// data := GetData()
// data.Seek(0, 0) // Error de compilación: io.Reader no tiene método Seek
Si el objeto real tiene métodos como Seek, Reset, o Close que son útiles para el flujo normal, devuelve el tipo concreto.
Diseña tus funciones para que el consumidor tenga el control, no para que tú se lo impongas.
N° 74