Interfaces en Go: Patrones de Diseño y Buenas Prácticas
Introducción
Las interfaces en Go representan una de las características más elegantes y potentes del lenguaje, fundamentalmente diferentes de las interfaces en lenguajes orientados a objetos tradicionales. A diferencia de la implementación explícita de interfaces que encontramos en Java o C#, la satisfacción implícita de interfaces de Go crea un sistema flexible que promueve código limpio y desacoplado sin la ceremonia de las jerarquías de herencia.
La simplicidad de las interfaces en Go es su principal fortaleza: cualquier tipo que implemente los métodos requeridos satisface automáticamente la interfaz. Esto fomenta la composición sobre la herencia y ayuda a hacer tu código modular, testeable y adaptable. Pero con esta flexibilidad viene responsabilidad: interfaces mal diseñadas llevan a acoplamiento, confusión y APIs frágiles.
En este artículo, te mostraré los patrones de diseño y buenas prácticas que te ayudarán a aprovechar las interfaces de Go a su máximo potencial. Ya estés construyendo microservicios, herramientas CLI o sistemas distribuidos complejos, entender estos patrones hará tu código Go más robusto y elegante.
Entendiendo los Fundamentos de las Interfaces de Go
Antes de adentrarnos en los patrones de diseño, establezcamos una base sólida sobre cómo funcionan las interfaces en Go y qué las hace especiales.
El Sistema de Interfaces Implícitas
El modelo de interfaces de Go utiliza tipado estructural: un tipo no necesita declarar explícitamente que "implementa" una interfaz. Si tiene las firmas de métodos necesarias, satisface la interfaz.
package main
import "fmt"
// Writer interface define un contrato para escribir
type Writer interface {
Write(data []byte) (int, error)
}
// FileWriter implementa Writer implícitamente
type FileWriter struct {
filename string
}
func (fw *FileWriter) Write(data []byte) (int, error) {
fmt.Printf("Writing %d bytes to file: %s\n", len(data), fw.filename)
return len(data), nil
}
// NetworkWriter también implementa Writer implícitamente
type NetworkWriter struct {
endpoint string
}
func (nw *NetworkWriter) Write(data []byte) (int, error) {
fmt.Printf("Sending %d bytes to endpoint: %s\n", len(data), nw.endpoint)
return len(data), nil
}
func processData(w Writer, data []byte) error {
_, err := w.Write(data)
return err
}
func main() {
data := []byte("Hello, Go interfaces!")
fileWriter := &FileWriter{filename: "output.txt"}
networkWriter := &NetworkWriter{endpoint: "api.example.com"}
processData(fileWriter, data)
processData(networkWriter, data)
}
En este ejemplo, processData
define la interfaz que espera.
Tipos como FileWriter
y NetworkWriter
la satisfacen automáticamente implementando el método Write
, sin ninguna declaración explícita.
Este patrón de definir interfaces en el lado del consumidor fomenta el bajo acoplamiento.
Valores de Interfaz y Dynamic Dispatch
Internamente, los valores de interfaz de Go contienen dos componentes: un tipo y un valor. Esto permite el despacho dinámico manteniendo la seguridad de tipos:
package main
import (
"fmt"
"reflect"
)
type Greeter interface {
Greet() string
}
type EnglishGreeter struct {
name string
}
func (eg EnglishGreeter) Greet() string {
return fmt.Sprintf("Hello, %s!", eg.name)
}
type SpanishGreeter struct {
name string
}
func (sg SpanishGreeter) Greet() string {
return fmt.Sprintf("¡Hola, %s!", sg.name)
}
func main() {
var greeter Greeter
// El valor de la interface es nil inicialmente
fmt.Printf("Initial: %v, %T\n", greeter, greeter)
// Asignar valor EnglishGreeter
greeter = EnglishGreeter{name: "Aitana"}
fmt.Printf("English: %s, Type: %s\n", greeter.Greet(), reflect.TypeOf(greeter))
// Asignar valor SpanishGreeter
greeter = SpanishGreeter{name: "Àlex"}
fmt.Printf("Spanish: %s, Type: %s\n", greeter.Greet(), reflect.TypeOf(greeter))
}
En este ejemplo, la variable greeter
de tipo Greeter
puede contener valores de diferentes tipos concretos (EnglishGreeter
y SpanishGreeter
).
Cuando se asigna un valor a greeter
, el valor de la interfaz contiene tanto el tipo concreto como el valor, permitiendo que las llamadas a métodos se despachen a la implementación correcta en tiempo de ejecución.
Entender esta naturaleza dual de los valores de interfaz es crucial para el diseño efectivo de interfaces y ayuda a evitar errores comunes como problemas de comparación de interfaces nil.
Principales Patrones de Diseño de Interfaces
Ahora aprendamos sobre algunos de los patrones fundamentales que forman los bloques de construcción del diseño efectivo de interfaces en Go.
1. El Patrón Strategy
El patrón Strategy te permite definir una familia de algoritmos, encapsular cada uno y hacerlos intercambiables. Las interfaces de Go hacen este patrón particularmente elegante:
package main
import (
"fmt"
"sort"
)
// SortStrategy define diferentes enfoques de ordenamiento
type SortStrategy interface {
Sort(data []string) []string
}
// AlphabeticalSort implementa ordenamiento lexicográfico
type AlphabeticalSort struct{}
func (as AlphabeticalSort) Sort(data []string) []string {
result := make([]string, len(data))
copy(result, data)
sort.Strings(result)
return result
}
// LengthSort ordena por longitud de cadena
type LengthSort struct{}
func (ls LengthSort) Sort(data []string) []string {
result := make([]string, len(data))
copy(result, data)
sort.Slice(result, func(i, j int) bool {
return len(result[i]) < len(result[j])
})
return result
}
// ReverseSort ordena en orden alfabético inverso
type ReverseSort struct{}
func (rs ReverseSort) Sort(data []string) []string {
result := make([]string, len(data))
copy(result, data)
sort.Sort(sort.Reverse(sort.StringSlice(result)))
return result
}
// DataProcessor usa una estrategia para procesar datos
type DataProcessor struct {
strategy SortStrategy
}
func (dp *DataProcessor) SetStrategy(strategy SortStrategy) {
dp.strategy = strategy
}
func (dp *DataProcessor) ProcessData(data []string) []string {
if dp.strategy == nil {
return data
}
return dp.strategy.Sort(data)
}
func main() {
data := []string{"banana", "apple", "cherry", "date", "elderberry"}
processor := &DataProcessor{}
// Usar diferentes estrategias
strategies := map[string]SortStrategy{
"Alphabetical": AlphabeticalSort{},
"By Length": LengthSort{},
"Reverse": ReverseSort{},
}
for name, strategy := range strategies {
processor.SetStrategy(strategy)
result := processor.ProcessData(data)
fmt.Printf("%s: %v\n", name, result)
}
}
Este patrón es particularmente útil en aplicaciones dirigidas por configuración donde el comportamiento necesita cambiar basado en preferencias del usuario o configuraciones del entorno. Cuando la aplicación se inicia, una estrategia específica puede ser seleccionada basada en la configuración, permitiendo código flexible y mantenible.
2. El Patrón Decorator
El patrón decorator te permite añadir comportamiento a objetos dinámicamente sin alterar su estructura. La composición de interfaces de Go hace que este patrón sea natural:
package main
import (
"fmt"
"strings"
"time"
)
// MessageProcessor define la interface central
type MessageProcessor interface {
Process(message string) string
}
// BaseProcessor proporciona procesamiento básico de mensajes
type BaseProcessor struct{}
func (bp BaseProcessor) Process(message string) string {
return message
}
// UppercaseDecorator convierte mensajes a mayúsculas
type UppercaseDecorator struct {
processor MessageProcessor
}
func (ud UppercaseDecorator) Process(message string) string {
result := ud.processor.Process(message)
return strings.ToUpper(result)
}
// TimestampDecorator añade marcas de tiempo a los mensajes
type TimestampDecorator struct {
processor MessageProcessor
}
func (td TimestampDecorator) Process(message string) string {
result := td.processor.Process(message)
timestamp := time.Now().Format("2006-01-02 15:04:05")
return fmt.Sprintf("[%s] %s", timestamp, result)
}
// PrefixDecorator añade un prefijo personalizado
type PrefixDecorator struct {
processor MessageProcessor
prefix string
}
func (pd PrefixDecorator) Process(message string) string {
result := pd.processor.Process(message)
return fmt.Sprintf("%s: %s", pd.prefix, result)
}
func main() {
// Construir una cadena de decoradores
base := BaseProcessor{}
// Añadir decorador de timestamp
withTimestamp := TimestampDecorator{processor: base}
// Añadir decorador de mayúsculas
withUppercase := UppercaseDecorator{processor: withTimestamp}
// Añadir decorador de prefijo
withPrefix := PrefixDecorator{
processor: withUppercase,
prefix: "LOG",
}
message := "User authentication successful to blog.marcnuri.com"
result := withPrefix.Process(message)
fmt.Println(result)
// Demostrar diferentes combinaciones
simpleChain := UppercaseDecorator{processor: base}
fmt.Println(simpleChain.Process("Simple message"))
}
Este patrón funciona muy bien en sistemas de middleware, frameworks de logging y en cualquier lugar donde necesites añadir funcionalidades transversales sin modificar la lógica de negocio central.
La salida del ejemplo anterior se verá así:
LOG: [2015-10-21 04:29:00] USER AUTHENTICATION SUCCESSFUL TO BLOG.MARCNURI.COM
SIMPLE MESSAGE
Puedes ver cómo los decoradores pueden apilarse para crear pipelines de procesamiento complejos manteniendo cada componente enfocado y reutilizable.
3. El Patrón Observer
El patrón observer define una dependencia uno-a-muchos entre objetos, permitiendo que múltiples observadores sean notificados de cambios de estado:
package main
import (
"fmt"
"sync"
"time"
)
// Observer define la interface para objetos que deberían ser notificados
type Observer interface {
Update(event Event)
}
// Event representa una notificación con datos
type Event struct {
Type string
Data interface{}
}
// Subject gestiona observadores y notificaciones
type Subject interface {
Subscribe(observer Observer)
Unsubscribe(observer Observer)
Notify(event Event)
}
// EventBus implementa un subject thread-safe
type EventBus struct {
observers []Observer
mutex sync.RWMutex
}
func (eb *EventBus) Subscribe(observer Observer) {
eb.mutex.Lock()
defer eb.mutex.Unlock()
eb.observers = append(eb.observers, observer)
}
func (eb *EventBus) Unsubscribe(observer Observer) {
eb.mutex.Lock()
defer eb.mutex.Unlock()
for i, obs := range eb.observers {
if obs == observer {
eb.observers = append(eb.observers[:i], eb.observers[i+1:]...)
break
}
}
}
func (eb *EventBus) Notify(event Event) {
eb.mutex.RLock()
defer eb.mutex.RUnlock()
for _, observer := range eb.observers {
go observer.Update(event) // Notificación asíncrona
}
}
// Observadores concretos
type EmailNotifier struct {
email string
}
func (en EmailNotifier) Update(event Event) {
fmt.Printf("Email to %s: %s event - %v\n", en.email, event.Type, event.Data)
}
type SMSNotifier struct {
phone string
}
func (sn SMSNotifier) Update(event Event) {
fmt.Printf("SMS to %s: %s event - %v\n", sn.phone, event.Type, event.Data)
}
type LogNotifier struct{}
func (ln LogNotifier) Update(event Event) {
fmt.Printf("LOG: %s event occurred with data: %v\n", event.Type, event.Data)
}
// UserService demuestra el patrón en acción
type UserService struct {
eventBus Subject
}
func (us *UserService) CreateUser(username string) {
// Simular creación de usuario
fmt.Printf("Creating user: %s\n", username)
// Notificar observadores
us.eventBus.Notify(Event{
Type: "USER_CREATED",
Data: map[string]string{"username": username},
})
}
func main() {
eventBus := &EventBus{}
userService := &UserService{eventBus: eventBus}
// Suscribir diferentes tipos de observadores
emailNotifier := EmailNotifier{email: "admin@example.com"}
smsNotifier := SMSNotifier{phone: "+1234567890"}
logNotifier := LogNotifier{}
eventBus.Subscribe(emailNotifier)
eventBus.Subscribe(smsNotifier)
eventBus.Subscribe(logNotifier)
// Disparar un evento
userService.CreateUser("aitana")
// Dar tiempo a las notificaciones asíncronas para completarse
time.Sleep(100 * time.Millisecond)
}
Este patrón es esencial para arquitecturas event-driven, notificaciones en tiempo real y componentes de sistema desacoplados.
Patrones Avanzados de Composición de Interfaces
Las capacidades de embebido y composición de interfaces de Go permiten patrones de diseño sofisticados que promueven la reutilización de código y la mantenibilidad.
Segregación y Composición de Interfaces
El Principio de Segregación de Interfaces sugiere que los clientes no deberían ser forzados a depender de interfaces que no usan. Go hace esto natural a través de la composición de interfaces:
package main
import "fmt"
// Interfaces pequeñas y enfocadas
type Printer interface {
Print(doc string)
}
type Scanner interface {
Scan() string
}
// Interfaces compuestas para casos de uso específicos
type MultiFunctionDevice interface {
Printer
Scanner
}
// Tipos concretos
type SimplePrinter struct{}
func (p SimplePrinter) Print(doc string) {
fmt.Println("Printing:", doc)
}
type MultiFunctionMachine struct{}
func (m MultiFunctionMachine) Print(doc string) {
fmt.Println("Printing:", doc)
}
func (m MultiFunctionMachine) Scan() string {
return "Scanned document"
}
func main() {
var printer Printer = SimplePrinter{}
printer.Print("Hello, ISP!")
var mfm MultiFunctionDevice = MultiFunctionMachine{}
mfm.Print("Report.pdf")
fmt.Println(mfm.Scan())
}
En este ejemplo, Printer
y Scanner
son interfaces pequeñas y enfocadas.
La interfaz MultiFunctionDevice
compone ambas, permitiendo a los clientes depender solo de la funcionalidad que necesitan.
El tipo SimplePrinter
solo implementa la interfaz Printer
, mientras que MultiFunctionMachine
implementa ambas.
En la función main
, demostramos cómo diferentes clientes pueden usar estas interfaces sin ser forzados a depender de métodos innecesarios.
Este enfoque crea APIs flexibles donde los clientes dependen solo de la funcionalidad que realmente necesitan, haciendo el código más mantenible y testeable.
El Patrón Adapter con Interfaces
El patrón adapter permite que interfaces incompatibles trabajen juntas. Las interfaces de Go hacen este patrón particularmente limpio:
package main
import (
"fmt"
"log"
"os"
"strings"
)
// Interface moderna que nuestra aplicación espera
type Logger interface {
Info(message string)
Error(message string)
Debug(message string)
}
// Sistema de logging legacy que necesitamos integrar
type LegacyLogger struct {
prefix string
}
func (ll *LegacyLogger) LogMessage(level, message string) {
fmt.Printf("[%s] %s: %s\n", ll.prefix, strings.ToUpper(level), message)
}
// Adapter para hacer que LegacyLogger implemente Logger
type LegacyLoggerAdapter struct {
legacy *LegacyLogger
}
func (lla *LegacyLoggerAdapter) Info(message string) {
lla.legacy.LogMessage("info", message)
}
func (lla *LegacyLoggerAdapter) Error(message string) {
lla.legacy.LogMessage("error", message)
}
func (lla *LegacyLoggerAdapter) Debug(message string) {
lla.legacy.LogMessage("debug", message)
}
// Librería de terceros con interface diferente
type ThirdPartyLogger struct{}
func (tpl *ThirdPartyLogger) WriteLog(severity int, msg string) {
severityMap := map[int]string{
1: "DEBUG",
2: "INFO",
3: "ERROR",
}
fmt.Printf("[THIRD-PARTY] %s: %s\n", severityMap[severity], msg)
}
// Adapter para logger de terceros
type ThirdPartyLoggerAdapter struct {
thirdParty *ThirdPartyLogger
}
func (tpla *ThirdPartyLoggerAdapter) Info(message string) {
tpla.thirdParty.WriteLog(2, message)
}
func (tpla *ThirdPartyLoggerAdapter) Error(message string) {
tpla.thirdParty.WriteLog(3, message)
}
func (tpla *ThirdPartyLoggerAdapter) Debug(message string) {
tpla.thirdParty.WriteLog(1, message)
}
// Adapter de la librería estándar
type StdLoggerAdapter struct {
logger *log.Logger
}
func (sla *StdLoggerAdapter) Info(message string) {
sla.logger.Printf("INFO: %s", message)
}
func (sla *StdLoggerAdapter) Error(message string) {
sla.logger.Printf("ERROR: %s", message)
}
func (sla *StdLoggerAdapter) Debug(message string) {
sla.logger.Printf("DEBUG: %s", message)
}
// Servicio de aplicación que usa la interface Logger
type UserService struct {
logger Logger
}
func (us *UserService) CreateUser(username string) error {
us.logger.Info(fmt.Sprintf("Creating user: %s", username))
// Simular algo de procesamiento
if username == "" {
us.logger.Error("Username cannot be empty")
return fmt.Errorf("invalid username")
}
us.logger.Debug(fmt.Sprintf("User %s created successfully", username))
return nil
}
func main() {
// Usar diferentes implementaciones de logger a través de adapters
loggers := map[string]Logger{
"Legacy": &LegacyLoggerAdapter{
legacy: &LegacyLogger{prefix: "LEGACY"},
},
"ThirdParty": &ThirdPartyLoggerAdapter{
thirdParty: &ThirdPartyLogger{},
},
"Standard": &StdLoggerAdapter{
logger: log.New(os.Stdout, "STD ", log.LstdFlags),
},
}
for name, logger := range loggers {
fmt.Printf("\n--- Using %s Logger ---\n", name)
service := &UserService{logger: logger}
service.CreateUser("julia")
service.CreateUser("") // Esto causará un error
}
}
En este ejemplo, tenemos una interfaz Logger
que nuestra aplicación espera.
Luego creamos adapters para un sistema de logging legacy, un logger de terceros y el logger de la librería estándar para conformar con esta interfaz.
Cada adapter traduce las llamadas de nuestra aplicación a los métodos apropiados del sistema de logging subyacente.
Como puedes ver, el patrón adapter es particularmente valioso cuando se integra con librerías externas, sistemas legacy o cuando necesitas estandarizar interfaces a través de diferentes implementaciones.
Estrategias de Testing y Mocking con Interfaces
El testing es donde las interfaces de Go realmente brillan. Permiten mocking fácil e inyección de dependencias, haciendo las pruebas unitarias aisladas y confiables.
Aquí hay un ejemplo completo de cómo estructurar tu código de producción para testabilidad usando interfaces:
package main
import (
"fmt"
"time"
)
// Dependencias externas como interfaces
type TimeProvider interface {
Now() time.Time
}
type EmailSender interface {
SendEmail(to, subject, body string) error
}
type UserStore interface {
SaveUser(user *User) error
GetUser(email string) (*User, error)
}
// Modelo de dominio
type User struct {
Email string
Password string
IsVerified bool
CreatedAt time.Time
VerifiedAt *time.Time
}
// Servicio con dependencias inyectadas como interfaces
type UserRegistrationService struct {
timeProvider TimeProvider
emailSender EmailSender
userStore UserStore
}
func NewUserRegistrationService(
timeProvider TimeProvider,
emailSender EmailSender,
userStore UserStore,
) *UserRegistrationService {
return &UserRegistrationService{
timeProvider: timeProvider,
emailSender: emailSender,
userStore: userStore,
}
}
func (s *UserRegistrationService) RegisterUser(email, password string) error {
// Verificar si el usuario ya existe
existing, _ := s.userStore.GetUser(email)
if existing != nil {
return fmt.Errorf("user already exists")
}
// Crear usuario
user := &User{
Email: email,
Password: password, // En código real, esto estaría hasheado
IsVerified: false,
CreatedAt: s.timeProvider.Now(),
}
// Guardar usuario
if err := s.userStore.SaveUser(user); err != nil {
return fmt.Errorf("failed to save user: %w", err)
}
// Enviar email de verificación
subject := "Verify your account"
body := fmt.Sprintf("Please verify your account by clicking the link...")
if err := s.emailSender.SendEmail(email, subject, body); err != nil {
return fmt.Errorf("failed to send verification email: %w", err)
}
return nil
}
func (s *UserRegistrationService) VerifyUser(email string) error {
user, err := s.userStore.GetUser(email)
if err != nil {
return fmt.Errorf("user not found: %w", err)
}
if user.IsVerified {
return fmt.Errorf("user already verified")
}
now := s.timeProvider.Now()
user.IsVerified = true
user.VerifiedAt = &now
return s.userStore.SaveUser(user)
}
// Implementaciones reales
type RealTimeProvider struct{}
func (r RealTimeProvider) Now() time.Time {
return time.Now()
}
type RealEmailSender struct{}
func (r RealEmailSender) SendEmail(to, subject, body string) error {
fmt.Printf("Sending email to %s: %s\n", to, subject)
return nil
}
type RealUserStore struct {
users map[string]*User
}
func NewRealUserStore() *RealUserStore {
return &RealUserStore{users: make(map[string]*User)}
}
func (r *RealUserStore) SaveUser(user *User) error {
r.users[user.Email] = user
return nil
}
func (r *RealUserStore) GetUser(email string) (*User, error) {
user, exists := r.users[email]
if !exists {
return nil, fmt.Errorf("user not found")
}
return user, nil
}
func main() {
// Ejemplo de uso con implementaciones reales
timeProvider := RealTimeProvider{}
emailSender := RealEmailSender{}
userStore := NewRealUserStore()
service := NewUserRegistrationService(timeProvider, emailSender, userStore)
err := service.RegisterUser("alice@example.com", "password123")
if err != nil {
fmt.Printf("Registration failed: %v\n", err)
return
}
fmt.Println("User registered successfully!")
err = service.VerifyUser("alice@example.com")
if err != nil {
fmt.Printf("Verification failed: %v\n", err)
return
}
fmt.Println("User verified successfully!")
}
Ahora veamos cómo escribir tests para este servicio usando implementaciones mock de las interfaces:
package main
import (
"fmt"
"testing"
"time"
)
// Mocks de test
type MockTimeProvider struct {
currentTime time.Time
}
func (m *MockTimeProvider) Now() time.Time {
return m.currentTime
}
type MockEmailSender struct {
sentEmails []EmailRecord
shouldFail bool
}
type EmailRecord struct {
To string
Subject string
Body string
}
func (m *MockEmailSender) SendEmail(to, subject, body string) error {
if m.shouldFail {
return fmt.Errorf("email sending failed")
}
m.sentEmails = append(m.sentEmails, EmailRecord{
To: to,
Subject: subject,
Body: body,
})
return nil
}
type MockUserStore struct {
users map[string]*User
shouldFail bool
}
func NewMockUserStore() *MockUserStore {
return &MockUserStore{users: make(map[string]*User)}
}
func (m *MockUserStore) SaveUser(user *User) error {
if m.shouldFail {
return fmt.Errorf("database error")
}
m.users[user.Email] = user
return nil
}
func (m *MockUserStore) GetUser(email string) (*User, error) {
if m.shouldFail {
return nil, fmt.Errorf("database error")
}
user, exists := m.users[email]
if !exists {
return nil, fmt.Errorf("user not found")
}
return user, nil
}
func TestUserRegistration(t *testing.T) {
// Configurar mocks
fixedTime := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)
timeProvider := &MockTimeProvider{currentTime: fixedTime}
emailSender := &MockEmailSender{}
userStore := NewMockUserStore()
service := NewUserRegistrationService(timeProvider, emailSender, userStore)
// Test de registro exitoso
err := service.RegisterUser("test@example.com", "password123")
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
// Verificar que el usuario fue guardado
savedUser, err := userStore.GetUser("test@example.com")
if err != nil {
t.Fatalf("User should be saved: %v", err)
}
if savedUser.Email != "test@example.com" {
t.Errorf("Expected email test@example.com, got %s", savedUser.Email)
}
if savedUser.IsVerified {
t.Error("User should not be verified initially")
}
if !savedUser.CreatedAt.Equal(fixedTime) {
t.Errorf("Expected created time %v, got %v", fixedTime, savedUser.CreatedAt)
}
// Verificar que el email fue enviado
if len(emailSender.sentEmails) != 1 {
t.Fatalf("Expected 1 email sent, got %d", len(emailSender.sentEmails))
}
sentEmail := emailSender.sentEmails[0]
if sentEmail.To != "test@example.com" {
t.Errorf("Expected email to test@example.com, got %s", sentEmail.To)
}
// Test de registro duplicado
err = service.RegisterUser("test@example.com", "password123")
if err == nil {
t.Error("Expected error for duplicate registration")
}
}
func TestUserVerification(t *testing.T) {
// Configuración
fixedTime := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)
timeProvider := &MockTimeProvider{currentTime: fixedTime}
emailSender := &MockEmailSender{}
userStore := NewMockUserStore()
service := NewUserRegistrationService(timeProvider, emailSender, userStore)
// Registrar usuario primero
service.RegisterUser("test@example.com", "password123")
// Verificar usuario
verifyTime := fixedTime.Add(time.Hour)
timeProvider.currentTime = verifyTime
err := service.VerifyUser("test@example.com")
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
// Verificar la verificación
user, _ := userStore.GetUser("test@example.com")
if !user.IsVerified {
t.Error("User should be verified")
}
if user.VerifiedAt == nil || !user.VerifiedAt.Equal(verifyTime) {
t.Errorf("Expected verified time %v, got %v", verifyTime, user.VerifiedAt)
}
// Test de doble verificación
err = service.VerifyUser("test@example.com")
if err == nil {
t.Error("Expected error for double verification")
}
}
Este enfoque proporciona varios beneficios:
- Testing Aislado: Cada test se enfoca en la lógica del servicio sin dependencias externas.
- Entorno Controlado: Las implementaciones mock permiten simular varios escenarios.
- Comportamiento Predecible: Tiempo fijo y respuestas controladas hacen los tests determinísticos.
- Mantenimiento Fácil: Los cambios en servicios externos no rompen tus tests.
Buenas Prácticas y Anti-Patrones de Interfaces
Examinemos los qué hacer y qué no hacer del diseño de interfaces en Go.
Buenas Prácticas
1. Mantén las Interfaces Pequeñas y Enfocadas
Sigue el principio de segregación de interfaces:
// ✅ Bueno: Interfaces pequeñas y enfocadas
type Reader interface {
Read([]byte) (int, error)
}
type Writer interface {
Write([]byte) (int, error)
}
// Componer cuando sea necesario
type ReadWriter interface {
Reader
Writer
}
// ❌ Malo: Interfaz grande y monolítica
type BadFileManager interface {
Read([]byte) (int, error)
Write([]byte) (int, error)
Seek(int64, int) (int64, error)
Close() error
Sync() error
Truncate(int64) error
Stat() (FileInfo, error)
Chmod(FileMode) error
// ... muchos más métodos
}
2. Define las Interfaces Donde se Usan
Coloca interfaces en el paquete consumidor, no en el paquete implementador:
package user_processor
// En el paquete consumidor
type UserValidator interface {
ValidateUser(user *User) error
}
func ProcessUser(validator UserValidator, user *User) error {
if err := validator.ValidateUser(user); err != nil {
return err
}
// Procesar usuario...
return nil
}
3. Usa Nombres Descriptivos de Interfaces
Los nombres de interfaces deberían transmitir claramente su propósito:
// ✅ Bueno: Nombres claros y descriptivos
type EmailSender interface {
SendEmail(to, subject, body string) error
}
type PriceCalculator interface {
CalculatePrice(item *Item) (float64, error)
}
// ❌ Malo: Nombres genéricos o poco claros
type Manager interface {
Do(interface{}) interface{}
}
Anti-Patrones Comunes a Evitar
Ahora veamos algunos errores comunes en el diseño de interfaces.
1. Contaminación de Interfaces
No crees interfaces para todo:
// ❌ Malo: Interfaz innecesaria para una sola implementación
type UserService interface {
CreateUser(name string) *User
}
type UserServiceImpl struct{}
func (us *UserServiceImpl) CreateUser(name string) *User {
return &User{Name: name}
}
// ✅ Bueno: Usar tipo concreto directamente cuando solo hay una implementación
type UserService struct{}
func (us *UserService) CreateUser(name string) *User {
return &User{Name: name}
}
Tener que añadir "Impl" al nombre del struct es una señal clara de que la interfaz es innecesaria. Si solo tienes una implementación y no hay planes inmediatos para otras, usa el tipo concreto directamente.
2. Interfaces Gordas
Evita interfaces con demasiados métodos:
// ❌ Malo: Interfaz intentando hacer demasiado
type SuperService interface {
CreateUser(name string) error
DeleteUser(id int) error
SendEmail(to, subject, body string) error
LogMessage(level, message string)
CacheData(key string, value interface{})
ValidateInput(input string) bool
// ... más métodos
}
// ✅ Bueno: Separar preocupaciones en interfaces enfocadas
type UserManager interface {
CreateUser(name string) error
DeleteUser(id int) error
}
type EmailSender interface {
SendEmail(to, subject, body string) error
}
type Logger interface {
LogMessage(level, message string)
}
3. Abstracción Prematura de Interfaces
No crees interfaces hasta que realmente necesites la abstracción:
// ❌ Malo: Crear interface antes de que sea necesaria
type DatabaseConnection interface {
Query(sql string) ([]Row, error)
}
type MySQLConnection struct {}
func (mc *MySQLConnection) Query(sql string) ([]Row, error) {
// Implementación
}
// Si solo tienes una implementación y no hay planes inmediatos para otras,
// usa el tipo concreto directamente
// ✅ Bueno: Empezar con implementación concreta
type MySQLConnection struct {}
func (mc *MySQLConnection) Query(sql string) ([]Row, error) {
// Implementación
}
// Añadir interface más tarde cuando necesites soportar PostgreSQL, etc.
Patrones Modernos de Interfaces con Generics
Go 1.18+ introdujo generics, que funcionan muy bien con interfaces para diseños flexibles y type-safe:
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// Interfaz genérica con parámetros de tipo
type Store[T any] interface {
Save(item T) error
Get(id string) (T, error)
}
// Interfaz genérica con restricciones
type Calculator[T constraints.Ordered] interface {
Add(a, b T) T
}
// Implementación simple
type User struct {
ID string
Name string
}
type MemoryStore[T any] struct {
data map[string]T
}
func (m *MemoryStore[T]) Save(item T) error {
if m.data == nil {
m.data = make(map[string]T)
}
m.data["key"] = item
return nil
}
func (m *MemoryStore[T]) Get(id string) (T, error) {
item, exists := m.data[id]
if !exists {
var zero T
return zero, fmt.Errorf("not found")
}
return item, nil
}
// Calculadora numérica con restricciones
type NumberCalc[T constraints.Ordered] struct{}
func (nc NumberCalc[T]) Add(a, b T) T {
return a + b
}
func main() {
// Store genérico con tipo User
userStore := &MemoryStore[User]{}
user := User{ID: "1", Name: "Aitana"}
userStore.Save(user)
fmt.Println("Generic store working!")
// Calculadora genérica con tipo int
calc := NumberCalc[int]{}
sum := calc.Add(10, 20)
fmt.Printf("10 + 20 = %d\n", sum)
}
Este enfoque moderno combina la flexibilidad de las interfaces con la seguridad de tipos de los generics, proporcionando lo mejor de ambos mundos.
Si conocer más sobre generics en Go, echa un vistazo a mi guía completa: Generics en Go: Introducción Completa a Type Parameters en Go 1.18+.
Conclusión
Las interfaces de Go representan un cambio de paradigma de la programación orientada a objetos tradicional, ofreciendo un enfoque más flexible y mantenible al diseño de software. Aprovechando la satisfacción implícita de interfaces, composición sobre herencia y diseño de interfaces enfocado, puedes construir sistemas que son al mismo tiempo potentes y fáciles de entender.
Los principios clave para recordar son:
- Empezar Sencillo: Comenzar con tipos concretos e introducir interfaces cuando necesites la abstracción.
- Mantener las Interfaces Pequeñas: Seguir el principio de responsabilidad única a nivel de interfaz.
- Definir Interfaces Donde se Usan: Colocar interfaces en paquetes consumidores para promover bajo acoplamiento.
- Aprovechar la Composición: Usar embebido de interfaces para construir comportamientos complejos a partir de componentes simples.
- Testing con Interfaces: Aprovechar las interfaces para inyección de dependencias y mocking fácil en tests.
Ya estés construyendo microservicios, herramientas CLI o sistemas distribuidos complejos, dominar los patrones de interfaces de Go te hará un desarrollador Go más efectivo y te ayudará a escribir código que resista la prueba del tiempo.