Guía práctica
La interfaz de una sola implementación que aumenta el acoplamiento
Una UserServiceInterface creada por costumbre obliga a consumidores distintos a depender del mismo contrato y muestra cuándo la interfaz debe nacer en el consumidor.
Un paquete users contiene un servicio y, delante de él, una interfaz:
package users
type UserServiceInterface interface {
Create(context.Context, CreateUser) (User, error)
Find(context.Context, UserID) (User, error)
Rename(context.Context, UserID, string) error
Delete(context.Context, UserID) error
List(context.Context) ([]User, error)
Export(context.Context, io.Writer) error
}
type UserService struct {
store Store
}
func NewUserService(store Store) UserServiceInterface {
return &UserService{store: store}
}Solo existe una implementación: UserService. La interfaz se añadió porque «los servicios deben tener interfaz» y porque quizá algún día exista otra implementación.
Sobre el papel parece desacoplamiento. En los consumidores ocurre lo contrario.
El paquete welcome solo necesita encontrar un usuario:
type Sender struct {
users users.UserServiceInterface
}El paquete admin necesita renombrarlo y eliminarlo. Un job necesita Export. Los tres dependen de la misma superficie aunque sus conversaciones sean distintas.
Cada método nuevo modifica el contrato compartido. Cada fake debe crecer. Cada consumidor puede empezar a usar operaciones que no pertenecían a su responsabilidad. La interfaz oculta el tipo concreto, pero hace visible una relación más grande de la necesaria.
Una interfaz no reduce dependencias por existir#
El acoplamiento no se mide contando si una firma contiene una interfaz o un struct. Se observa preguntando qué promesas conoce cada consumidor.
welcome depende conceptualmente de una sola conversación:
Find(UserID) → User | UserNotFoundPero UserServiceInterface le entrega seis. Aunque el código de welcome no llame a Delete, su constructor afirma que necesita un objeto capaz de borrar usuarios, exportarlos y listarlos.
La diferencia se vuelve visible al probar:
type fakeUsers struct{}
func (fakeUsers) Find(context.Context, users.UserID) (users.User, error) {
return users.User{Email: "reader@example.com"}, nil
}
// Para satisfacer UserServiceInterface también hay que implementar
// Create, Rename, Delete, List y Export.El fake no está incompleto respecto de welcome. Está incompleto respecto de una interfaz diseñada desde el proveedor.
Primera opción: usa el tipo concreto#
Si un consumidor no necesita sustituir el servicio y el paquete concreto expresa bien la conversación, no hace falta anticipar otra capa:
type Sender struct {
users *users.UserService
}
func NewSender(subject *users.UserService) *Sender {
return &Sender{users: subject}
}Esto no elimina la API. UserService.Find sigue siendo una API entre paquetes: tiene un mensaje, vocabulario, resultados y garantías. Una API no necesita adoptar la forma interface del lenguaje.
El tipo concreto deja ver una dependencia real en lugar de fingir una sustitución que ningún consumidor ha pedido.
Si los tests de Sender pueden usar un UserService pequeño con una store en memoria, esa composición prueba la conversación real sin crear un contrato adicional. Introducir una interfaz solo para obtener un mock no es automáticamente más modular.
Segunda opción: la interfaz pertenece al consumidor#
Supongamos ahora que welcome necesita controlar usuarios ausentes y encontrados sin construir el módulo completo. O que puede trabajar contra proveedores diferentes. Existe una necesidad concreta de sustitución.
La interfaz puede nacer donde esa necesidad existe:
package welcome
type Users interface {
Find(context.Context, users.UserID) (users.User, error)
}
type Sender struct {
users Users
}
func NewSender(subject Users) *Sender {
return &Sender{users: subject}
}*users.UserService satisface welcome.Users sin declararlo. El fake también:
type stubUsers struct {
user users.User
err error
}
func (s stubUsers) Find(
context.Context,
users.UserID,
) (users.User, error) {
return s.user, s.err
}Ahora la interfaz describe exactamente lo que Sender necesita. admin puede usar el tipo concreto o definir otra interfaz con Rename y Delete. Un cambio en Export no alcanza a ninguno de ellos.
No hemos dividido una interfaz grande por estética. Hemos reconocido que existen conversaciones diferentes.
El proveedor conserva su propia API#
Mover la interfaz al consumidor no significa que el consumidor sea dueño de User, UserID o de las reglas de búsqueda. Esos elementos siguen perteneciendo al vocabulario que ofrece users.
Hay dos límites relacionados:
usersdecide qué significa encontrar un usuario y qué garantizaUserService;welcomedecide qué parte de esa capacidad necesita para enviar bienvenidas.
API-DD se repite en ambos lados. Un módulo ofrece una API y, al consumir otra, puede declarar una necesidad más pequeña. Esta es la recursión aplicada a una relación real, no una interfaz añadida a cada paquete por simetría.
Cuándo una interfaz en el proveedor sí tiene sentido#
Una interfaz publicada por el proveedor puede ser correcta cuando la sustitución forma parte de la capacidad que ofrece. Por ejemplo, un paquete puede definir un Codec para que sus consumidores elijan entre implementaciones compatibles, o un contrato de plugin deliberadamente estable.
La diferencia está en quién necesita reconocer la familia de implementaciones.
Interfaz del proveedor:
el proveedor ofrece varias implementaciones como parte de su API.
Interfaz del consumidor:
el consumidor expresa la capacidad mínima que necesita recibir.Si nadie necesita elegir, sustituir o implementar el contrato, la interfaz puede ser una abstracción prematura.
El nombre también delata el problema#
UserServiceInterface repite dos categorías técnicas. No explica una capacidad que UserService no exprese ya. Nombres como Users, UserFinder o WelcomeRecipients solo mejoran el diseño cuando corresponden a una conversación real; renombrar la misma interfaz grande no la vuelve pequeña.
La prueba consiste en leer el constructor:
func NewSender(users Users) *Sender¿Puede una persona entender qué parte de users necesita Sender? ¿Podría sustituirla sin conocer operaciones que Sender jamás usa? Si no, el límite todavía está dibujado desde el proveedor.
El fundamento de recursión permite mirar cada módulo como proveedor y consumidor. El fundamento de visibilidad recuerda que cada método visible se convierte en una promesa adicional.
Una interfaz desacopla cuando representa la necesidad de un consumidor; creada por costumbre, solo añade otro nombre al mismo acoplamiento.