Principio de responsabilidad única: una receta para un gran código

Publicado: 2022-03-11

Independientemente de lo que consideremos un gran código, siempre requiere una cualidad simple: el código debe poder mantenerse. La sangría adecuada, los nombres de variables limpios, la cobertura de prueba del 100%, etc., solo pueden llevarlo hasta cierto punto. Cualquier código que no se pueda mantener y que no pueda adaptarse a los requisitos cambiantes con relativa facilidad es un código que espera volverse obsoleto. Es posible que no necesitemos escribir un gran código cuando intentamos construir un prototipo, una prueba de concepto o un producto mínimo viable, pero en todos los demás casos siempre debemos escribir un código que se pueda mantener. Esto es algo que debe considerarse una cualidad fundamental de la ingeniería y el diseño de software.

Principio de responsabilidad única: una receta para un gran código

En este artículo, discutiré cómo el principio de responsabilidad única y algunas técnicas que giran en torno a él pueden darle a su código esta misma cualidad. Escribir un gran código es un arte, pero algunos principios siempre pueden ayudar a darle a su trabajo de desarrollo la dirección que necesita para producir software robusto y fácil de mantener.

El modelo lo es todo

Casi todos los libros sobre algún nuevo marco MVC (MVP, MVVM u otro M **) están llenos de ejemplos de código incorrecto. Estos ejemplos intentan mostrar lo que el marco tiene para ofrecer. Pero también acaban dando malos consejos a los principiantes. Ejemplos como "digamos que tenemos este ORM X para nuestros modelos, el motor de plantillas Y para nuestras vistas y tendremos controladores para administrarlo todo" no logran más que controladores gigantescos.

Aunque en defensa de estos libros, los ejemplos pretenden demostrar la facilidad con la que puede comenzar con su marco. No están destinados a enseñar diseño de software. Pero los lectores que siguen estos ejemplos se dan cuenta, solo después de años, de lo contraproducente que es tener fragmentos de código monolíticos en su proyecto.

Los modelos son el corazón de su aplicación.

Los modelos son el corazón de su aplicación. Si tiene modelos separados del resto de la lógica de su aplicación, el mantenimiento será mucho más fácil, independientemente de cuán complicada se vuelva su aplicación. Incluso para aplicaciones complicadas, una buena implementación del modelo puede dar como resultado un código extremadamente expresivo. Y para lograrlo, comience por asegurarse de que sus modelos hagan solo lo que deben hacer, y no se preocupen por lo que hace la aplicación creada a su alrededor. Además, no se preocupa por cuál es la capa de almacenamiento de datos subyacente: ¿su aplicación se basa en una base de datos SQL o almacena todo en archivos de texto?

A medida que continuamos con este artículo, te darás cuenta de lo bueno que es el código para la separación de preocupaciones.

Principio de responsabilidad única

Probablemente haya oído hablar de los principios SOLID: responsabilidad única, abierto-cerrado, sustitución de liskov, segregación de interfaz e inversión de dependencia. La primera letra, S, representa el Principio de responsabilidad única (SRP) y su importancia no se puede exagerar. Incluso diría que es una condición necesaria y suficiente para un buen código. De hecho, en cualquier código que esté mal escrito, siempre puede encontrar una clase que tiene más de una responsabilidad: form1.cs o index.php que contienen unas pocas miles de líneas de código no es algo tan raro de encontrar y todos nosotros probablemente lo haya visto o hecho.

Veamos un ejemplo en C# (ASP.NET MVC y Entity framework). Incluso si no es un desarrollador de C#, con algo de experiencia en programación orientada a objetos podrá seguirlo fácilmente.

 public class OrderController { ... public ActionResult CreateForm() { /* * View data preparations */ return View(); } [HttpPost] public ActionResult Create(OrderCreateRequest request) { if (!ModelState.IsValid) { /* * View data preparations */ return View(); } using (var context = new DataContext()) { var order = new Order(); // Create order from request context.Orders.Add(order); // Reserve ordered goods …(Huge logic here)... context.SaveChanges(); //Send email with order details for customer } return RedirectToAction("Index"); } ... (many more methods like Create here) }

Esta es una clase OrderController habitual, se muestra su método Create. En controladores como este, a menudo veo casos en los que la propia clase Order se usa como parámetro de solicitud. Pero prefiero usar clases de solicitud especial. De nuevo, SRP!

Demasiados trabajos para un solo controlador

Observe en el fragmento de código anterior cómo el controlador sabe demasiado sobre "hacer un pedido", que incluye, entre otros, almacenar el objeto Pedido, enviar correos electrónicos, etc. Eso es simplemente demasiados trabajos para una sola clase. Por cada pequeño cambio, el desarrollador necesita cambiar todo el código del controlador. Y en caso de que otro Controlador también necesite crear órdenes, la mayoría de las veces, los desarrolladores recurrirán a copiar y pegar el código. Los controladores solo deben controlar el proceso general y no albergar realmente toda la lógica del proceso.

¡Pero hoy es el día en que dejaremos de escribir estos gigantescos controladores!

Primero extraigamos toda la lógica empresarial del controlador y movámosla a una clase OrderService:

 public class OrderService { public void Create(OrderCreateRequest request) { // all actions for order creating here } } public class OrderController { public OrderController() { this.service = new OrderService(); } [HttpPost] public ActionResult Create(OrderCreateRequest request) { if (!ModelState.IsValid) { /* * View data preparations */ return View(); } this.service.Create(request); return RedirectToAction("Index"); }

Una vez hecho esto, el controlador ahora solo hace lo que debe hacer: controlar el proceso. Solo conoce las vistas, las clases OrderService y OrderRequest: el conjunto mínimo de información necesaria para realizar su trabajo, que es gestionar solicitudes y enviar respuestas.

De esta manera, rara vez cambiará el código del controlador. Otros componentes, como vistas, objetos de solicitud y servicios, aún pueden cambiar, ya que están vinculados a los requisitos comerciales, pero no a los controladores.

De esto se trata SRP, y existen muchas técnicas para escribir código que cumplan con este principio. Un ejemplo de esto es la inyección de dependencia (algo que también es útil para escribir código comprobable).

Inyección de dependencia

Es difícil imaginar un gran proyecto basado en el principio de responsabilidad única sin inyección de dependencia. Echemos un vistazo a nuestra clase OrderService nuevamente:

 public class OrderService { public void Create(...) { // Creating the order(and let's forget about reserving here, it's not important for following examples) // Sending an email to client with order details var smtp = new SMTP(); // Setting smtp.Host, UserName, Password and other parameters smtp.Send(); } }

Este código funciona, pero no es del todo ideal. Para comprender cómo funciona la clase OrderService del método de creación, se ven obligados a comprender las complejidades de SMTP. Y, nuevamente, copiar y pegar es la única forma de replicar este uso de SMTP donde sea necesario. Pero con un poco de refactorización, eso puede cambiar:

 public class OrderService { private SmtpMailer mailer; public OrderService() { this.mailer = new SmtpMailer(); } public void Create(...) { // Creating the order // Sending an email to client with order details this.mailer.Send(...); } } public class SmtpMailer { public void Send(string to, string subject, string body) { // SMTP stuff will be only here } }

¡Mucho mejor ya! Pero la clase OrderService todavía sabe mucho sobre el envío de correos electrónicos. Necesita exactamente la clase SmtpMailer para enviar correos electrónicos. ¿Y si queremos cambiarlo en el futuro? ¿Qué pasa si queremos imprimir el contenido del correo electrónico que se envía a un archivo de registro especial en lugar de enviarlo en nuestro entorno de desarrollo? ¿Qué sucede si queremos realizar una prueba unitaria de nuestra clase OrderService? Continuemos con la refactorización creando una interfaz IMailer:

 public interface IMailer { void Send(string to, string subject, string body); }

SmtpMailer implementará esta interfaz. Además, nuestra aplicación utilizará un contenedor IoC y podemos configurarlo para que la clase SmtpMailer implemente IMailer. OrderService se puede cambiar de la siguiente manera:

 public sealed class OrderService: IOrderService { private IOrderRepository repository; private IMailer mailer; public OrderService(IOrderRepository repository, IMailer mailer) { this.repository = repository; this.mailer = mailer; } public void Create(...) { var order = new Order(); // fill the Order entity using the full power of our Business Logic(discounts, promotions, etc.) this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); } }

¡Ahora estamos llegando a alguna parte! Aproveché esta oportunidad para hacer también otro cambio. OrderService ahora se basa en la interfaz IOrderRepository para interactuar con el componente que almacena todos nuestros pedidos. Ya no le importa cómo se implementa esa interfaz y qué tecnología de almacenamiento la impulsa. Ahora la clase OrderService solo tiene código que se ocupa de la lógica comercial de pedidos.

De esta manera, si un evaluador encuentra algo que se comporta incorrectamente con el envío de correos electrónicos, el desarrollador sabe exactamente dónde buscar: la clase SmtpMailer. Si algo estaba mal con los descuentos, el desarrollador, nuevamente, sabe dónde buscar: el código de clase OrderService (o en caso de que haya adoptado SRP de memoria, entonces puede ser DiscountService).

Arquitectura impulsada por eventos

Sin embargo, todavía no me gusta el método OrderService.Create:

 public void Create(...) { var order = new Order(); ... this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); }

Enviar un correo electrónico no forma parte del flujo principal de creación de pedidos. Incluso si la aplicación no puede enviar el correo electrónico, el pedido aún se crea correctamente. Además, imagine una situación en la que tenga que agregar una nueva opción en el área de configuración del usuario que les permita optar por no recibir un correo electrónico después de realizar un pedido con éxito. Para incorporar esto a nuestra clase OrderService, necesitaremos introducir una dependencia, IUserParametersService. Agregue la localización a la mezcla y tendrá otra dependencia, ITranslator (para producir mensajes de correo electrónico correctos en el idioma elegido por el usuario). Varias de estas acciones son innecesarias, especialmente la idea de agregar tantas dependencias y terminar con un constructor que no cabe en la pantalla. ¡Encontré un gran ejemplo de esto en el código base de Magento (un popular CMS de comercio electrónico escrito en PHP) en una clase que tiene 32 dependencias!

Un constructor que no cabe en la pantalla

A veces es difícil descubrir cómo separar esta lógica, y la clase de Magento probablemente sea víctima de uno de esos casos. Es por eso que me gusta la forma impulsada por eventos:

 namespace <base namespace>.Events { [Serializable] public class OrderCreated { private readonly Order order; public OrderCreated(Order order) { this.order = order; } public Order GetOrder() { return this.order; } } }

Cada vez que se crea un pedido, en lugar de enviar un correo electrónico directamente desde la clase OrderService, se crea la clase de evento especial OrderCreated y se genera un evento. En algún lugar de la aplicación, se configurarán los controladores de eventos. Uno de ellos enviará un correo electrónico al cliente.

 namespace <base namespace>.EventHandlers { public class OrderCreatedEmailSender : IEventHandler<OrderCreated> { public OrderCreatedEmailSender(IMailer, IUserParametersService, ITranslator) { // this class depend on all stuff which it need to send an email. } public void Handle(OrderCreated event) { this.mailer.Send(...); } } }

La clase OrderCreated está marcada como Serializable a propósito. Podemos manejar este evento de inmediato o almacenarlo serializado en una cola (Redis, ActiveMQ o algo más) y procesarlo en un proceso/hilo separado del que maneja las solicitudes web. En este artículo, el autor explica en detalle qué es la arquitectura basada en eventos (no preste atención a la lógica comercial dentro de OrderController).

Algunos pueden argumentar que ahora es difícil entender lo que sucede cuando se crea la orden. Pero eso no puede estar más lejos de la verdad. Si se siente así, simplemente aproveche la funcionalidad de su IDE. Al encontrar todos los usos de la clase OrderCreated en el IDE, podemos ver todas las acciones asociadas con el evento.

Pero, ¿cuándo debo usar la inyección de dependencia y cuándo debo usar un enfoque basado en eventos? No siempre es fácil responder a esta pregunta, pero una regla simple que puede ayudarlo es usar Inyección de dependencia para todas sus actividades principales dentro de la aplicación y un enfoque basado en eventos para todas las acciones secundarias. Por ejemplo, use Inyección de dependencia con cosas como la creación de un pedido dentro de la clase OrderService con IOrderRepository y delegue el envío de correo electrónico, algo que no es una parte crucial del flujo principal de creación de pedidos, a algún controlador de eventos.

Conclusión

Comenzamos con un controlador muy pesado, solo una clase, y terminamos con una elaborada colección de clases. Las ventajas de estos cambios son bastante evidentes a partir de los ejemplos. Sin embargo, todavía hay muchas maneras de mejorar estos ejemplos. Por ejemplo, el método OrderService.Create se puede mover a una clase propia: OrderCreator. Dado que la creación de pedidos es una unidad independiente de la lógica empresarial que sigue el principio de responsabilidad única, es natural que tenga su propia clase con su propio conjunto de dependencias. Del mismo modo, la eliminación y la cancelación de pedidos pueden implementarse en sus propias clases.

Cuando escribí código altamente acoplado, algo similar al primer ejemplo de este artículo, cualquier pequeño cambio en el requisito podría generar fácilmente muchos cambios en otras partes del código. SRP ayuda a los desarrolladores a escribir código desacoplado, donde cada clase tiene su propio trabajo. Si las especificaciones de este trabajo cambian, el desarrollador realiza cambios solo en esa clase específica. Es menos probable que el cambio rompa toda la aplicación, ya que otras clases deberían seguir haciendo su trabajo como antes, a menos, por supuesto, que se rompieran en primer lugar.

Desarrollar código por adelantado utilizando estas técnicas y siguiendo el principio de responsabilidad única puede parecer una tarea desalentadora, pero los esfuerzos sin duda valdrán la pena a medida que el proyecto crezca y el desarrollo continúe.