Introducción a la arquitectura de software

arquitectura solid

Introducción

Este post es recuperando una sección que utilizo en mi TFM donde explicaba la arquitectura de software y los principios SOLID. Espero que sea útil para alguien que quiera aprender sobre estos temas.

Arquitectura de Software

La arquitectura de software es la estructura principal y organizativa de un sistema, en la cual se definen sus componentes y como estos interactúan entre sí, a través de una serie de principios y patrones de diseño. Esto es esencial para garantizar tanto la calidad del software como su escalabilidad y mantenibilidad.

En este apartado se explorará una de las arquitecturas de software más relevante en la actualidad: la arquitectura hexagonal, y se discutirán los principios SOLID y Clean Code, así como algunos patrones de diseño aplicables. Todo ello, con el objetivo de proporcionar una visión general de por qué se han elegido estos enfoques para el desarrollo del proyecto.

Arquitectura Hexagonal

En su concepto más clásico la arquitectura hexagonal o también llamada arquitectura de puertos y adaptadores, es un patrón de diseño que busca separar el núcleo de la aplicación de las preocupaciones externas, como bases de datos, interfaces de usuario y servicios externos. Esto se logra a través de la definición de puertos (interfaces) y adaptadores (implementaciones concretas) que permiten la comunicación entre el núcleo y el exterior. Esta separación facilita tanto las pruebas unitarias como la evolución del sistema, ya que el núcleo puede cambiar sin afectar a las tecnologías externas y viceversa.

arquitectura_hexagonal

Para comprender mejor la arquitectura hexagonal, imaginemos el proceso de cargar un teléfono móvil como un sistema de software. El móvil representa el núcleo de nuestra aplicación, con su lógica de negocio interna (procesador, memoria, aplicaciones). El móvil “sabe” que necesita energía para funcionar, pero no sabe de dónde viene ni cómo se genera y tampoco es su responsabilidad. El puerto de carga del móvil actúa como la interfaz que define el “contrato” especificando cómo debe recibir la energía (voltaje, corriente, tipo de conector), es decir, “necesito 5V a través de un puerto USB-C”. Los adaptadores son las implementaciones concretas: el cargador de pared toma la corriente alterna de 220V y la convierte a los 5V de corriente continua que necesita el móvil, el power bank implementa la misma interfaz pero obtiene la energía de una batería interna, y el cargador de coche convierte los 12V del automóvil a los 5V necesarios.

Esta analogía ilustra los beneficios de la arquitectura hexagonal: intercambiabilidad (el móvil puede cargarse desde cualquier fuente sin cambiar su funcionamiento interno), testabilidad (podemos probar el móvil con un cargador de prueba sin necesidad de conectarlo a la red eléctrica real), evolución independiente (si cambia el estándar de los enchufes, solo necesitamos cambiar el cargador, no el móvil), y separación de responsabilidades (el móvil se enfoca en su lógica interna, el cargador en la conversión de energía, y cada fuente externa en generar electricidad). De esta manera, el núcleo de la aplicación permanece independiente de las tecnologías externas, facilitando el mantenimiento y la evolución del sistema.

Principios SOLID

Los principios SOLID son un conjunto de reglas y buenas prácticas a seguir al desarrollar la estructura de una clase. Es decir, son las reglas por las cuales se debe implementar la Programación Orientada a Objetos. En Clean Architecture se definen y explican cada uno de ellos:

  • S: Single Responsibility Principle (SRP) - Un módulo o clase debe tener una única razón para cambiar, es decir, debe tener una única responsabilidad.
  • O: Open/Closed Principle (OCP) - Los módulos deben estar abiertos a la extensión, pero cerrados a la modificación. Esto significa que se debe poder añadir nueva funcionalidad sin cambiar el código existente.
  • L: Liskov Substitution Principle (LSP) - Los objetos de una clase derivada deben poder sustituir a los objetos de la clase base sin alterar el correcto funcionamiento del programa.
  • I: Interface Segregation Principle (ISP) - Es mejor tener muchas interfaces específicas en lugar de una interfaz única y general. Los clientes no deben verse obligados a depender de interfaces que no utilizan.
  • D: Dependency Inversion Principle (DIP) - Las dependencias deben ser abstraídas. Los módulos de alto nivel no deben depender de módulos de bajo nivel, ambos deben depender de abstracciones.

A continuación se muestran ejemplos de cada principio con Python.

Single Responsibility Principle (SRP)

El Single Responsibility Principle (SRP) establece que un módulo o clase debe tener una única razón para cambiar, es decir, debe tener una única responsabilidad. Esto significa que cada clase debe enfocarse en una sola tarea o funcionalidad, lo que facilita su comprensión, mantenimiento y evolución.

A continuación se muestra un ejemplo sencillo en Python que ilustra el principio de responsabilidad única. Se definen dos clases: una para gestionar usuarios y otra para enviar notificaciones. Cada clase tiene una única responsabilidad.

class VoltageConverter:
    def to_volts(self, millivolts):
        # Converts millivolts to volts
        return millivolts / 1000

class CurrentConverter:
    def to_amperes(self, milliamperes):
        # Converts milliamperes to amperes
        return milliamperes / 1000

# Using the classes
voltage_converter = VoltageConverter()
current_converter = CurrentConverter()

mv = 5000  # millivolts
ma = 1500  # milliamperes

volts = voltage_converter.to_volts(mv)
amperes = current_converter.to_amperes(ma)

print(f"{mv} mV is {volts} V")
print(f"{ma} mA is {amperes} A")

En este ejemplo, VoltageConverter solo convierte unidades de voltaje y CurrentConverter solo convierte unidades de corriente, cumpliendo así el SRP.

Open/Closed Principle (OCP)

El Open/Closed Principle (OCP) establece que los módulos deben estar abiertos a la extensión, pero cerrados a la modificación. Esto significa que se debe poder añadir nueva funcionalidad sin cambiar el código existente. Para cumplir con este principio, se pueden utilizar técnicas como la herencia y la composición.

A continuación se muestra un ejemplo en Python que ilustra el OCP. Se define una clase base ElectricitySource y varias clases derivadas que extienden su comportamiento.

class ElectricitySource:
    def get_specifications(self):
        return {
            "tension": "220V",
            "frecuencia": "50Hz",
            "potencia": "1000W"
        }


class SolarPanel(ElectricitySource):
    def get_specifications(self):
        return {
            "tension": "24V",
            "frecuencia": "DC",
            "potencia": "300W"
        }


class WindTurbine(ElectricitySource):
    def get_specifications(self):
        return {
            "tension": "690V",
            "frecuencia": "50Hz",
            "potencia": "2000W"
        }


sources = [ElectricitySource(), SolarPanel(), WindTurbine()]
for source in sources:
    specs = source.get_specifications()
    print(
        f"""
        Fuente: {source.__class__.__name__} -> Tensión: {specs['tension']},
        Frecuencia: {specs['frecuencia']},
        Potencia: {specs['potencia']}
        """
    )

En este ejemplo, la clase base ElectricitySource define el método get_specifications. Las clases derivadas (SolarPanel y WindTurbine) extienden el comportamiento sin modificar la clase original, permitiendo agregar nuevas fuentes de electricidad cumpliendo el principio OCP.

Liskov Substitution Principle (LSP)

El Liskov Substitution Principle (LSP) establece que los objetos de una clase derivada deben poder sustituir a los objetos de la clase base sin alterar el correcto funcionamiento del programa. Esto significa que las clases derivadas deben ser completamente intercambiables con sus clases base.

A continuación se muestra un ejemplo en Python que ilustra el LSP. Se define una clase base ElectricitySource y una clase derivada que respeta el contrato de la clase base, permitiendo que ambas puedan ser utilizadas de manera intercambiable sin alterar el funcionamiento del programa.

class ElectricitySource:
    def supply(self):
        return "Suministrando electricidad estándar"

class SolarPanel(ElectricitySource):
    def supply(self):
        return "Suministrando electricidad desde panel solar"

def provide_power(source: ElectricitySource):
    print(source.supply())

sources = [ElectricitySource(), SolarPanel()]
for source in sources:
    provide_power(source)

En este ejemplo, tanto ElectricitySource como SolarPanel pueden ser utilizadas por la función provide_power sin causar errores ni modificar el comportamiento esperado, cumpliendo así el principio de sustitución de Liskov.

Interface Segregation Principle (ISP)

El Interface Segregation Principle (ISP) establece que los clientes no deben verse obligados a depender de interfaces que no utilizan. En otras palabras, es mejor tener varias interfaces específicas en lugar de una interfaz general. Esto permite que las clases implementen solo los métodos que realmente necesitan.

A continuación se muestra un ejemplo en Python que ilustra el ISP. Se definen interfaces específicas para diferentes tipos de fuentes de electricidad.

from abc import ABC, abstractmethod

class ElectricitySource(ABC):
    @abstractmethod
    def get_specifications(self):
        pass

    @abstractmethod
    def is_dfig(self):
        pass

class SolarPanel(ElectricitySource):
    def get_specifications(self):
        return {
            "tension": "24V",
            "frecuencia": "DC",
            "potencia": "300W"
        }

class WindTurbine(ElectricitySource):
    def get_specifications(self):
        return {
            "tension": "690V",
            "frecuencia": "50Hz",
            "potencia": "2000W"
        }

    def is_dfig(self):
        return True

sources = [SolarPanel(), WindTurbine()]
for source in sources:
    specs = source.get_specifications()
    print(
        f"""
        Fuente: {source.__class__.__name__} -> Tensión: {specs['tension']},
        Frecuencia: {specs['frecuencia']}, Potencia: {specs['potencia']}
        """
    )
    if isinstance(source, WindTurbine):
        print(f"¿Es DFIG? {'Sí' if source.is_dfig() else 'No'}")

En este ejemplo se observa el principio porque la clase base ElectricitySource define métodos abstractos (get_specifications y is_dfig), pero cada subclase implementa solo los métodos que realmente necesita. Por ejemplo, SolarPanel implementa únicamente get_specifications, mientras que WindTurbine implementa ambos métodos porque el concepto de DFIG solo aplica a turbinas eólicas.

Dependency Inversion Principle (DIP)

El Dependency Inversion Principle (DIP) establece que las clases de alto nivel no deben depender de clases de bajo nivel, sino de abstracciones. Esto significa que se deben utilizar interfaces o clases abstractas para desacoplar las dependencias entre los módulos de un sistema.

A continuación se muestra un ejemplo en Python que ilustra el DIP. Se define una interfaz ElectricitySource y una clase PowerPlant que depende de esta interfaz en lugar de depender de implementaciones concretas.

from abc import ABC, abstractmethod

class ElectricitySource(ABC):
    @abstractmethod
    def get_specifications(self):
        pass

class PowerPlant:
    def __init__(self, source: ElectricitySource):
        self.source = source

    def provide_power(self):
        specs = self.source.get_specifications()
        print(f"Proporcionando electricidad: {specs['potencia']}")

class SolarPanel(ElectricitySource):
    def get_specifications(self):
        return {
            "tension": "24V",
            "frecuencia": "DC",
            "potencia": "300W"
        }

class WindTurbine(ElectricitySource):
    def get_specifications(self):
        return {
            "tension": "690V",
            "frecuencia": "50Hz",
            "potencia": "2000W"
        }

solar_panel = SolarPanel()
wind_turbine = WindTurbine()

power_plant1 = PowerPlant(solar_panel)
power_plant2 = PowerPlant(wind_turbine)

power_plant1.provide_power()
power_plant2.provide_power()

En este ejemplo, la clase PowerPlant depende de la interfaz ElectricitySource, lo que le permite trabajar con cualquier fuente de electricidad que implemente esta interfaz. Esto cumple con el principio de inversión de dependencias, ya que las clases de alto nivel (PowerPlant) no dependen de clases de bajo nivel (SolarPanel y WindTurbine), sino de abstracciones.

Clean Code

Ya habiendo visto la arquitectura hexagonal y los principios SOLID, es bueno para concluir este apartado, introducir el concepto de Clean Code.

El Clean Code se refiere a un conjunto de prácticas y principios que buscan mejorar la calidad del código, haciéndolo más legible, mantenible y menos propenso a errores.

A continuación se muestran ejemplos de algunos, los que se consideran más relevantes, con Python.

Nombres significativos

Utilizar nombres descriptivos para variables, funciones y clases, de modo que el propósito del código sea claro.

# MAL - Este es un ejemplo de mal código
def calculate_power(power_stator, s):
    return power_stator * s

# BIEN - Este es un ejemplo de buen código
def calculate_rotor_active_power(power_stator, s):
    return power_stator * s

El nombre de la función ahora es más descriptivo y refleja su propósito.

Funciones pequeñas y enfocadas

Las funciones deben ser pequeñas y realizar una única tarea. Esto facilita la comprensión y la reutilización del código.

# MAL - Este es un ejemplo de mal código
def calculate_power(u_conv, i_conv, u_s, i_s):
    S_conv = 3 * u_conv * i_conv
    S_s = 3 * u_s * i_s
    return S_conv, S_s

# BIEN - Este es un ejemplo de buen código
def calculate_stator_power(u_s, i_s):
    return 3 * u_s * i_s

def calculate_converter_power(u_conv, i_conv):
    return 3 * u_conv * i_conv

Ahora las funciones son más pequeñas y fáciles de entender, cada una realiza una única tarea, con lo cual es más fácil de probar y mantener.

Eliminación de código muerto

El código que no se utiliza debe ser eliminado para reducir la complejidad y mejorar la legibilidad.

Comentarios útiles

Los comentarios deben explicar el “por qué” detrás de una decisión de diseño, no el “qué” hace el código. El código debe ser lo suficientemente claro como para que los comentarios sean mínimos.

# MAL - Este es un ejemplo de mal código
def calculate_converter_power(u_conv, i_conv):
    # calcula la potencia activa del convertidor
    # u_conv: tensión del convertidor
    # i_conv: corriente del convertidor
    return 3 * u_conv * i_conv

# BIEN - Este es un ejemplo de buen código
def calculate_converter_power(u_s, i_s, s):
    # Despreciando las caidas en R_s, X_e y R_r
    # u_s = u_conv / s
    # Despreciando la corriente magnetica
    # i_s = i_conv
    return 3 * u_s * i_s * s

En el primer ejemplo, se tienen redundancias en los comentarios, ya que por el entorno se entinede que u_conv y i_conv son las tensiones y corrientes del convertidor, respectivamente, también el nombre de la función refleja su propósito.

Por otro lado, en el segundo ejemplo, los comentarios son más útiles, ya que explican las suposiciones y simplificaciones realizadas en los cálculos.

Consistencia

Seguir un estilo de codificación coherente en todo el proyecto, incluyendo la nomenclatura, la organización de archivos y la estructura del código.

# MAL - Este es un ejemplo de mal código
uConv = 400
I_CONV = 100
s = 0.1

def CalculateStatorPower():
    pass

# BIEN - Este es un ejemplo de buen código
u_conv = 400
i_conv = 100
s = 0.1

def calculate_stator_power(u_s, i_s):
    return 3 * u_s * i_s

Pruebas automatizadas

Escribir pruebas automatizadas para el código ayuda a garantizar que funcione correctamente y facilita la refactorización.

def test_calculate_stator_power():
    assert calculate_stator_power(400, 100) == 120000
    assert calculate_stator_power(0, 0) == 0
    assert calculate_stator_power(230, 50) == 34500

Patrones de Diseño

Los patrones de diseño son soluciones reutilizables a problemas comunes en el desarrollo de software. Cada uno es como una maqueta la cual se puede adaptar a tu diseño particular.

A continuación se mencionan algunos patrones que se consideran más relevantes para este trabajo:

  1. State: es un patrón de diseño de comportamiento, con el que se permite modificar el comportamiento cuando su estado interno cambia.

  2. Strategy: permite definir una familia de algoritmos, encapsular cada uno y hacerlos intercambiables. Este patrón permite que el algoritmo varíe independientemente de los clientes que lo utilizan.

  3. Prototype: permite crear nuevos objetos a partir de una instancia existente, sin necesidad de conocer su clase concreta. Este patrón es útil para evitar la sobrecarga de la creación de objetos y para implementar la clonación de objetos.

  4. Template Method: define el esqueleto de un algoritmo en una operación, delegando algunos pasos a las subclases. Este patrón permite que las subclases redefinan ciertos pasos de un algoritmo sin cambiar la estructura del mismo.

  5. Decorator: permite añadir funcionalidad a un objeto de forma dinámica. Este patrón es útil para seguir el principio de responsabilidad única, ya que permite dividir la funcionalidad en clases independientes.