Mantén puro el Dominio para conseguir una arquitectura Limpia y fácil de mantener

Publicado: 2025-09-06

📖 16 min de lectura

En el desarrollo de software, la complejidad y la deuda técnica accidental surgen con mucha frecuencia cuando la lógica de negocio se mezcla con detalles de infraestructura o se enreda con dependencias externas. Para tomar mejores decisiciones te quiero compartir un principio de diseño sencillo pero a la vez poderoso que permite llegar a desarrollar arquitecturas limpias, sostenibles y fáciles de mantener:

Mantener puro el dominio.

En este artículo profundizaré sobre este principio con el fin de explicar:

La aplicación de este principio es propia de un paradigma funcional. Sin embargo, junto a mi compañero Cristian Suarez, hemos aplicado estos principios en un proyecto real con Java, demostrando que es posible mantener un dominio puro incluso en lenguajes no tipicamente funcionales. Los ejemplos que aquí veas son en Java, pero los principios son aplicables a cualquier lenguaje.


¿Qué significa mantener puro el dominio?

En palabras de Scott Wlaschin, en su libro Domain Modeling Made Functional:

Se trata de trasladar cualquier tipo de efecto secundario, mutación o aletoriedad fuera de la lógica de negocio, pues se busca que ésta sea un conjunto de funciones predecibles, fáciles de razonar y con dependencias explícitas.

En esencia, mantener puro el dominio implica que toda la lógica de negocio debe ser:

Ejemplo: Dominio puro vs. Dominio impuro

Veamos la diferencia con un ejemplo práctico de cálculo de descuentos:

❌ Dominio impuro:

import java.math.BigDecimal;
import utils.*; // AJA! ni es explicito de donde vienen las dependencias ocultas

public class DiscountsModule {
    
    public static Function<DiscountRequest, BigDecimal> calculateDiscount = discountRequest -> {

        // ❌ Acceso a base de datos dentro del dominio
        Customer customer = Database.getInstance().getCustomer(discountRequest.customer().id());

        // ❌ Efecto secundario (logging) dentro del dominio
        Logger.singleton.log("Calculating discount for customer " + customer.id());

        // ❌ Dependencia de estado externo (fecha actual)
        int customerAge = LocalDate.now().getYear() - customer.getBirthYear();
        
        if (customer.isPremium() && customerAge > 65) {
            return discountRequest.amount().multiply(new BigDecimal("0.20"));
        }

        return customer.isPremium() ?
            discountRequest.amount().multiply(new BigDecimal("0.10")) :
            BigDecimal.ZERO;
    }
}

✅ Dominio puro:

// Tipos del dominio
public record Age(int value) {}
public record Customer(boolean isPremium, Age age) {}
public record DiscountRequest(Customer customer, BigDecimal amount) {}

public final class DiscountsModule {

    public static Function<DiscountRequest, BigDecimal> calculateDiscount = discountRequest -> {
        // ✅ Lógica pura: solo depende de los parámetros
        Customer customer = discountRequest.customer();
        if (customer.isPremium() && customer.age().value() > 65) {
            return discountRequest.amount().multiply(new BigDecimal("0.20"));
        }

        return discountRequest.customer().isPremium() ?
            discountRequest.amount().multiply(new BigDecimal("0.10")) :
            BigDecimal.ZERO;
    }
}

Cómo usarlo manteniendo los efectos secundarios en los bordes:


public class DiscountService {
    private final CustomerRepository repository;
    private final Logger logger;
    
    public DiscountService(CustomerRepository repository, Logger logger) {
        this.repository = repository;
        this.logger = logger;
    }
    
    public CompletableFuture<BigDecimal> processDiscount(int customerId, BigDecimal amount) {
        return repository
        .getCustomerAsync(customerId)
        .thenApply(customer -> {
            // ❗ Efecto secundario (logging) en el shell
            logger.log("Calculating discount for customer " + customerId);
            Optional<Customer> maybeCustomer = this.repository.getCustomer(customerId);


            return maybeCustomer.ifPresentOrElse(
                customer -> {
                    return DiscountsModule.calculateDiscount.apply(new DiscountRequest(customer, amount));

                    },
                () -> {
                    return BigDecimal.ZERO;
                }
            );
        });
    }
}

Jessica Kerr define este aislamiento funcional asegurando que:

Una función tiene aislamiento cuando la única información que tiene sobre el mundo externo es aquella que se le pasa explícitamente como argumento.

Este aislamiento permite escribir funciones puras, que son un subconjunto especial de funciones con aislamiento, donde además no existen efectos secundarios.


Beneficios de mantener la lógica de dominio pura

Scott Wlaschin identifica beneficios claros de códigos puros en la lógica de dominio:

Además, separar validaciones del dominio evita programación defensiva y hace que el dominio trabaje siempre con datos válidos, según Wlaschin:

Las validaciones deben ocurrir en la frontera de los procesos IO y Core (dominio), antes de que la lógica de negocio actúe.

Si al inicio de un proceso se obtiene datos inválidos se hace un bypass inmediato, evitando que la lógica de negocio tenga que lidiar con casos erróneos.


Arquitectura que surge naturalmente: Functional Core, Imperative Shell

Una arquitectura que adopta estos principios es el patrón Functional Core, Imperative Shell, que es un refinamiento de la arquitectura hexagonal o de puertos y adaptadores.

Estructura de la arquitectura

La arquitectura se divide en dos grandes capas:

1. Núcleo funcional (Functional Core):

2. Cáscara imperativa (Imperative Shell):

Flujo típico:

Este enfoque garantiza que la lógica de negocio permanezca aislada y libre de efectos secundarios, mientras que toda la infraestructura y el IO se gestionan en los bordes del sistema. De manera visual, se puede representar como un sandwich:

IO | CORE | IO

pero, y si tengo que hacer algo de IO en medio por alguna razón? Pues simplemente haz más capas de sandwich:

IO | CORE | IO | CORE | IO

Lo importante es que el core (dominio) siempre esté puro y aislado. Esta es la directiva principal de esta arquitectura y de mantener puro el dominio.

Ejemplo práctico de la arquitectura

Imaginemos un sistema de cálculo de precios con descuentos:

🔵 Functional Core (Dominio puro):

// Tipos del dominio 
public record ProductId(String value) {}

public record Money(BigDecimal value) {
    public static final Money ZERO = new Money(BigDecimal.ZERO);
}

public record Product(ProductId id, Money basePrice) {}

// Operaciones del dominio
public final class ProductModule {
    public static Function<List<Product>, Money> calculateTotalPrice = products -> {
        return products.stream()
            .map(Product::basePrice)
            .reduce(new Money(BigDecimal.ZERO), (a, b) -> new Money(a.value().add(b.value())));
    }
}

🟡 Ports (Puertos):

// Funciones que representan queries externas (puertos)
public class Ports {

    public interface GetProducts extends Function<ProductId, List<Product>> {}

}

🔴 Adapters (Adaptadores):

public class Adapters {
    // ⚠️ Se ha puesto el nombre de tecnología subyacente para mayor claridad para el ejemplo

    public static GetProducts getProductsFromMySQL = productId -> {
        // Lógica para obtener productos desde la base de datos MySQL
    };
}

🟠 Application Service como endpoint Spring Boot:

@RestController
@RequestMapping("/products")
public class CalculateTotalPriceController {
    private final GetProducts getProducts;

    public CalculateTotalPriceController(GetProducts getProducts) {
        this.getProducts = getProducts;
    }

    @PostMapping("/calculate-total")
    public ResponseEntity<Money> calculateTotal(@RequestBody List<String> productIds) {
        try {
            //IO
            List<ProductId> ids = productIds
                .stream()
                .map(ProductId::new)
                .toList();
            List<Product> products = getProducts.apply(ids);
            
            //Core
            Money total = products.isEmpty()
                ? Money.ZERO
                : ProductModule.calculateTotalPrice.apply(products);
            
            //IO
            return ResponseEntity.ok(total);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Money.ZERO);
        }
    }
}

Mark Seemann explica que al escribir funciones puras para el dominio y relegar las impurezas a los bordes, se debe cumplir que:

“Puedes llamar funciones puras desde funciones impuras, pero no al revés.”

Que es otra forma de decir lo que comunmente tratamos de cumplir con la arquitectura hexagonal de que el dominio no debe depender de infraestructura. Pero en este caso de funciones puras, se cumple naturalmente tal necesidad y no es una convención. Lenguajes como Haskell incluso obligan esto por sistema de tipos, dando errores en tiempo de compilación si se incumple (necesidad de tratar con la Monada IO). En definitiva esto significa que, si la función de dominio depende internamente de funciones impuras, la pureza y la arquitectura limpia se rompen.

Este enfoque asegura y simplifica la lógica de dominio pues no necesita preocuparse por asincronismo, excepciones o detalles de infraestructura, facilitando un desarrollo incremental, aislado y testeable.


Los tests según esta división

Con esta separación clara podemos establecer una estrategia de testing efectiva:

La estrategia de testing se divide en dos grandes tipos:

Tests de integración:
Prueban flujos completos del sistema utilizando dependencias reales, como endpoints HTTP, transacciones de base de datos, llamadas a servicios externos, envío de emails… Estos tests validan que los componentes colaboran correctamente y que la integración con el entorno funciona como se espera.

Tests unitarios:
Evalúan la lógica pura del dominio en completo aislamiento. Se centran en reglas de negocio, cálculos, validaciones y modelos de dominio. No requieren mocks ni dobles complejos, se ejecutan rápidamente y son deterministas, ya que dependen únicamente de los datos de entrada.

Esta separación permite asegurar que la lógica de negocio se mantiene limpia y testeable, mientras que la integración con el entorno se valida por separado.

Ejemplo de tests unitarios para el dominio puro

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;

class CalculateTotalPriceShould {

    @Test
    void calculateZeroMoneyWhenNoProductsArePassed() {
        List<Product> products = List.of();

        Money total = ProductModule.calculateTotalPrice(products);

        assertEquals(Money.ZERO, total);
    }

    @Test
    void calculateSumOfAllProductPrices() {
        List<Product> products = List.of(
            new Product(new ProductId("1"), new Money(new BigDecimal("100.00"))),
            new Product(new ProductId("2"), new Money(new BigDecimal("50.00"))),
            new Product(new ProductId("3"), new Money(new BigDecimal("25.00")))
        );

        Money total = ProductModule.calculateTotalPrice(products);

        assertEquals(new Money(new BigDecimal("175.00")), total);
    }

}

Ejemplo de test de integración

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;

@SpringBootTest
@TestPropertySource(locations = "classpath:application-test.properties")
class CalculateTotalPriceControllerTests {

    @BeforeEach
    void setUp() {
        // Configurar base de datos en memoria, servidor mock, etc.
    }

    @AfterEach
    void tearDown() {
        // Limpiar estado, cerrar conexiones, etc.
    }

    @Test
    void calculateTotalPriceEndpointShouldReturnCorrectSum() {
        
        // más fácil de realizar con Spring Boot, en otros casos utiliza herramientas como Playwright mientras tu aplicación está en ejecución)
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
            new CalculateTotalPriceController(Adapters.getProductsFromMySQL)
        ).build();

        var response = mockMvc.perform(post("/products/calculate-total")
            .contentType(MediaType.APPLICATION_JSON)
            .content("[\"1\", \"2\", \"3\"]"));

        response.andExpect(status().isOk())
                .andExpect(content().json("{\"value\":175.00}"));
    }

}

Con esta división clara:

De esta manera, no es necesario usar dobles y mocks complejos para validar la lógica del dominio, pues es pura y determinista.


Señales claras de incumplimiento

¿Cómo sabemos si estamos rompiendo este principio?

❌ El dominio depende de funciones o servicios que realizan IO, estados globales o variables implícitas.

❌ Se introducen efectos secundarios, procesos asíncronos o manejo de excepciones propias del IO dentro de la lógica de negocio.

❌ Es necesario simular o mockear dependencias externas complejas para testear la lógica básica del dominio.

❌ Se tiene una función que pretende ser pura pero llama internamente a funciones impuras, violando el patrón de llamada propuesto por Seemann.

Cuando esto ocurre, el diseño comienza a degradarse y aparecen problemas como código difícil de mantener.


Orientando el principio a objetos

De momento hemos visto como aplicar programación funcional con Java pero realmente podemos seguir aplicando este principio con los patrones de POO. Michel Feathers, incluso comenta lo siguiente:

“O.O.P. Looks Like Functional Programming When Done Right” Michael Feathers On OOP vs FP (Modern Software Engineering)

Feathers explica que esta semejanza no es literal, sino que se refiere al efecto que se logra en la práctica. La clave radica en el concepto de la inyección de dependencias, una de las principales técnicas en la POO, que al final no deja de ser una parametrización de lo que necesita (dependencias) el objeto para operar quedando explicito.

La relación con TDD

Feathers también destaca que el Desarrollo Guiado por Pruebas (TDD) empuja naturalmente a los desarrolladores hacia este tipo de diseño:

El ciclo de TDD (Test Driven Development) impulsa la pureza del dominio y la explicitud de dependencias de la siguiente manera:


🔴 Rojo:

Escribe un test que falla.

Esto obliga a explicitar las dependencias, revela acoplamientos ocultos y exige interfaces claras.

🟢 Verde:

Haz que el test pase.

Implementa la solución mínima, inyecta dependencias de forma explícita y separa responsabilidades de manera natural.

🔵 Refactoriza:

Mejora el diseño.

Extrae funciones puras, elimina duplicaciones y potencia la composibilidad.


Este ciclo se repite continuamente, guiando el diseño hacia una arquitectura más limpia y un dominio más puro.

Al escribir pruebas, los programadores se ven obligados a hacer explícitas las dependencias y a crear un diseño donde la modularidad y la claridad son prioritarias. Esto ayuda a evitar diseños donde la complejidad está oculta y los componentes son difíciles de probar.

En resumen, podemos extraer las siguientes conclusiones sobre cómo la POO puede lograr un dominio puro similar al de la programación funcional:

Así, el dominio puro puede implementarse y sostenerse también en POO si se aplican principios sólidos de diseño, como la inyección de dependencias, separación de responsabilidades y configuración explícita. Pero ¿No parece que es más difícil mantener la disciplina en POO? ¿No son más reglas y patrones que aprender y aplicar?


El reto de mantener el principio con POO

Mark Seemann señala que aplicar arquitecturas limpias y patrones de inyección de dependencias en POO conlleva un esfuerzo significativo para:

Además, combina la encapsulación típica de la POO con la exigencia de aislamiento para el testing puede provocar lo que Mark Seemann denomina test‑induced damage. En esencia, ocurre cuando las pruebas empujan a los desarrolladores a abrir o cambiar la estructura interna del código —por ejemplo añadiendo getters, exponiendo estados, o introduciendo seams incómodos— únicamente para que el código sea testeable. El resultado es una erosión gradual de la encapsulación: el diseño se modifica no por necesidad del dominio, sino para satisfacer las pruebas, lo que genera código más acoplado y menos resistente al cambio.

Los patrones comunes y cómo mitigarlos en la práctica:

En otras palabras: no dejes que las pruebas dicten la estructura interna, haz que las pruebas consuman la estructura bien definida (mínima y expresiva) que el dominio necesita.

En contraste, la programación funcional reduce estos riesgos porque facilita naturalmente la separación entre core puro y shell impuro: cuando la lógica de negocio se implementa como funciones puras con entradas y salidas explícitas, las pruebas pueden cubrir el dominio sin requerir cambios que rompan la encapsulación.


Checklist para mantener puro el dominio

Aquí tienes una guía práctica resumida para implementar y mantener un dominio puro programes en el pardigma funcional o de objetos:

✅ Todo el IO va en los bordes del sistema.

✅ Las funciones/métodos del dominio son deterministas.

✅ Todas las dependencias son explícitas y pasan como parámetros (inyección de dependencias en objetos de abstracciones).

✅ Las validaciones ocurren en la frontera, antes de entrar al dominio.

✅ Usa inmutabilidad siempre que sea posible.

✅ Los tests del dominio se pueden escribir sin mocks ni dobles complejos.


Conclusiones

Mantener puro el dominio no es solo un consejo abstracto, es un principio práctico que da forma a arquitecturas que son fáciles de entender, extender y mantener.

Este principio, aunque es natural en la programación funcional al escribir funciones puras, es igualmente aplicable en la programación orientada a objetos si se diseña cuidadosamente la explicitud y manejo de dependencias, tal y como apunta Michel Feathers.

Detectar y corregir violaciones al principio de dominio puro evita deudas técnicas accidentales, facilita una mayor testabilidad y permite que los sistemas escalen con calidad.

En definitiva, mantener puro el dominio es la llave maestra que nos garantiza que la complejidad inherente al software mantenga control y orden a lo largo del tiempo.


Referencias (Junto a mis notas)