Table of contents
Hace un par de semanas un colega me hizo una pregunta retórica: ¿Será correcto que por cada problema que necesitamos resolver exista una interfaz que le corresponda una única implementación? Caí de ignorante y esta pregunta estuvo en mi mente un par de días hasta que decidí investigar el tema. Muchas veces caemos en esta práctica y la mayoría es por desconocimiento. Aquí un resumen de lo que aprendí.
TL;DR
El exceso de interfaces en un sistema no está justificado cuando:
- Una interfaz no resuelve un problema específico.
- Tiene sólo una implementación,
- Está acoplada a una tecnología o framework concreto,
- Opera en múltiples niveles de abstracción,
- Contiene partes que son irrelevantes o poco claras,
- No es una interfaz impulsada por el dominio (Domain-Driven).
¿Qué es una interfaz?
Para nuestros propósitos, una interfaz es un tipo de elemento que posee un lenguaje para especificar el comportamiento de objetos que la implementan, típico de lenguajes fuertemente tipados como Java o Typescript.
El simple uso de interfaces no garantiza el buen diseño de software. Si se abusa de ellas, pueden empeorar las cosas.
Malos entendidos de las interfaces
Como mencionamos anteriormente, el simple uso de interfaces no asegura un buen diseño de software, aunque patrones como el Strategy o el Principio de segregación de interfaces alientan su uso, el abuso siempre es malo.
Las interfaces (no) son contratos
Un acuerdo entre dos partes, el proveedor y el consumidor, inquebrantable y que define la forma en que se entregará una funcionalidad. De esta manera podemos definir un contrato. Por lo general, el comportamiento de un componente se describe de manera declarativa. El nivel de abstracción es importante para estos casos.
interface VideoRecommender {
Video nextVideo( Profile profile );
Float randomVideoId( Seed seed, List<Likes> likes );
}
En el ejemplo, el segundo método randomVideoId, se encuentra en un nivel inferior de abstracción y no debiera ser parte de la interfaz. La clase que implementa la interfaz es quien tendrá el método como miembro privado. El consumidor del contrato no está interesado en una funcionalidad con este nivel de abstracción, tampoco es buena idea filtrarla en la interfaz. Resumiendo, esta interfaz no es un buen contrato.
Las interfaces (no) son abstracciones
Una abstracción es una separación entre el "qué" y el "cómo", entre la solución de su implementación, la razón de la descomposición. Eric Elliott twitteó:
La esencia del desarrollo (de software) es la composición. La esencia del diseño (de software) es la descomposición del problema.
Por lo general, una interfaz se crea para ser implementada, pero no podemos dar por hecho que es una abstracción sólo por ser una interfaz. Analizamos el siguiente código:
import cl.codigo.algunframework.orm.Entity;
import cl.codigo.algunframework.orm.EntityArray;
interface ItemRepository {
EntityArray<Item> findAll();
Entity<Item> findOne( Long id );
}
¿Podríamos decir que el código anterior es una buena abstracción? Veamos:
El tipo de retorno de los métodos es
EntityArray
yEntity
, que si miramos bien, pertenecen a un framework específico, un ORM.Además, el tipo de retorno escrito de esta forma, expone los detalles de la implementación (Lo que llamaremos Interfaces con fugas).
El otro problema es el tipo
Long
, que por lo general es usado para los Id de los registros en base de datos, estos no deberían filtrarse a la interfaz.
Si sólo consideramos el lenguaje Java, no hay nada en él que nos impida usar de mala forma las interfaces. Pero en resumen, una interfaz no es inmediatamente una buena abstracción.
Las interfaces son reutilizables
El principio DRY (del que ya escribimos antes), nos habla de una planificación previa para crear trozos de lógica que podamos reutilizar. Cuando nos encontramos escribiendo el mismo código varias veces, por lo general tendemos a cortarlo y colocarlo en un módulo separado. Luego simplemente hacemos referencia a ese módulo.
Una motivación para crear reutilizaciones incorrectas es la especulación sobre escenarios futuros. El principio YAGNI (You aren't gonna need it) es un mantra en la mayoría de equipos ágiles, nos indica principalmente que si una característica de tu software presumiblemente será necesaria en el futuro, no la construyas hoy porque no la necesitará.
El abuso de reutilización de código en situaciones que no lo ameritan, conduce a menudo, a abstracciones incorrectas.
Considera el siguiente código:
interface Item {
String code;
Integer stock;
}
class ItemImpl implements Item {
private String code;
private Integer stock;
ItemImpl( String code, Integer stock ){
this.code = code;
this.stock = stock;
}
public String code(){ return code; }
public Integer stock(){ return stock; }
}
En este caso ¿la interfaz hizo el código más reutilizable? ¿cuál es el beneficio de hacer esto?
Las interfaces (no) están ligeramente acopladas
El nivel de acoplamiento está definido por el número de dependencias. Agregar una interfaz (un nivel adicional de indirección) no hace que el código en sí esté más desacoplado. Aquí un ejemplo:
class AgreementService {
private final AgreementMapper agreementMapper;
private final AgreementsClient agreementsClient;
public List<EibsAgreementDto> getAgreement() {
return eibsAgreementsClient
.getAgreements(appId, appKey)
.stream()
.map(agreementMapper::agreementModelToAgreementDto)
.collect(Collectors.toList());
}
}
class AgreementController {
private final AgreementService agreementService; // clase
public ResponseEntity<List<AgreementDto>> getAgreements() {
return ResponseEntity.ok(agreementService.getAgreement());
}
}
interface AgreementService {
List<AgreementDto> getAgreement();
}
class AgreementServiceImpl implements AgreementService {
private final AgreementMapper agreementMapper;
private final AgreementsClient agreementsClient;
@Override
public List<AgreementDto> getAgreement(){
return agreementsClient
.getAgreements(appId, appKey)
.stream()
.map(agreementMapper::agreementModelToAgreementDto)
.collect(Collectors.toList());
}
}
class AgreementController {
private final AgreementService agreementService; // interface
public ResponseEntity<List<AgreementDto>> getAgreements() {
return ResponseEntity.ok(agreementService.getAgreement());
}
}
En el primer bloque de código, la clase AgreementController
tiene una dependencia a la clase concreta AgreementService
, mientras que el segundo bloque de código una nueva capa de indirección fue agregada, una interfaz.
¿Se desacopló realmente el código? La clase AgreementController
sigue teniendo una sola dependencia, nada cambió en ella, pero ahora hay una nueva dependencia entre la interfaz AgreementService
y su implementación AgreementServiceImpl
. Sólo hemos empeorado las cosas, hemos aumentado la complejidad y hecho que el código sea más difícil de entender.
Sin considerar aún que la interfaz AgreementService no tendrá jamás otra implementación.
¿Cómo hago buenas abstracciones?
La esencia de nuestro trabajo es responder a problemas. El desarrollo de software es una solución de un problema.
Una buena abstracción es una solución a un problema, reduce la complejidad y proporciona simplicidad. Una interfaz con una sola implementación suele ser una abstracción de mala calidad e incorrecta, debe eliminarse.
Una abstracción innecesaria sólo aumenta la complejidad de manera accidental del sistema. No nos brinda simplicidad. ¿Te ha pasado? Miras tu software y te das cuenta de pronto que tienes cientos de interfaces con una única implementación y te preguntas ¿algún día esta interfaz tendrá otra?
Insisto, una buena abstracción es simple y clara. Todo lo complicado, los detalles, están ocultos.
Un poco de ayuda: Domain-Driven Design
El diseño orientado al dominio es un buen inicio para generar buenas abstracciones estables en el tiempo. Esta idea se sustenta bajo la premisa que el negocio cambia más lento que el software. Esta es una gran ventaja cuando a mantención del código se refiere.
Tomando los consejos anteriores, veamos el siguiente ejemplo:
class FindItem {
Item byName(String name) {
return ...
}
}
@RestController
class InventoryController {
private FindItem findItem;
@GetMapping
Item findItem(String name) {
return findItem.byName(name);
}
}
Aquí tenemos una clase FindItem
que no necesita ser una interfaz en este momento del desarrollo, ya que tiene solo una implementación actualmente, y es porque el negocio sólo posee una fuente de datos (principio YAGNI).
¿Qué pasa si el negocio ahora tiene dos fuentes de datos? Ahora será tiempo de reemplazar la clase FindItem con una interfaz, el código del cliente seguirá sin cambios, porque la abstracción está lo suficientemente desacoplada para permitirlo. La implementación está aislada.
interface FindItem {
Item byName(String name);
}
class FindItemJdbc implements FindItem {
private JdbcTemplate jdbcTemplate;
Item byName(String name) {
return jdbcTemplate.query(...);
}
}
class FindItemMongo implements FindItem {
private MongoCollection collection;
Item byName(String name) {
return collection.find(...);
}
}
@RestController
class InventoryController {
private FindItem findItem;
// NO NECESITÓ NINGÚN CAMBIO :D
@GetMapping
Item findItem(String name) {
return findItem.byName(name);
}
}
Conclusiones
No es necesario pensar en buenas abstracciones cuando estás aparecen solas por el diseño impulsado por el dominio (Domain-Driven Design). Además, las interfaces deben resolver un problema específico. Idealmente, una interfaz debe tener más de una implementación o responder a una abstracción que sostenga la necesidad de tener una interfaz.
Adicionalmente, el principio de inyección de dependencias es uno de los más importantes en el desarrollo de sistemas. Controla cuál objeto es usado en una implementación particular. Sin embargo, una buena práctica de inyección de dependencias no necesita de interfaces como tampoco lo necesitan las buenas abstracciones.
Finalmente podemos concluir que la necesidad de diseñar primero el software es una parte fundamental, para luego aplicar las buenas prácticas que necesitamos para generar sistemas sostenibles y escalables en el tiempo. Enfocándonos en las abstracciones por sobre las interfaces nos ayudará a enfocarnos más en el qué más que en el cómo.
Gracias por tu lectura.