🧠 El problema de fondo
Cuando trabajas con filtros en Java, es común empezar de forma inocente. Tienes una clase de productos, y necesitas filtrar por color, por tamaño, y tal vez por ambos. Así que creas métodos como estos:
class ProductFilter {
public Stream<Product> filterByColor(List<Product> products, Color color) {
return products.stream().filter(p -> p.color == color);
}
public Stream<Product> filterBySize(List<Product> products, Size size) {
return products.stream().filter(p -> p.size == size);
}
public Stream<Product> filterBySizeAndColor(List<Product> products, Size size, Color color){
return products.stream().filter(p -> p.size == size && p.color == color);
}
}
Pero el problema viene después. ¿Qué pasa si mañana te piden filtrar por peso? ¿Y pasado mañana por categoría, stock o precio?
Cada nuevo criterio implica modificar la clase ProductFilter
. Y si ya tienes combinaciones como “color y tamaño”, ahora te piden “color, tamaño y peso”, y después “color o tamaño y categoría, pero no en oferta”…
Tu clase de filtros se vuelve una bomba de tiempo. Y no solo por la cantidad de código duplicado, sino porque violas uno de los principios fundamentales de diseño: Open/Closed Principle.
¿Qué es el Open/Closed Principle?
El Principio , parte de los principios SOLID, dice lo siguiente:
“Una entidad de software debe estar abierta para extensión, pero cerrada para modificación.”
Esto significa que deberías poder agregar nueva funcionalidad sin tener que modificar el código existente. En otras palabras, tu clase debería poder crecer sin ser editada directamente. ¿Por qué? Porque cada vez que la modificas, puedes romper lo que ya funcionaba.
¿Cómo evitamos eso? Usando el Patrón Specification
El patrón Specification nos permite encapsular condiciones en objetos. En vez de tener lógica metida en if
s o métodos con combinaciones, defines clases que expresan claramente una condición, como por ejemplo “el producto es verde” o “el producto es grande”.
Y lo mejor es que estas condiciones se pueden componer, es decir, combinar varias para formar reglas complejas sin necesidad de escribir nuevos métodos.
Veamos un ejemplo práctico
Supón que tienes estas definiciones básicas:
enum Color {
RED, GREEN, BLUE
}
enum Size {
SMALL, MEDIUM, LARGE
}
class Product {
public String name;
public Color color;
public Size size;
public Product(String name, Color color, Size size) {
this.name = name;
this.color = color;
this.size = size;
}
}
Ahora, el filtro clásico sería algo como esto:
class ProductFilter {
public Stream<Product> filterByColor(List<Product> products, Color color) {
return products.stream().filter(p -> p.color == color);
}
public Stream<Product> filterBySize(List<Product> products, Size size) {
return products.stream().filter(p -> p.size == size);
}
public Stream<Product> filterBySizeAndColor(List<Product> products, Size size, Color color) {
return products.stream().filter(p -> p.size == size && p.color == color);
}
}
El problema es evidente: si quieres combinar más criterios, tienes que modificar esta clase. No está cerrada para modificación.
Refactor con Specification Pattern
Creamos una interfaz genérica para representar condiciones:
@FunctionalInterface
interface Specification<T> {
boolean isSatisfied(T item);
}
Y un filtro que use esa interfaz:
interface Filter<T> {
Stream<T> filter(List<T> items, Specification<T> spec);
}
Luego, creamos especificaciones concretas:
class ColorSpecification implements Specification<Product> {
private final Color color;
public ColorSpecification(Color color) {
this.color = color;
}
public boolean isSatisfied(Product p) {
return p.color == color;
}
}
class SizeSpecification implements Specification<Product> {
private final Size size;
public SizeSpecification(Size size) {
this.size = size;
}
public boolean isSatisfied(Product p) {
return p.size == size;
}
}
Y también podemos combinarlas para crear especificaciones AND, OR, NOT:
class AndSpecification<T> implements Specification<T> {
private final Specification<T> first, second;
public AndSpecification(Specification<T> first, Specification<T> second) {
this.first = first;
this.second = second;
}
public boolean isSatisfied(T item) {
return first.isSatisfied(item) && second.isSatisfied(item);
}
}
Finalmente, el filtro genérico:
class BetterFilter implements Filter<Product> {
public Stream<Product> filter(List<Product> items, Specification<Product> spec) {
return items.stream().filter(p -> spec.isSatisfied(p));
}
}
¿Y cómo se usa todo esto?
Supongamos que tienes tus productos:
Product apple = new Product("Apple", Color.GREEN, Size.SMALL);
Product tree = new Product("Tree", Color.GREEN, Size.LARGE);
Product house = new Product("House", Color.BLUE, Size.LARGE);
List<Product> products = List.of(apple, tree, house);
Ahora puedes filtrar así:
BetterFilter bf = new BetterFilter();
bf.filter(products, new ColorSpecification(Color.GREEN))
.forEach(p -> System.out.println(p.name + " is green"));
bf.filter(products, new AndSpecification<>(
new ColorSpecification(Color.BLUE),
new SizeSpecification(Size.LARGE)
)).forEach(p -> System.out.println(p.name + " is large and blue"));
Y si mañana necesitas filtrar productos que no sean rojos, puedes escribir una nueva especificación NotSpecification
.
¿Y si tengo muchas condiciones?
Puedes crear una clase de utilidad para componer varias:
class Specs {
public static <T> Specification<T> and(Specification<T>... specs) {
return item -> Arrays.stream(specs).allMatch(s -> s.isSatisfied(item));
}
public static <T> Specification<T> not(Specification<T> spec) {
return item -> !spec.isSatisfied(item);
}
}
Lo podemos usar asi:
Specification<Product> greenAndLargeButNotRed = Specs.and(
new ColorSpecification(Color.GREEN),
new SizeSpecification(Size.LARGE),
Specs.not(new ColorSpecification(Color.RED))
);
bf.filter(products, greenAndLargeButNotRed)
.forEach(p -> System.out.println("MATCH: " + p.name));
Conclusión

Este enfoque te da una arquitectura sólida que escala sin explotar. Puedes agregar nuevas reglas de negocio sin tocar las clases ya existentes. Cumples el Principio de Abierto/Cerrado, tu sistema se vuelve más fácil de mantener, y tu código es más expresivo.
Y eso, en un sistema real con múltiples condiciones y reglas cambiantes, marca la diferencia entre sobrevivir y tener que reescribir todo cada trimestre.
Te Dejamos el link con el repositorio: Github Repository