Interfaces en Go: pequeñas, implícitas y más simples de lo que parecen

Interfaces implícitas en Go: composición, testing, desacoplamiento y errores habituales de diseño. Piensa desde el consumidor.

Cover for Interfaces en Go: pequeñas, implícitas y más simples de lo que parecen

En Java, declaras interfaces por adelantado. Creas el contrato antes de escribir una sola línea de implementación. En Go, las interfaces se descubren después de escribir el código. Esta diferencia no es un detalle de sintaxis. Es una forma completamente distinta de pensar el diseño de software.

Vengo de trabajar con Kotlin y Java durante años. Interfaces explícitas, implements, contratos formales, inyección de dependencias con frameworks. Todo muy ceremonioso. Cuando empecé con Go, las interfaces implícitas me parecieron raras. Después de usarlas en producción, me parecen una de las mejores decisiones de diseño del lenguaje.

Si vienes de la JVM o de cualquier lenguaje con interfaces explícitas, este artículo va a cambiar tu forma de pensar el desacoplamiento. Porque en Go, la pregunta no es “qué contrato quiero imponer”, sino “qué comportamiento necesito consumir”.


Satisfacción implícita: no hay keyword implements

La primera diferencia que notas viniendo de otros lenguajes es que en Go no existe la palabra implements. Un tipo satisface una interfaz automáticamente si tiene todos los métodos que la interfaz define. Nada 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 y Cat satisfacen Speaker sin declararlo en ningún sitio. No hay anotación, no hay registro, no hay relación explícita. El compilador lo verifica cuando intentas usar un Dog donde se espera un Speaker:

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

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

Esto tiene implicaciones profundas. No necesitas que el autor del tipo sepa qué interfaces va a satisfacer. Puedes definir una interfaz en tu paquete que un tipo de otro paquete (o de la librería estándar) ya satisface sin haber sido diseñado para ello. Es un desacoplamiento que no puedes conseguir con interfaces explícitas.

Piénsalo así: en Java, si quieres que una clase implemente tu interfaz, necesitas modificar esa clase o crear un wrapper. En Go, simplemente defines la interfaz con los métodos que necesitas y cualquier tipo que ya tenga esos métodos la cumple automáticamente.

// Esto funciona sin que os.File sepa nada de tu interfaz
type ReadCloser interface {
	Read(p []byte) (n int, err error)
	Close() error
}

// *os.File ya tiene Read y Close, así que satisface ReadCloser
var rc ReadCloser = os.Stdout

Por qué las interfaces pequeñas ganan

Si miras la librería estándar de Go, las interfaces más usadas son diminutas. Un método. Dos como mucho. Esto no es casualidad. Es una decisión de diseño que permea todo el ecosistema.

io.Reader y io.Writer

Las dos interfaces más importantes de Go tienen un solo método cada una:

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

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

Un método. Eso es todo. Y sin embargo, todo el ecosistema de I/O de Go se construye sobre estas dos interfaces. Ficheros, conexiones de red, buffers, compresión, cifrado, HTTP bodies… todo implementa io.Reader, io.Writer o ambos.

¿Por qué funciona tan bien? Porque una interfaz de un solo método es extremadamente fácil de satisfacer. Cualquier tipo que pueda leer bytes puede ser un Reader. Cualquier tipo que pueda escribir bytes puede ser un Writer. La barrera de entrada es mínima y el valor de la composición es máximo.

fmt.Stringer

type Stringer interface {
	String() string
}

Si tu tipo tiene un método String() string, puedes usarlo directamente con fmt.Println, fmt.Sprintf y cualquier función de formateo. Sin registrar nada, sin heredar de ninguna clase 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 interfaz error es otro ejemplo perfecto:

type error interface {
	Error() string
}

Un método. Cualquier tipo que tenga Error() string es un error en Go. Puedes crear errores personalizados sin heredar de una clase base, sin implementar diez métodos que no necesitas:

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 lección es clara: cuanto más pequeña es la interfaz, más tipos la satisfacen, más componentes puedes componer y más flexible es tu sistema. Rob Pike lo dijo mejor: “The bigger the interface, the weaker the abstraction.”


Composición de interfaces: combinando piezas pequeñas

Go no tiene herencia. No puedes extender una interfaz como en Java con extends. Lo que tienes es composición: incrustar interfaces dentro de otras para construir contratos más grandes a partir de piezas pequeñas.

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ón: ReadWriter es un Reader Y un Writer
type ReadWriter interface {
	Reader
	Writer
}

// Composición más amplia
type ReadWriteCloser interface {
	Reader
	Writer
	Closer
}

Esto es exactamente lo que hace la librería estándar en el paquete io. Las interfaces grandes se construyen componiendo interfaces pequeñas. Un tipo que satisface ReadWriteCloser también satisface Reader, Writer, Closer, ReadWriter y cualquier otra combinación. Todo implícitamente.

La ventaja sobre la herencia clásica es que no hay jerarquía rígida. Puedes crear las combinaciones que necesites sin depender de una taxonomía predefinida:

// En tu paquete, defines exactamente lo que necesitas
type ReadFlusher interface {
	io.Reader
	Flush() error
}

Nadie necesita haber previsto esta combinación. Si un tipo tiene Read y Flush, satisface ReadFlusher. Punto. Esto es lo que hace que el diseño en Go sea tan orgánico: los contratos se descubren, no se imponen.


La interfaz vacía: interface y any

La interfaz vacía no define ningún método. Por tanto, todos los tipos la satisfacen. Es el equivalente de Object en Java o Any en Kotlin:

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

// Desde Go 1.18, any es un alias de interface{}
func Print(v any) {
	fmt.Println(v)
}

Puedes pasar lo que quieras: un int, un string, un struct, un slice. Todo es any.

Cuándo usar any

Tiene sentido en casos muy específicos:

  • Funciones de logging o depuración: que aceptan cualquier cosa
  • Serialización/deserialización genérica: como json.Marshal(v any)
  • Contenedores genéricos: antes de que existieran generics

Cuándo no usar any

La mayoría del tiempo. Si usas any para evitar pensar en el tipo, estás perdiendo la principal ventaja de Go: el sistema de tipos estático. Cada vez que usas any, estás moviendo errores del compilador al runtime.

// Mal: pierdes toda la seguridad de tipos
func Sum(a, b any) any {
	// Necesitas type assertions, manejo de errores...
	return a.(int) + b.(int) // panic si no son int
}

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

Desde Go 1.18 con generics, la necesidad de any ha disminuido significativamente. Si puedes expresar el tipo con generics, hazlo. El compilador te lo va a agradecer con errores en tiempo de compilación en lugar de panics en producción.

Cada uso de any debería hacerte preguntarte: “¿Puedo expresar esto con una interfaz específica o con generics?” Si la respuesta es sí, usa eso en su lugar.


Acepta interfaces, devuelve structs

Este es el principio de diseño más importante para interfaces en Go y el que más cuesta interiorizar viniendo de Java o Kotlin.

La idea es simple: las funciones deberían aceptar interfaces como parámetros y devolver tipos concretos como resultado.

// Bien: acepta una interfaz, devuelve un tipo concreto
func NewUserService(repo UserRepository) *UserService {
	return &UserService{repo: repo}
}

// Mal: devuelve una interfaz
func NewUserService(repo UserRepository) UserServiceInterface {
	return &UserService{repo: repo}
}

¿Por qué? Porque aceptar interfaces da flexibilidad al que llama: puede pasar cualquier implementación que satisfaga el contrato. Pero devolver structs concretos da información al que recibe: sabe exactamente qué tipo tiene, puede acceder a todos sus métodos (no solo los de la interfaz) y el compilador puede optimizar mejor.

Devolver interfaces oculta información sin necesidad. El que recibe el valor tiene que trabajar con un contrato reducido cuando podría tener acceso a todo. Solo devuelve interfaces cuando haya una razón real para ocultar la implementación, como en factorías donde la implementación concreta es un detalle interno.

Este principio aplica especialmente a constructores y funciones de creación. Si tu función NewX devuelve una interfaz, estás añadiendo una capa de abstracción que probablemente nadie necesita.


Interfaces para testing: mocking sin frameworks

Aquí es donde las interfaces implícitas de Go brillan con más fuerza. En Java o Kotlin, para mockear una dependencia necesitas frameworks como Mockito, o generar proxies dinámicos, o crear implementaciones manuales de interfaces explícitas. En Go, defines una interfaz con los métodos que usas y creas un struct que los implemente. Nada más.

Imaginemos un servicio que obtiene datos de una API externa:

// En tu paquete, defines la interfaz que necesitas
type WeatherClient interface {
	GetTemperature(city string) (float64, error)
}

// Tu servicio depende de la interfaz, no de la implementación
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ón usas el cliente real que llama a la API. En tests, creas 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")
	}
}

Sin Mockito. Sin reflexión. Sin generación de código. Un struct con los métodos que necesitas. El compilador verifica que tu mock satisface la interfaz. Si mañana la interfaz cambia, tu mock no compila y lo sabes inmediatamente.

La clave está en que la interfaz la define el consumidor, no el proveedor. El servicio AlertService define qué necesita (WeatherClient) y cualquier tipo que satisfaga esos métodos sirve. El cliente HTTP real no sabe que existe esa interfaz. No le hace falta.

Para profundizar en patrones de test con interfaces, mira lo que explico en testing en Go.


Errores comunes: interfaces prematuras, gordas y contaminación

Después de ver mucho código Go de equipos que vienen de Java o C#, hay tres patrones que se repiten y que deberías evitar.

1. Interfaces prematuras

El error más frecuente es crear la interfaz antes de tener una segunda implementación.

// Mal: defines la interfaz antes de necesitarla
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)
}

// Y luego solo tienes una implementación
type PostgresUserRepository struct { ... }

Si solo tienes una implementación, no necesitas la interfaz todavía. Espera a que aparezca la necesidad real: un segundo storage, un mock para tests, un caché que envuelve el repositorio. Entonces extraes la interfaz con los métodos que realmente necesitas.

La excepción: si ya sabes que vas a necesitar mocks para testing, define la interfaz desde el principio. Pero define solo los métodos que tu consumidor necesita, no todos los que el repositorio ofrece.

2. Interfaces gordas (fat interfaces)

Directamente relacionado con lo anterior. Una interfaz con diez métodos es una señal de alerta:

// Mal: interfaz demasiado grande
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
	// ... y sigue
}

Esto es lo que pasa cuando piensas la interfaz desde el proveedor (“qué puede hacer mi store”) en lugar de desde el consumidor (“qué necesita este handler”).

La solución: interfaces segregadas, definidas donde se consumen.

// Bien: cada consumidor define lo que necesita
type UserGetter interface {
	GetUser(id int) (*User, error)
}

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

// Tu handler solo depende de lo que usa
type UserHandler struct {
	getter  UserGetter
	creator UserCreator
}

Si tu struct de base de datos implementa GetUser y CreateUser, satisface ambas interfaces implícitamente. No necesitas dividir la implementación. Solo divides el contrato en el punto de consumo.

3. Contaminación de interfaces (interface pollution)

Esto ocurre cuando creas una interfaz para cada struct, “por si acaso”. Es un anti-patrón muy común en equipos que vienen de Java donde Spring te obliga a tener un Service y un ServiceImpl para todo.

// Mal: una interfaz por cada struct, sin justificación
type UserService interface {
	GetUser(id int) (*User, error)
}

type userServiceImpl struct { ... }

// Y solo hay una implementación, usada en un solo sitio

En Go, si no necesitas polimorfismo ni testing con mocks, no necesitas la interfaz. Usa el tipo concreto directamente. Puedes extraer la interfaz más tarde sin cambiar la implementación, gracias a la satisfacción implícita.

La regla de oro: no diseñes interfaces, descúbrelas. Cuando tengas dos implementaciones o necesites un mock, ese es el momento de extraer la interfaz. No antes.


Ejemplo real: diseñando interfaces para un servicio backend

Veamos un caso práctico. Tienes un servicio de gestión de tareas con una API REST. Necesitas acceso a base de datos, un sistema de notificaciones y logging. Vamos a diseñar las interfaces pensando desde el consumidor.

El handler define lo que necesita

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ón necesita guardar y 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 {
			// Loggeamos pero no fallamos: la tarea ya está creada
			log.Printf("failed to notify user %s: %v", task.AssignedTo, err)
		}
	}

	return nil
}

Las implementaciones reales

// PostgreSQL store - satisface TaskCreator implícitamente
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 - satisface Notifier implícitamente
type EmailNotifier struct {
	smtpAddr string
}

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

Los 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")
	}
}

Fíjate en cómo cada pieza es independiente. El handler no sabe si el store es PostgreSQL, MongoDB o un map en memoria. No sabe si el notifier envía emails, push notifications o mensajes a Slack. Solo sabe qué métodos necesita. Esto es arquitectura limpia en Go en la práctica, y las interfaces implícitas lo hacen posible sin ceremonias.


Interfaces vs generics: cuándo usar cada uno

Desde Go 1.18, los generics están disponibles y hay confusión sobre cuándo usar interfaces y cuándo generics. La respuesta es más simple de lo que parece: resuelven problemas diferentes.

Interfaces: comportamiento

Usa interfaces cuando te importa qué puede hacer un tipo:

// Me importa que pueda leer bytes
type Reader interface {
	Read(p []byte) (n int, err error)
}

// Me importa que pueda guardarse
type Saver interface {
	Save(ctx context.Context) error
}

Generics: tipos

Usa generics cuando necesitas operar sobre múltiples tipos con la misma lógica:

// Necesito encontrar un elemento en un slice, sea cual sea el tipo
func Contains[T comparable](slice []T, target T) bool {
	for _, v := range slice {
		if v == target {
			return true
		}
	}
	return false
}

// Necesito un map concurrente que funcione con cualquier clave y valor
type SafeMap[K comparable, V any] struct {
	mu sync.RWMutex
	m  map[K]V
}

La línea divisoria

SituaciónUsa
Necesitas polimorfismo en runtimeInterfaces
Necesitas la misma lógica para distintos tiposGenerics
Necesitas desacoplamiento para testingInterfaces
Necesitas colecciones type-safeGenerics
Necesitas inyección de dependenciasInterfaces
Necesitas algoritmos genéricos (sort, filter, map)Generics

Puedes combinar ambos. Las constraint interfaces de generics son interfaces que definen tanto métodos como tipos permitidos:

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 consejo práctico: empieza con tipos concretos. Si necesitas polimorfismo o desacoplamiento, extrae una interfaz. Si necesitas reutilizar lógica para distintos tipos, usa generics. No empieces por la abstracción. Para más detalles sobre generics, mira generics en Go.


Comparación con interfaces en Java y Kotlin

Si vienes de Java o Kotlin, hay diferencias conceptuales que cambian la forma de diseñar.

Java: contratos explícitos y rígidos

// Java: la interfaz se define primero
public interface UserRepository {
    User findById(int id);
    void save(User user);
    void delete(int id);
    List<User> findAll();
}

// La implementación declara explícitamente que la cumple
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 solo necesitas findById en un punto del código, sigues arrastrando los cuatro métodos. Para segregar, necesitas crear nuevas interfaces y que la implementación las declare explícitamente. Esto desincentiva la segregación porque cada nueva interfaz requiere cambios en la clase que implementa.

Kotlin: mejor que Java, mismo modelo

// Kotlin: más limpio, mismo modelo
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 mejora la ergonomía con propiedades en interfaces, métodos por defecto y delegación. Pero el modelo sigue siendo explícito: la clase tiene que declarar qué interfaces implementa.

Go: contratos implícitos y descubiertos

// Go: defines la interfaz donde la necesitas
type UserFinder interface {
	FindByID(ctx context.Context, id int) (*User, error)
}

// PostgresStore ya tiene FindByID, así que satisface UserFinder
// sin necesidad de modificar nada
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 diferencia clave: en Go cada consumidor puede definir su propia interfaz con solo los métodos que necesita. Un handler que solo lee usuarios depende de UserFinder. Otro handler que crea usuarios depende de UserCreator. Ambas interfaces son satisfechas por el mismo PostgresStore sin que este sepa que existen.

Tabla comparativa

AspectoJavaKotlinGo
SatisfacciónExplícita (implements)Explícita (:)Implícita
DefiniciónProveedor decideProveedor decideConsumidor decide
SegregaciónRequiere cambios en implementaciónRequiere cambios en implementaciónSin cambios en implementación
Herencia de interfacesextends:Composición (embedding)
Métodos por defectoSí (Java 8+)No
Interfaces funcionalesSí (@FunctionalInterface)Sí (lambdas)No necesarias (funciones son ciudadanos de primera clase)
Campos en interfacesNoSí (propiedades)No

El modelo de Go es más restrictivo en features (no hay métodos por defecto, no hay campos) pero más flexible en composición y desacoplamiento. No puedes hacer todo lo que haces con interfaces en Kotlin, pero lo que puedes hacer es más simple y más desacoplado.


Principios para diseñar interfaces en Go

Después de todo lo anterior, estos son los principios que sigo en producción:

1. Define interfaces donde se consumen, no donde se implementan. El paquete que usa la dependencia es el que define la interfaz. No el paquete que la provee.

2. Empieza sin interfaces. Usa tipos concretos hasta que necesites desacoplamiento real. Extraer una interfaz después es trivial en Go.

3. Mantén interfaces pequeñas. Una o dos métodos es lo ideal. Si tiene más de tres, pregúntate si puedes dividirla.

4. Acepta interfaces, devuelve structs. Tus funciones públicas aceptan interfaces para flexibilidad y devuelven tipos concretos para claridad.

5. Nombra interfaces por lo que hacen, no por lo que son. Reader, Writer, Closer, Stringer. El sufijo -er para interfaces de un método es una convención que funciona.

6. No crees interfaces “por si acaso”. Cada interfaz añade una capa de indirección. Si no la necesitas hoy, no la crees.

7. Usa composición para construir interfaces más grandes. Combina interfaces pequeñas en lugar de crear interfaces monolíticas.

Estos principios se alinean con la estructura de proyecto Go que describo en otro artículo: paquetes pequeños con responsabilidades claras y dependencias expresadas a través de interfaces definidas por el consumidor.


No diseñes interfaces, descúbrelas

Las interfaces de Go son una de esas cosas que parecen demasiado simples hasta que las usas en un proyecto real. Sin implements, sin frameworks de inyección de dependencias, sin generación de código para mocks. Solo un contrato implícito que el compilador verifica.

La clave está en cambiar la mentalidad. No pienses “qué contrato quiero que cumpla mi implementación”. Piensa “qué comportamiento necesita mi función para hacer su trabajo”. Define ese comportamiento como una interfaz de uno o dos métodos en el paquete que lo consume. Deja que la satisfacción implícita haga el resto.

Si vienes de Java o Kotlin, esto va a sentirse incómodo las primeras semanas. No tener implements explícito se siente como conducir sin cinturón. Pero después de un tiempo entiendes que el compilador sigue verificando todo, que los tests se escriben más rápido, que el desacoplamiento es más real y que el código es más simple.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados