Si no lo puedes abstraer, hazlo explícito

Hace un par de días José Manuel Nieto planteaba en su blog una duda sobre cómo diseñar servicios distribuidos. Aunque ya lo estuvimos hablando en Twitter, creo que es un tema lo bastante interesante como escribir un post y dejarlo en algo menos efímero que un par de tweets.

La duda en cuestión es cómo podemos abstraer a un cliente de que está usando servicios remotos. José Manuel pone el ejemplo de una interfaz de usuario que depende de un servicio externo, pero realmente es aplicable a cualquier otro componente que deba consumir este tipo de servicios.

Interfaces al rescate

Generalmente, cuando se trata de abstraer comportamientos la primera idea que viene a nuestra mente es utilizar un interface. De esta forma, podemos utilizar técnicas de inyección de dependencias para aislar al cliente de la implementación concreta que estamos utilizamos.

Al introducir un interface hacemos que el cliente adquiera una dependencia sobre un contrato en lugar de sobre una implementación, por lo que podemos (en teoría) cambiar esa implementación sin que nuestro cliente se vea afectado.

Por ejemplo, si tenemos una clase que debe cifrar información, podemos hacer algo así:

public interface ICryptoService
{
  string Encrypt(string text);
}

public class EmailSender
{
  // Inyectado por constructor
  private readonly ICrypoService crypto;

  public void Send(string to, string message)
  {
    var encryptedMessage = crypto.Encrypt(message);
    // enviar el mensaje
  }
}

En este caso, podríamos cambiar la implementación de ICryptoService para que usase distintos algoritmos de cifrado y nuestro cliente no se vería afectado.

Los interfaces son abstracciones incompletas

Hace mucho tiempo, Joel Spolsky enunció lo que se conoce como Ley de las Leaky Abstractions (algo así como ley de las abstracciones que gotean o abstracciones con fugas) que dice lo siguiente:

All non-trivial abstractions, to some degree, are leaky.

Toda abstracción no trivial es, hasta cierto punto, incompleta.

Lo que quiere decir esta ley es que, excepto en los casos más triviales, toda abstracción acabará por exponer de una forma u otra aquello de lo que trata de abstraernos.

Cuando estamos tratando con sistemas distribuidos, los interfaces se convierten rápidamente en una leaky abstraction porque hay parte del comportamiento de la implementación que debemos tener en cuenta aunque no esté reflejado por ninguna parte en el interface.

Por ejemplo, supongamos que tenemos un interface para generar números aleatorios:

public interface IRandomNumberGenerator
{
  double Next();
}

A primera vista, un interface tan simple como éste no debería presentar problemas; expone un contrato muy claro y definido: genera números aleatorios al llamar al método Next. Sin embargo, distintas implementaciones de este interface pueden hacer que la forma de usarlo cambie completamente.

Si estamos implementando el interface a través de la clase System.Random, sabemos que la obtención del número aleatorio será muy rápida y seguramente no debemos esperar ningún tipo de error. Si en lugar de eso necesitamos más aleatoriedad y usamos un genérador de números aleatorios por hardware, tendremos que tener en cuenta que el tiempo en generar un número aleatorio será mayor y podemos tener problemas al acceder al hardware específico. Si eres de los que todavía no se han comprado un generador de números aleatorios por hardware puedes recurrir a algún servicio en internet, pero entonces tendrás que contar con posibles fallos de red, caídas del servicio, etc.

Este interface, tan aparentemente simple, puede no ser suficiente para describir un generador de números aleatorios, y las distintas características de cada implementación hacen que el código cliente del interface no pueda abstraerse realmente de la implementación y deba tenerla en cuenta a la hora de decidir cómo utilizar el interface (¿puedo bloquear el interfaz de usuario mientras se genera el número aleatorio? ¿debo hacerlo en una hebra de background?) y cómo gestionar posibles errores (¿no falla nunca? ¿tengo que contar con errores de hardware? ¿con errores de conectividad? ¿con disponibilidad del servicio?).

Es algo similar a lo que veíamos cuando hablábamos de la eficiencia de distintos tipos de colecciones en .NET y las variaciones de rendimiento que podíamos encontrar en colecciones que, aparentemente, sirven para lo mismo.

La localidad de un servicio no se puede abstraer; hazla explícita

El problema real no es que estemos utilizando un interfaz, el problema es que estamos tratando de homogeneizar cosas que son muy diferentes y un interface no es capaz de capturar esas diferencias.

Si un componente necesita acceder a un servicio externo, seguramente necesite conocer el tipo de errores que se pueden producir, las características de rendimiento, los límites de uso y otras características que no se aplicarían si se accediese a un servicio local al proceso que se está ejecutando.

En un escenario como ese, es mejor modelar de forma explícita que se trata de un servicio externo para que el cliente pueda actuar en consecuencia y tomar las precauciones necesarias, realizando reintentos, implementando un circuit breaker o lo que considere oportuno.

Incluso si nos olvidamos de la disponibilidad y asumimos que el servicio siempre va a estar disponible, es importante ser consciente de lo que se está realizando es una comunicación entre procesos y el rendimiento no va a ser, ni de lejos, equiparable a la ejecución de código en el sistema local.

Cuando el interfaz de una clase «oculta» esta falta de localidad, es fácil encontrar problemas de rendimiento por un uso incorrecto del servicio por parte del cliente. El típico problema del select N+1 que ocurre con los ORMs y el lazy load es un ejemplo de esto. A partir del API es imposible saber si se lanzará una consulta o no la base de datos (un sistema externo) y el consumidor del API puede usar el servicio (la entidad en este caso) de una forma muy poco eficiente.

¿Quiere decir esto que los interfaces son malos? No, claro que no. Lo malo es tratar de agrupar bajo un mismo interfaz componentes que, aunque exponen las mismas funcionalidades, no lo hacen con unas características homogéneas.

Por seguir con el ejemplo de los números aleatorios, podríamos reescribirlo de la siguiente forma:

public interface IHardwareRandomNumberGenerator
{
  double Next();
}

public interface IHttpRandomNumberGenerator
{
  double Next();
}

public interface IInMemoryRandomNumberGenerator
{
  double Next();
}

De esta forma, si tenemos varios servicios para generar números aleatorios basados en harware, podríamos tener distintas implementaciones de IHardwareRandomNumberGenerator y el cliente podría esperar un comportamiento similar de todas ellas (un tiempo determinado para generar el número aleatorio, posibilidad de error si el hardware no está inicializado, etc.). Lo mismo ocurriría con las otras alternativas que vimos antes.

Puede que en un lenguaje como Java, donde las excepciones son chequeadas y es obligatorio indicar en la declaración del método qué tipos de excepción puede lanzar, estos problemas se atenúen en parte, pero hay que cosas que siguen sin poder capturarse en el interface, como la duración estimada de la operación (aparte que lo de definir las excepciones acaba conviertiéndose en encapsular todas las excepciones en una común y tampoco soluciona gran cosa).

Conclusión

El cerebro del ser humano está preparado para buscar patrones y el cerebro del programador está educado para llevar esa búsqueda al extremo. Es fácil caer en la tentación de unificarlo todo dentro de un mismo diseño y con un mismo nivel de abstracción, pero hay cosas que, simplemente, son distintas.

No es lo mismo coger leche de la nevera, que ir a comprarla a un centro comercial, que ir a ordeñar una vaca, aunque en todos los casos la operación sea la misma «conseguir leche». Con el acceso a un servicio externo pasa lo mismo, aunque la operación sea la misma que con un servicio local, la forma de implementarla es tan diferente que no podemos darle el mismo tratamiento.

Cuando se diseñan componentes que tienen que acceder a sistemas externos, es preferible hacer explícita la externalidad para que el cliente pueda decidir cómo gestionarla en base a sus necesidades.