Design pattern | Criteria

Design pattern | Criteria

March 18, 2023

codequality
design-pattern

We start this new series with the pattern called Criteria, This pattern allows you to build search queries using a common interface, which makes the code much more modular and flexible.


Uses cases without Criteria pattern

I have assembled the examples with Typescript but the implementation of said pattern is equally valid for other languages.

Let's see an example where the use of the Criteria pattern can help us.

First of all we are going to create a very simple type to define what is a Client for our domain:

interface Client {
    name: string
    age: number
    gender: 'M' | 'F'
    city: string
}

then suppose we have to perform several filters to the following list of clients:

const clients: Client[] = [
    { name: 'Ana', age: 25, gender: 'F', city: 'Madrid' },
    { name: 'John', age: 15, gender: 'M', city: 'London' },
    { name: 'Marta', age: 14, gender: 'F', city: 'Madrid' },
    { name: 'Luis', age: 30, gender: 'M', city: 'Barcelona' },
]

Clients who are over 15 years old:

const clients: Client[] = [
    { name: 'Ana', age: 25, gender: 'F', city: 'Madrid' },
    { name: 'John', age: 15, gender: 'M', city: 'London' },
    { name: 'Marta', age: 14, gender: 'F', city: 'Madrid' },
    { name: 'Luis', age: 30, gender: 'M', city: 'Barcelona' },
]
 
clients.filter((client) => client.age > 15)
 
/*
[
  { name: 'Ana', age: 25, gender: 'F', city: 'Madrid' },
  { name: 'Luis', age: 30, gender: 'M', city: 'Barcelona' }
];
*/

Now we are going to complicate the query a bit, imagine that we are asked to obtain the clients whose age is older than 20 and younger than 30 years old, who are women and your city is Madrid or Barcelona

const clients: Client[] = [
    { name: 'Ana', age: 25, gender: 'F', city: 'Madrid' },
    { name: 'John', age: 15, gender: 'M', city: 'London' },
    { name: 'Marta', age: 14, gender: 'F', city: 'Madrid' },
    { name: 'Luis', age: 30, gender: 'M', city: 'Barcelona' },
]
 
clients.filter(
    (client) =>
        client.age > 20 &&
        client.age < 30 &&
        client.gender === 'F' &&
        (client.city === 'Madrid' || client.city === 'Barcelona')
)
 
/*
[
  { name: 'Ana', age: 25, gender: 'F', city: 'Madrid' }
];
*/

As we can see, things start to get complicated and it starts to be difficult to maintain and read, In addition, at the semantic level there is a large margin for improvement, for this, we will see how we can improve these aspects by applying the Criteria pattern.


Same complex use case with Criteria pattern

We start by creating the interface that will define our Criteria, we will use typescript generics for it to gain flexibility:

interface Criteria<T> {
    meetCriteria(items: T[]): T[]
}

We continue creating a Composite class that implements our Criteria interface, it will also allow us to maintaining an array of criterias to apply and of course the implementation of the required meetCriteria method upon implementation, the addCriteria method will allow us to add criteria that will function as AND:

class CompositeCriteria<T> implements Criteria<T> {
    private criteriaList: Criteria<T>[] = []
 
    addCriteria(criteria: Criteria<T>): void {
        this.criteriaList.push(criteria)
    }
 
    meetCriteria(items: T[]): T[] {
        let result = items
 
        for (const criteria of this.criteriaList) {
            result = criteria.meetCriteria(result)
        }
 
        return result
    }
}

Finally, as I mentioned above, the addCriteria when adding criteria to a list would work implicitly as an AND therefore we will need to make an implementation to be able to do OR operations:

class OrCriteria<T> implements Criteria<T> {
    constructor(
        private firstCriteria: Criteria<T>,
        private secondCriteria: Criteria<T>
    ) {}
 
    meetCriteria(items: T[]): T[] {
        const firstResult = this.firstCriteria.meetCriteria(items)
        const secondResult = this.secondCriteria.meetCriteria(items)
 
        return Array.from(new Set([...firstResult, ...secondResult]))
    }
}

How to use this new Criteria API

First of all we will create some queries which will help us in the construction of our filter:

const ageOlderThanTwentyYearsCriteria = {
    meetCriteria(items: Client[]): Client[] {
        return items.filter((client) => client.age > 20)
    },
}
 
const ageYoungerThanThirtyYearsCriteria = {
    meetCriteria(items: Client[]): Client[] {
        return items.filter((client) => client.age < 30)
    },
}
 
const madridCityCriteria = {
    meetCriteria(items: Client[]): Client[] {
        return items.filter((client) => client.city === 'Madrid')
    },
}
 
const barcelonaCityCriteria = {
    meetCriteria(items: Client[]): Client[] {
        return items.filter((client) => client.city === 'Barcelona')
    },
}
 
const femaleCriteria = {
    meetCriteria(items: Client[]): Client[] {
        return items.filter((client) => client.gender === 'F')
    },
}

This is where the magic happens since we will be able to mount the filtering to our liking and with much greater semantics, I am sure that if I give you this code you will know very quickly which filter is being built:

const clients: Client[] = [
    { name: 'Ana', age: 25, gender: 'F', city: 'Madrid' },
    { name: 'John', age: 15, gender: 'M', city: 'London' },
    { name: 'Marta', age: 14, gender: 'F', city: 'Madrid' },
    { name: 'Luis', age: 30, gender: 'M', city: 'Barcelona' },
]
 
const compositeCriteria = new CompositeCriteria<Client>()
 
compositeCriteria.addCriteria(ageOlderThanTwentyYearsCriteria)
 
compositeCriteria.addCriteria(ageYoungerThanThirtyYearsCriteria)
 
compositeCriteria.addCriteria(
    new OrCriteria(madridCityCriteria, barcelonaCityCriteria)
)
 
compositeCriteria.addCriteria(femaleCriteria)
 
compositeCriteria.meetCriteria(clients)
 
/* Same result
[
  { name: 'Ana', age: 25, gender: 'F', city: 'Madrid' }
];
*/

Benefits


Thanks for reading me 😊