Aplicando especificaciones para tratar con las reglas de negocio


 

Si hubiese que elegir la primera acción para poder empezar a implementar un software, una solución a un problema, ¿qué elegirías? 

Yo optaría por empezar definiendo las reglas del negocio, todo aquello que concierne al conocimiento del problema que se pretende solucionar, así como su contexto y límites. Las reglas que se deben aplicar para garantizar la consistencia y validez de las entidades que se manejan. En definitiva la especificación de requisitos de negocio que aplican, para realmente entender el problema que se pretende resolver y que se deben trasladar al software. 

Dentro del paradigma DDD y concretamente si se aplica arquitectura hexagonal las reglas de negocio pertenecen y están en el dominio, y cada entidad tiene las suyas. De esta manera se suelen implementar en el interior de cada entidad del dominio, buscando sobre todo la cohesión. Esta aproximación, válida y suficiente en muchas ocasiones, favorece, sin embargo, la duplicidad de código, además de que el número de líneas de código en las entidades de dominio se puede volver bastante alto, aunque tengamos objetos de valor y agregados correspondientes.

Para ayudarnos a organizar el código de las reglas de negocio podemos usar el patrón Specification. Una especificación es como una condición o predicado usado para asegurar (validar) las propiedades de un objeto. 

El uso de especificaciones (junto con políticas) nos ayudará a mejorar la robustez y consistencia de las reglas de negocio en el código que generemos. Algunas de las ventajas que obtenemos son: 

  • Encapsulación de la lógica, favoreciendo la reutilización y combinación.
  • Permite desacoplar el diseño de requisitos, cumplimiento y validación.
  • Mejora de la expresividad y definición de las reglas de negocio.
  •  Facilita la implementación y ejecución de pruebas unitarias que validen la funcionalidad de las reglas de negocio.

En el siguiente ejemplo voy a tratar de poner en valor el uso de este patrón.

Vamos a partir de la necesidad existente de realizar inspecciones o controles de calidad en algún proceso. De manera que se puedan descubrir los incumplimientos de los lotes y los elementos individuales de cada lote. En el ejemplo se va a trabajar con la entidad Medición que engloba a un conjunto de elementos (o individuos). Se aplicarán una serie de reglas de negocio que permitirán determinar si cada elemento cumple con la normativa (reglas de negocio), además de otras reglas posibles para el conjunto, Medición

Por ejemplo se podría usar para inspecciones de pesca donde las reglas de negocio se establecen en función de la especie y la zona donde se realizó la captura. Pero también sería válido en otros contextos, como de fabricación, agricultura, etc.

* Se usa Java para los ejemplo de código.

Diseño de las especificaciones

Voy a empezar por el diseño de las especificaciones. Para ello se define una interfaz, que deben cumplir las especificaciones en las que se encapsule cada regla de domino.

 
 public interface Specification<T> {
    
    boolean isSatisfiedBy(T t);    
    
    Optional<NonCompliance> check(T t);
    
    default Specification<T> and(final Specification<T> specification) {        
        return new AndSpecification<T>(this,specification);
    }

    default Specification<T> or(final Specification<T> specification) {        
        return new OrSpecification<T>(this,specification);
    }

   default Specification<T> nor(final Specification<T> specification) {        
        return new NorSpecification<T>(this,specification);
    }         

    default String getMessage(String specName, String valueToCheck, String valuePermited){
        return "Validation error: "+specName
                +" Value: "+valueToCheck
                +" Permited: "+valuePermited;
    }
}

Como se ve en el código se ha definido unos métodos para validar si la especificación es satisfecha por un objeto, para poder chequear si hay incumplimiento por parte del objeto y además tres métodos (and, or y nor) para poder combinar especificaciones, y así facilitar la posibilidad de crear predicados.

A partir de aquí podemos definir especificaciones que cumplan con la interfaz proporcionada e implementen reglas de negocio concretas.

Por ejemplo, hay una regla que indica el tamaño mínimo de cada individuo de la captura a medir debe ser menor que un tamaño especificado. de esta forma podemos implementar la clase:


public class MinSizePerUnitSpec implements Specification<Individual> {

    private Double minSizePerUnit;

    public MinSizePerUnitSpec(Double minSizetPerUnit) {
        this.minSizePerUnit = minSizetPerUnit;        
    }

    @Override
    public boolean isSatisfiedBy(Individual ind) {
        return ind.getSize() >= minSizePerUnit;
    }

    @Override
    public Optional<NonCompliance> check(Individual ind) {
        
        NonCompliance comp = null;
        if(!isSatisfiedBy(ind)){    
            comp = new NonCompliance(this.getMessage("MinSizePerUnitSpec",
                String.valueOf(ind.getSize()),
                String.valueOf(minSizePerUnit)));
        }
        return Optional.ofNullable(comp);
    }     
}

Tenemos la primera especificación que validará si un individuo satisface la regla de negocio de tener un tamaño mínimo. Vamos a probar que efectivamente funciona.


@SpringBootTest
public class SpecificationTest {
    
    private static final Double MIN_SIZE_PER_UNIT = 100D;

    static Specification<Individual> spec1;

    @BeforeAll
    static void beforeClass() {
        spec1 = new MinSizePerUnitSpec(MIN_SIZE_PER_UNIT);
    } 

    @DisplayName("Individuo NO cumple en tamaño mímino")
    @Test
    void shouldNotSatisfyMinSize(){
        //given
        Individual individual = new Individual(50D, 90D);
        //when
        boolean isSatisfied = spec1.isSatisfiedBy(individual);
        //then
        assertFalse(isSatisfied);
}

Al ejecutar este test la especificación no será satisfecha por el individuo, ya que se ha creado con un tamaño de 90, cuando el mínimo permitido que se ha definido en la especificación es de 100.

La inicialización (creación de las especificaciones) puede ser a través de información de base de datos, de manera que para cada especie/zona de captura a inspeccionar tenga los valores concretos de la normativa aplicable.

Así podemos seguir implementando especificaciones y sus test correspondientes. 

Modelo de dominio

Como he reflejado anteriormente para el ejemplo vamos a tratar fundamentalmente con la entidad Medición.

@Getter
public class Mensuration {

    private final MensurationId id;

    private final Species species;

    private final CaptureType type;

    private final Zone zone;
    
    private MensurationEquipment equipment;

    private final Set<Specification<Individual>> individualSpecs;

    private final Set<Specification<Mensuration>> specs;
    
    private final List<Individual> individuals = new ArrayList<Individual>();

    private final Set<NonCompliance> nonCompliances = new HashSet<NonCompliance>();
    

    public Mensuration(MensurationId id, Species species,
                        CaptureType type,
                        Zone zone,
                        MensurationEquipment equipment,
                        Set<Specification<Mensuration>> specs,
                        Set<Specification<Individual> individualSpecs){
        this.id = id;
        this.species = species;
        this.type = type;
        this.zone = zone;
        this.equipment = equipment;
        this.specs = specs;
        this.individualSpecs = individualSpecs;        
    }

    public void addIndividual(Individual individual){
        validateIndividual(individual);
        this.individuals.add(individual);        
    }

    private void validateIndividual(Individual individual){
        individualSpecs.forEach(s -> {
            Optional hasNonCompliance = s.check(individual);
            if(!hasNonCompliance.isEmpty()){
                individual.addNonCompliance(hasNonCompliance.get());                
            }            
        });
    }

    public boolean isValid(){     
        return this.nonCompliances.isEmpty()
            && !hasIndividualsNotValid();
    }

    public boolean hasIndividualsNotValid(){
        return this.getIndividuals().stream()
                    .anyMatch(i -> !i.isValid());
    }

    public int totalIndividual(){
        return this.individuals.size();
    }

    public void validate() {
    	this.nonCompliances.clear();
        specs.forEach(s -> {
            Optional<NonCompliance> hasNonCompliance = s.check(this);
            if(!hasNonCompliance.isEmpty()){
                nonCompliances.add(hasNonCompliance.get());                
            }            
        });
    }

    public long totalIndNonCompliance() {
        return this.individuals.stream().filter(i -> !i.isValid()).count();
    }

    public long totalIndCompliance() {
        return this.individuals.stream().filter(i -> i.isValid()).count();
    }
}

Cada medición tendrá una serie de objetos de valor, como la especie, la zona y el equipamiento empleado para realizar la medición. Además tendrá una colección de especificaciones propias para la medición, y otro conjunto de especificaciones a satisfacer por cada individuo. Estas especificaciones serán obligatorias a la hora de construir la entidad (por simplicidad uso el constructor y no se comprueba, pero sería deseable).

Se puede observar en el código como al añadir un individuo a la medición se procesarán todas las especificaciones definidas para el individuo, y si detecta algún incumplimiento quedará registrado. Así mismo se ha provisto del método validate() a la propia entidad Medición, el cual será responsable de aplicar las especificaciones de la misma y registrar los incumplimientos.

Gracias al desacoplamiento conseguido se pueden reutilizar las diferentes especificaciones dinámicamente en función de la medición (especie y zona).

Además cada entidad Medición tendrá un conjunto de incumplimientos de la entidad al completo (por ejemplo que el nº de individuos por kilogramo), donde se indicarán las reglas que no satisface. En muchos escenarios lo que se suele hacer es lanzar alguna excepción por ejemplo en la construcción, pero en este ejemplo se tratan los incumplimientos de las reglas de negocio como una propiedad más, aportando valor a la medición. con esta implementación se tiene, por tanto, registro de los incumplimientos de cada individuo y del conjunto de la medición.

Me voy a saltar la definición de los objetos de valor básicos, y pongo a continuación la definición de Individual (individuo) y de NonCompliance (incumplimiento). En este caso los voy a tratar como objetos de valor. Indicando que una Medición tiene una colección de individuos y una colección de incumplimientos.
@Getter
@RequiredArgsConstructor
public class Individual {
    
    private final Double weight;
    
    private final Double size;
    
    private Set<NonCompliance> nonCompliances = new HashSet<NonCompliance>();

    public void addNonCompliance(NonCompliance nonCompliance){
        nonCompliances.add(nonCompliance);
    }

    public boolean isValid(){
        return this.nonCompliances.isEmpty();
    }
}

Cada individuo tendrá un peso y un tamaño (valores que se desean inspeccionar), a su vez tendrá una colección de incumplimientos, correspondientes a la evaluación de cada una de las reglas de negocio aplicadas. Por ejemplo: el individuo no satisface el tamaño mínimo y/o el peso mínimo, para la medición.

Para los incumplimientos, usaré la clase siguiente:

@Getter 
public class NonCompliance {
    
    private final String message;

    public NonCompliance(String message) {
        this.message = message;
    }       
}
Con un solo mensaje donde se especifica el incumplimiento.

Combinando especificaciones

Supongamos que necesitamos combinar especificaciones, por ejempo: un individuo debe satisfacer el peso y la talla mínima.  Para ello podemos usar el and disponible en la API de Speficication.

Por ejemplo:


@SpringBootTest
public class SpecificationTest {
    
    private static final Double MIN_SIZE_PER_UNIT = 100D;
    private static final Double MIN_WEIGHT_PER_UNIT = 100D;    

    static Specification<Individual> spec1;
    static Specification<Individual> spec2;    

    @BeforeAll
    static void beforeClass() {
        spec1 = new MinSizePerUnitSpec(MIN_SIZE_PER_UNIT);
        spec2 = new MinWeightPerUnitSpec(MIN_WEIGHT_PER_UNIT);
    }    

    @DisplayName("Individuo cumple en peso mímino y talla mínima")
    @Test
    void shouldSatisfyMinWeightAndMinSize(){
        //given
        Individual individual = new Individual(MIN_WEIGHT_PER_UNIT, MIN_SIZE_PER_UNIT);
        //when
        boolean isSatisfied = spec1.and(spec2).isSatisfiedBy(individual);
        //then
        assertTrue(isSatisfied);
    }

Igualmente se podrían combinar usando or o nor.

La implementación de AndSpecification podría quedar así:


public class AndSpecification<T> implements Specification<T>{

    private final Specification<T> spec1;
    private final Specification<T> spec2;
    

    public AndSpecification(Specification<T> spec1, Specification<T> spec2) {
        this.spec1 = spec1;
        this.spec2 = spec2;        
    }

    @Override
    public boolean isSatisfiedBy(T t) {
        
        return spec1.isSatisfiedBy(t) &&
            spec2.isSatisfiedBy(t);
    }

    @Override 
    public Optional<NonCompliance> check(T t) {
        
        NonCompliance comp = null;
        if (!this.isSatisfiedBy(t)) {
            comp = new NonCompliance(spec1.getClass().getName()
                        +" AND "
                        +spec2.getClass().getName());            
        }
        return Optional.ofNullable(comp);
    }
}

Aunque el código mostrado puede quedar algo simple, he pretendido exponer el concepto con un ejemplo lo mas corto y sencillo posible. 

Como siempre cualquier comentario es bienvenido.

Comentarios