DEV Community

Cover image for 🧹 Improve Filtering with the Predicate Interface!
Oleksandr Yasinskyi
Oleksandr Yasinskyi

Posted on

🧹 Improve Filtering with the Predicate Interface!

Working with lists in apps often means filtering data before showing it to users.

But, we know that filters can sometimes create complex and tricky code to maintain 😵‍💫

Today, we’ll dive into a cleaner, more reusable approach using the Predicate interface 🚀


📦 Let's imagine we're working on a fun app to help keep track of all our products in storage. We’ve got a Product model.

data class Product(val id: Int, val name: String, val price: Int, val inStock: Boolean, val weight: Int, val category: String)
Enter fullscreen mode Exit fullscreen mode

🛑 The Problems with Traditional Filtering

Take a look at this filtering example:

fun filter(
    products: List<Product>,
    name: String?,
    price: String?,
    inStock: Boolean?,
    weight: Int?,
    category: String?
): List<Product> {
    return products
        .asSequence()
        .filter { product ->
            name?.let { product.name.contains(it, ignoreCase = true) } ?: true
        }
        .filter { product ->
            price?.let { product.price == it.toInt() } ?: true
        }
        .filter { product ->
            inStock?.let { product.inStock == it } ?: true
        }
        .filter { product ->
            weight?.let { product.weight == it } ?: true
        }
        .filter { product ->
            category?.let { product.category == it } ?: true
        }
        .toList()
}
Enter fullscreen mode Exit fullscreen mode

🚩 Problems with this strategy:

  1. Code is hard to maintain:
    • Adding new conditions requires modifying existing code
  2. Poor readability:
    • Multiple filters that are difficult to understand and extend.
  3. Code is hard to reuse:
    • We have to duplicate the code if we want to use it in another context.

✅ Improving Validation with the Predicate Interface

The Predicate interface from java.util.function is here to save the day! It allows us to encapsulate individual validation rules and chain them together easily.

@FunctionalInterface
public interface Predicate<T> {

    boolean test(T t);

    default Predicate<T> and(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) && other.test(t);
    }

    default Predicate<T> negate() {
        return (t) -> !test(t);
    }

    default Predicate<T> or(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) || other.test(t);
    }

    static <T> Predicate<T> isEqual(Object targetRef) {
        return (null == targetRef)
        ? Objects::isNull
        : object -> targetRef.equals(object);
    }

    static <T> Predicate<T> not(Predicate<? super T> target) {
        Objects.requireNonNull(target);
        return (Predicate<T>)target.negate();
    }
}
Enter fullscreen mode Exit fullscreen mode

⛓️ What is the Chain of Responsibility Pattern?

The Chain of Responsibility pattern allows passing requests along a chain of handlers. Each handler decides whether to process the request or pass it to the next handler.

✨ Predicate in Action

Now, we will create predicates for each field of the Product model, such as category, weight, inStock, and so on. These predicates will check if values meet the given conditions. Each of these predicates will implement the Predicate<Product> interface, and we can combine them using the and, or, and negate methods.


class ProductWeightPredicate(private val weight: Int) : Predicate<Product> {
    override fun test(product: Product): Boolean {
        return product.weight == weight
    }
}

class ProductCategoryPredicate(private val category: String) : Predicate<Product> {
    override fun test(product: Product): Boolean {
        return product.category == category
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we can dynamically combine these predicates. For example:


private val predicates: MutableSet<Predicate<Product>> = mutableSetOf()


fun onWeightChanged(weight: Int) {
    predicates.add(ProductWeightPredicate(weight))
}

fun onCategoryChanged(category: String) {
    predicates.add(ProductCategoryPredicate(category))
}

fun onFilterClicked(products: List<Product>): List<Product> {
    val predicate = predicates.reduce { acc, predicate -> acc.and(predicate) }
    return products.filter { predicate.test(it) }
}
Enter fullscreen mode Exit fullscreen mode

🔍 Searching by Category or Name

We can also combine predicates into different chains. For example, filtering by category or product name when searching:

class ProductNamePredicate(private val name: String) : Predicate<Product> {
    override fun test(product: Product): Boolean {
        return product.name.contains(name, ignoreCase = true)
    }
}
Enter fullscreen mode Exit fullscreen mode
fun filterByCategoryOrName(
    products: List<Product>,
    query: String,
): List<Product> {
    val predicate = ProductNamePredicate(query).or(ProductCategoryPredicate(query))
    return products.filter { predicate.test(it) }
}
Enter fullscreen mode Exit fullscreen mode

🎉 Benefits of This Approach

  1. Flexibility: We can add new filtering rules without modifying existing code. Each new condition is a new predicate.
  2. Reusability: Predicates can be reused in different contexts, greatly simplifying code maintenance.
  3. Clean Code: Your filtering logic becomes easier to read, extend, and maintain.

Happy coding! ⭐

Top comments (0)