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
- Better maintainability of code.
- Better readability.
- Allows you to make complex filters more easily.
- Move code to semantics of our domain.
Thanks for reading me 😊
Top comments (0)