Obsesión Primitiva: Simplificando la Complejidad

Obsesión Primitiva: Simplificando la Complejidad

Transformando nuestros Modelos de Entidades con Abstracciones Inteligentes

La Obsesión Primitiva es un término que denota la excesiva dependencia de tipos de datos primitivos para representar conceptos complejos del dominio en el desarrollo de software. Este artículo abordará las implicaciones de esta práctica y cómo superarla para lograr una arquitectura de software más robusta y mantenible.

La Obsesión Primitiva se refiere al uso generalizado de tipos de datos primitivos (como int, string, bool) en sistemas de software para representar conceptos de dominio complejos. Aunque inicialmente parece conveniente, este enfoque conduce a problemas como falta de expresividad, aumento de la complejidad y dificultades en el mantenimiento.

Cuándo y por qué superar la Obsesión Primitiva

  • Cuándo: Es crucial superarla cuando la representación de conceptos complejos del dominio se vuelve confusa y el código es difícil de mantener.

  • Por qué: Superar esta práctica mejora la expresividad del código, facilita la validación y reduce la duplicación, resultando en un software más robusto y fácil de mantener.

Técnicas para abordar la Obsesión Primitiva

Antes de profundizar en la implementación específica, me gustaría establecer una base sólida mediante la introducción de dos clases útiles: Error y Result. Estas clases nos permitirán manejar de manera eficiente y coherente los resultados y errores a lo largo de nuestra aplicación, asegurando que el código sea robusto y fácil de mantener.

Clase Error

public record Error(string Code, string Name)
{
    public static Error None = new(string.Empty, string.Empty);

    public static Error NullValue = new("Error.NullValue", "Null value was provided");
}

Se utiliza para encapsular información relacionada con errores que pueden ocurrir durante la ejecución de nuestro programa. Esta clase es un registro, lo que significa que es inmutable y su estado no cambia una vez que se ha creado una instancia.

  • Code: Un identificador único para el tipo de error.

  • Message: Un mensaje descriptivo asociado con el error.

La clase también incluye errores predefinidos como None (que representa la ausencia de un error) y NullValue (que se utiliza cuando se proporciona un valor nulo donde no se espera).

Clase Result

Es fundamental para el manejo de operaciones que pueden tener éxito o fallar. Esta clase encapsula el resultado de una operación, junto con cualquier error que pueda haber ocurrido.

public class Result
{
    protected internal Result(bool isSuccess, Error error)
    {
        if (isSuccess && error != Error.None)
        {
            throw new InvalidOperationException();
        }

        if (!isSuccess && error == Error.None)
        {
            throw new InvalidOperationException();
        }

        IsSuccess = isSuccess;
        Error = error;
    }

    public bool IsSuccess { get; }

    public bool IsFailure => !IsSuccess;

    public Error Error { get; }

    public static Result Success() => new(true, Error.None);

    public static Result Failure(Error error) => new(false, error);

    public static Result<TValue> Success<TValue>(TValue value) => new(value, true, Error.None);

    public static Result<TValue> Failure<TValue>(Error error) => new(default, false, error);

    public static Result<TValue> Create<TValue>(TValue? value) =>
        value is not null ? Success(value) : Failure<TValue>(Error.NullValue);
}

public class Result<TValue> : Result
{
    private readonly TValue? _value;

    protected internal Result(TValue? value, bool isSuccess, Error error)
        : base(isSuccess, error)
    {
        _value = value;
    }

    [NotNull]
    public TValue Value => IsSuccess
        ? _value!
        : throw new InvalidOperationException("The value of a failure result can not be accessed.");

    public static implicit operator Result<TValue>(TValue? value) => Create(value);
}

Constructor Protegido Interno:

El constructor de la clase Result es protegido e interno, lo que significa que sólo puede ser llamado desde dentro de la clase o desde clases derivadas. Acepta dos parámetros: isSuccess (un booleano que indica si la operación fue exitosa) y error (un objeto Error que contiene información sobre un error si ocurrió).

Validaciones en el Constructor:

Dentro del constructor, hay validaciones para asegurarse de que el estado del objeto sea coherente: si isSuccess es true, entonces error debe ser Error.None, y si isSuccess es false, entonces error no debe ser Error.None. Si estas condiciones no se cumplen, se lanza una excepción InvalidOperationException.

Propiedades:

  • IsSuccess: Un valor booleano que indica si la operación fue exitosa.

  • IsFailure: Un valor booleano derivado que indica si la operación falló.

  • Error: Un objeto de tipo Error que contiene detalles del error si la operación falló.

Métodos Estáticos de Fábrica:

  • Success(): Crea un resultado exitoso sin valor.

  • Failure(error): Crea un resultado fallido con un error específico.

  • Success<TValue>(value): Crea un resultado exitoso con un valor de tipo TValue.

  • Failure<TValue>(error): Crea un resultado fallido de tipo TValue.

  • Create<TValue>(value): Crea un resultado basado en si value es nulo o no.

Clase Genérica Result<TValue> :

  • Esta clase derivada permite manejar resultados que incluyen un valor de tipo TValue.

  • Tiene un campo privado _value para almacenar el valor.

  • El constructor de esta clase derivada también verifica la consistencia del resultado.

  • La propiedad Value devuelve el valor si IsSuccess es true; de lo contrario, lanza una excepción, asegurando que los valores de los resultados fallidos no se puedan acceder.

Conversión Implícita:

Hay un operador de conversión implícita que permite convertir un valor de tipo TValue en un objeto Result<TValue>. Esto simplifica la creación de resultados exitosos.

Esta clase Result ofrece métodos estáticos para crear instancias que representen éxitos (Success) o fracasos (Failure). También se proporciona una sobrecarga genérica de Result para operaciones que devuelven un valor.

El uso de estas clases en conjunto proporciona un marco claro y consistente para manejar los resultados y errores en una aplicación, lo que facilita la escritura de código más limpio y mantenible.

Implementación

Crea clases o estructuras personalizadas para encapsular datos primitivos y comportamientos relacionados, mejorando la expresividad y conteniendo la lógica de validación.

Ahora si, veamos como podemos implementar las diferentes técnicas.

Técnica #1: Con clase abstracta

Clase Abstracta ValueObject:

ValueObject es una clase abstracta, lo que significa que no puede ser instanciada por sí misma. En su lugar, está diseñada para ser heredada por otras clases que representan objetos de valor.

public abstract class ValueObject : IEquatable<ValueObject>
{
    public abstract IEnumerable<object> GetAtomicValues();

    public bool Equals(ValueObject? other)
    {
        return other is not null && ValuesAreEqual(other);
    }

    public override bool Equals(object? obj)
    {
        return obj is ValueObject other && ValuesAreEqual(other);
    }

    public override int GetHashCode()
    {
        return GetAtomicValues().Aggregate(default(int), HashCode.Combine);
    }

    private bool ValuesAreEqual(ValueObject other)
    {
        return GetAtomicValues().SequenceEqual(other.GetAtomicValues());
    }
}

Interfaz IEquatable<ValueObject> :

La clase implementa la interfaz IEquatable<ValueObject>, lo que significa que proporciona una implementación personalizada para determinar si dos objetos de valor son iguales.

Método Abstracto GetAtomicValues :

  • Este método abstracto debe ser implementado por las clases derivadas para devolver los valores individuales (atómicos) que componen el objeto de valor.

  • Estos valores son los que se utilizan para determinar la igualdad entre dos instancias.

Método Equals (Sobrecarga de IEquatable<ValueObject>):

  • Este método determina la igualdad con otro ValueObject.

  • Utiliza ValuesAreEqual para comparar los valores atómicos de ambos objetos.

  • Devuelve false si el otro objeto es nulo.

Método Equals (Sobrecarga de Object):

  • Esta sobrecarga asegura que la igualdad se maneje correctamente cuando el objeto se trata como un Object.

  • Realiza una comprobación de tipo antes de llamar a ValuesAreEqual.

Método GetHashCode :

  • Sobrescribe el método GetHashCode de Object.

  • Proporciona un código hash que se basa en los valores atómicos del objeto, utilizando Aggregate y HashCode.Combine.

  • Este código hash es consistente con la definición de igualdad de la clase.

Método Privado ValuesAreEqual :

  • Compara los valores atómicos de this y otro ValueObject para determinar si son iguales.

  • Utiliza SequenceEqual para comparar las secuencias de valores atómicos.

En resumen, ValueObject es una clase base diseñada para proporcionar una implementación consistente y robusta de igualdad basada en valores atómicos para objetos de valor. Esta clase es esencial para garantizar que la igualdad de objetos en tu dominio se base en sus valores y no en sus referencias de memoria, lo cual es un aspecto clave en el diseño orientado al dominio y en la creación de modelos de dominio ricos y expresivos.

Clase EmailAddress:

Esta es una implementación de ejemplo para entender como podemos utilizar nuestra clase abstracta de ValueObject.

public sealed class EmailAddress : ValueObject
{
    public const int MaxLength = 100; // Longitud máxima típica para un email

    private EmailAddress(string value)
    {
        Value = value;
    }

    public string Value { get; }

    public static Result<EmailAddress> Create(string email)
    {
        if (string.IsNullOrWhiteSpace(email))
        {
            return Result.Failure<EmailAddress>(new Error(
                "EmailAddress.Empty",
                "Email address is empty."));
        }

        if (email.Length > MaxLength)
        {
            return Result.Failure<EmailAddress>(new Error(
                "EmailAddress.TooLong",
                "Email address is too long."));
        }

        if (!IsValidEmail(email))
        {
            return Result.Failure<EmailAddress>(new Error(
                "EmailAddress.Invalid",
                "Email address is not valid."));
        }

        return new EmailAddress(email);
    }

    public override IEnumerable<object> GetAtomicValues()
    {
        yield return Value;
    }

    private static bool IsValidEmail(string email)
    {
        // Patrón de regex para validar el correo electrónico
        var emailRegex = @"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$";
        var regex = new Regex(emailRegex, RegexOptions.IgnoreCase);

        return regex.IsMatch(email);
    }
}

Herencia de ValueObject :

Al heredar de ValueObject, EmailAddress obtiene una implementación de igualdad basada en el valor, esencial para objetos de valor como una dirección de correo electrónico.

Propiedad MaxLength :

Define una constante MaxLength para establecer la longitud máxima permitida para una dirección de correo electrónico, lo que ayuda a mantener la validez y la integridad de los datos.

Constructor Privado:

El constructor es privado y solo se llama dentro de la clase, lo que garantiza que las instancias de EmailAddress solo puedan ser creadas a través del método Create, asegurando que todas las direcciones de correo electrónico pasen por las validaciones necesarias.

Propiedad Value :

Almacena el valor de la dirección de correo electrónico, manteniendo la inmutabilidad del objeto después de su creación.

Método Estático Create :

  • Proporciona un punto de creación controlado para instancias de EmailAddress.

  • Realiza varias validaciones: verifica que el correo electrónico no esté vacío, que no exceda la longitud máxima y que sea válido según una expresión regular.

  • Devuelve un objeto Result<EmailAddress>, permitiendo un manejo elegante de errores o un éxito en la creación del objeto.

Sobrescritura de GetAtomicValues :

Implementa el método abstracto heredado de ValueObject, devolviendo los componentes que definen la igualdad del objeto, en este caso, el valor de la dirección de correo electrónico.

Método Privado Estático IsValidEmail :

  • Utiliza una expresión regular para validar el formato de la dirección de correo electrónico.

  • Proporciona una verificación de formato adicional y esencial para garantizar la validez del correo electrónico.

En resumen, EmailAddress encapsula de manera efectiva las características y validaciones necesarias para manejar direcciones de correo electrónico en una aplicación. Al integrarse con ValueObject y Result, esta clase no solo garantiza la validez y la integridad de las direcciones de correo electrónico sino que también facilita la gestión de errores y el mantenimiento de la inmutabilidad de los objetos.

Ejemplo de uso de la ténica #1

public class User
{
    // Constructor(es)

    // Otras propiedades

    public Email Email { get; set; }

    // Métodos
}
var user = new User();
var email = EmailAddress.Create("khanakat@email.com");

if (email.IsFailure)
{
    Console.WriteLine(email.Error);
    return;
}

user.Email = email.Value;
Console.WriteLine(user.Email.Value);

Técnica #2: Con clase record

Utiliza Value Objects para representar conceptos del dominio definidos por sus atributos en lugar de su identidad. Estos son inmutables y su igualdad se basa en el valor de sus propiedades.

Por ejemplo podemos utilizar una clase record la cual ya contiene la implementación de IEquatable por ende ya no es necesario implementar una clase abstracta.
De una manera muy sencilla podría ser de la siguiente manera:

public sealed record FirstName(string Value);

Pero si quisieramos que nuestro ValueObject sea mas robusto podriamos hacerlo de la siguiente manera:

public sealed record FirstName
{
    public const int MaxLength = 50;

    private FirstName(string value)
    {
        Value = value;
    }

    public string Value { get; }

    public static Result<FirstName> Create(string firstName)
    {
        if (string.IsNullOrWhiteSpace(firstName))
        {
            return Result.Failure<FirstName>(new Error(
                "FirstName.Empty",
                "First name is empty."));
        }

        if (firstName.Length > MaxLength)
        {
            return Result.Failure<FirstName>(new Error(
                "FirstName.TooLong",
                "First name is too long."));
        }

        return new FirstName(firstName);
    }
}

Uso de record :

Al ser un record, FirstName es inmutable por diseño. Los registros en C# son tipos de referencia que proporcionan valor semántico de igualdad y son ideales para modelar objetos inmutables.

Constante MaxLength :

La constante MaxLength define la longitud máxima permitida para un nombre, lo que ayuda a mantener la validez y consistencia de los datos.

Constructor:

El constructor es privado y establece el valor del nombre. Se espera que las instancias sean creadas mediante el método estático Create.

Propiedad Value :

Almacena el valor del nombre de pila, garantizando que una vez que se crea una instancia de FirstName, su valor no puede ser modificado.

Método Estático Create :

  • Este método es el punto de creación recomendado para instancias de FirstName.

  • Realiza validaciones para asegurarse de que el nombre de pila proporcionado no esté vacío y no exceda la longitud máxima establecida.

  • En caso de error (nombre vacío o demasiado largo), devuelve un objeto Result<FirstName> indicando el fallo, junto con un mensaje de error apropiado.

  • Si las validaciones son exitosas, devuelve una nueva instancia de FirstName.

En resumen el diseño de la clase FirstName garantiza que todos los nombres de pila manejados por tu sistema cumplan con las reglas definidas, evitando estados inválidos y facilitando el manejo de errores. Al utilizar un enfoque basado en registros y métodos estáticos para la creación y validación, esta clase promueve un uso seguro y coherente de los nombres de pila en toda tu aplicación.

Ejemplo de uso de la ténica #2

public class User
{
    // Constructor(es)

    // Otras propiedades

    public FirstName FirstName { get; set; }

    // Métodos
}
var user = new User();
var firstName = FirstName.Create("Fernando");

if (firstName.IsFailure)
{
    Console.WriteLine(firstName.Error);
    return;
}

user.FirstName = firstName.Value;
Console.WriteLine(user.FirstName.Value);

Conclusión

Conclusión: La Obsesión Primitiva y el Arte de la Abstracción Efectiva

Para concluir, es esencial recordar que superar la Obsesión Primitiva no es solo una cuestión de aplicar técnicas; es un aspecto fundamental en la evolución de nuestras habilidades como desarrolladores. Cada vez que elegimos representar un concepto complejo del dominio de manera más abstracta y significativa, no solo estamos escribiendo código más limpio y mantenible, sino también perfeccionando nuestro arte.

Este artículo es solo un paso en el viaje hacia una mejor comprensión y aplicación de prácticas de desarrollo de software eficaces. Espero que este enfoque en la Obsesión Primitiva te haya inspirado a reflexionar sobre cómo representas los conceptos en tus proyectos y a buscar constantemente formas de mejorar tu código.

Nos vemos en futuros artículos donde continuaremos desglosando los principios del diseño de software limpio y su aplicación práctica en el mundo del desarrollo. ¡Hasta entonces, feliz codificación y recuerda que cada línea de código cuenta!

😸 Happy Coding!