A lo largo de la carrera profesional como desarrollador de software, a veces llega un momento con el que estoy seguro que muchos se sienten identificados.
Cuando estás empezando tus primeros programas, lo normal es que hagas las cosas con el objetivo de que funcionen y no tengan muchos bugs. Es un trabajo difícil, estás aprendiendo, y lo normal es adquirir experiencia e ir haciendo las cosas cada vez mejor, si pones un mínimo de interés.
Como decía antes, hay un punto, una especie de revelación que suele suponer un salto cualitativo como profesional: cuando ya no te vale con que las cosas funcionen, sino que quieres hacerlas bien. Porque ya has vivido lo que es el código legado y las implicaciones que tiene, y porque vas cruzando tu camino con compañeros que saben más que tú y te nutres de su experiencia y sabiduría.
Tras esa catársis, te preocupas más por las buenas prácticas. Te interesa el código limpio, hacer tests automáticos, los principios SOLID, los patrones de diseño, etc.
Pero como te apasiona tu profesión, es muy posible que quieras progresar demasiado rápido, y acabes excediéndote en el empleo de tus nuevos conocimientos. Por ejemplo, empezarán a aparecer interfaces por todas partes aunque no sean necesarias. Querrás invertir dependencias que ni existen. Harás tests automáticos sobre funcionalidades de librerías externas que no son de tu dominio. Implementarás patrones de diseño en código que no lo requiere. Aplicarás arquitecturas complejas que dan más problemas que soluciones. Etc.
Y así llegamos al punto al que yo quería, que es plantear mi punto de vista sobre los principios, patrones, metodologías, etc. Si has leido este blog alguna vez, sabrás que nos gustan las buenas prácticas, y creemos firmemente en hacer las cosas “bien” por encima de todo, no por modas sino porque nos funciona. Pero también creemos firmemente que los extremos son contraproducentes, y que seguir ciegamente cualquier principio no siempre es la solución, sino que hay que ser conscientes del contexto, y si aplicar ese principio te genera más problemas que inconvenientes, ser capaz de ponerlo en duda para aplicar una solución más adecuada a tu problema.
Nosotros hemos tenido esa sensación de que algo no cuadraba en algunas ocasiones, y voy a exponer un par de ejemplos de como los resolvimos. Por supuesto, no tiene que ser la única solución correcta, sino la que a nosotros como equipo nos pareció adecuada en función de las circunstancias.
Caso 1: Haciendo concesiones con el Command Query Separation
El principio CQS dice que una operación debe ser un command o una query, pero nunca ambas. Es decir, un método que modifica el estado del sistema (command) no debería además retornar un valor; y un método que retorna un valor (query) no debería modificar el estado del sistema y por tanto se debería poder ejecutar tantas veces como se quiera sin que nada se rompa al ser una simple consulta.
En este ejemplo, estábamos procesando millones de productos en un proceso diario, y además queríamos dejar traza en un log de aquellos que no tenían datos correctos. Inicialmente teníamos un código similar a este en el constructor del producto, y en caso de haber algún problema se recogía la excepción y se trazaba en el log:
public Product(string sku, decimal? price, decimal? shippingPrice)
{
if (string.IsNullOrWhiteSpace(sku))
throw new SkuNotValidException();
if (!price.HasValue)
throw new HasNotPriceException();
if (!shippingPrice.HasValue)
throw new HasNotShippingPriceException();
this.SKU = sku;
this.Price = price.Value;
this.ShippingPrice = shippingPrice.Value;
}
Pero ocurrió que nuestro set de productos estaba bastante sucio, y decenas de miles provocaban excepciones. Y el proceso se demoraba muchísmo tiempo ya que las excepciones en C# ralentizan la ejecución, de manera que una excepción asilada no se nota, pero miles de ellas se nota mucho. Por tanto, no podíamos usar excepciones, pero teníamos la necesidad de saber los detalles cuando al crear un producto fallaba.
Entonces, decidimos que la opción más simple era que el método que crea un producto, también nos diera información sobre su éxito, y los errores en caso de existir, retornando un genérico Result. Nos saltamos el CQS ya que nuestra operación modificaba el sistema y también retornaba un valor… pero el proceso pasó de tardar horas a completarse en minutos.
public static Result<Product> Create(string sku, decimal? price, decimal? shippingPrice)
{
List<ErrorType> errorTypes = new List<ErrorType>();
if (string.IsNullOrWhiteSpace(sku))
errorTypes.Add(new SkuNotValidErrorType());
if (!price.HasValue)
errorTypes.Add(new HasNotPriceErrorType());
if (!shippingPrice.HasValue)
errorTypes.Add(new HasNotShippingPriceErrorType());
if (errorTypes.Any())
return Result.Fail<Product>(errorTypes);
var product = new Product(sku,price.Value,shippingPrice.Value);
return Result.Ok(product);
}
Caso 2: Interpretando Clean Architecture
En la definición de Uncle Bob de Clean Architecture, cuando se cruza una capa se debe hacer mediante una estructura de datos simple. Pero por ejemplo, en varias aplicaciones de Android nos ha ocurrido que nuestras entidades eran muy sencillas, y que hacer un DTO y su consiguiente mapeo para presentarlas en pantalla, introducía más complejidad y posibilidades de error que simplemente enviar la entidad hasta la capa de presentación.
De esta manera, en esas aplicaciones los casos de uso devolvían directamente las entidades, pero sí que seguíamos respetando la que consideramos la regla más importante de esta arquitectura, que es la regla de la dependencia, por la que las capas interiores no deben tener dependencias de las capas exteriores, que son más volátiles.
Nos permitimos esta concesión, pero siempre teniendo en cuenta que en nuestro equipo éramos muy conscientes de ella, y que en ningún caso nadie del equipo caerá en la tentación de modificar la Entidad por una necesidad de Presentación.
Un interactor (simplificado) quedó así, de manera que la entidad Pet que nos devolvía el repositorio era directamente devuelta como mensaje al invocador del caso de uso.
class GetPetDetailsUseCase(
val petRepository: PetRepository,
val executor : Executor,
val mainThread : MainThread) : UseCase {
lateinit var callback : Callback
var petId : String = ""
interface Callback {
fun onDetailsLoaded(pet: Pet)
fun onError()
}
fun execute(petId : String, callback : Callback) {
this.petId = petId
this.callback = callback
this.executor.run(this)
}
override fun run() {
try {
val pet = petRepository.getById(this.petId)
notifyDetailsLoaded(pet)
} catch (e: Exception) {
notifyError()
}
}
private fun notifyDetailsLoaded(pet: Pet) {
mainThread.post(Runnable { callback.onDetailsLoaded(pet) })
}
private fun notifyError() {
mainThread.post(Runnable { callback.onError() })
}
}
Líneas rojas
Sin embargo, hay determinadas buenas prácticas que es muy difícil que traspasemos dado que nunca encontramos que hacerlo vaya a suponer una ventaja de ningún tipo. Especialmente si lo que se “gana” es tiempo, pues ya se sabe que a la larga la ausencia de esas buenas prácticas va a costar más que el “ahorro” inicial.
Por ejemplo, es muy difícil que no sigamos los principios SOLID, en especial la inversión de dependencias, o que no tengamos ningún tipo de testing automático. Queda a criterio de cada uno donde puede llegar a establecer esos límites.
Conclusión
Cada maestrillo tiene su librillo, y en este artículo comparto casos concretos que puede que en otras circunstancias se hubieran solucionado de otra manera. Sin embargo, lo que trasciende de esto es la idea de que ser inflexible con las buenas prácticas, principios y patrones, debe considerarse más una limitación que una ventaja. Poner en duda las cosas y ser flexible, amplía las opciones de éxito en tu trabajo siempre que se se argumente y las ventajas superen los inconvenientes.
Nota: Este texto fue publicado originalmente en el blog de Kirei Studio.