Real-World Refactoring: Filtering products using a Builder Pattern
Everyone searches for products at their favorite store on the Internet. Today, I show you how to build a simple product search filter using a builder/fluent pattern.
Disclosure:
I get commissions for purchases made through links in this post.
At this stage in the Internet game, most people know how to shop online. It's becoming second nature for some. If you break down the components of an ecommerce website, one of the most important features on a site is the ability for your users to filter out certain products.
Take Amazon for example. You perform a search and you immediately get a filtering capability on the left side of the page.
How does Amazon do that?
A better question is how do you setup those filters where they are maintainable for you, your future self, and future developers? These filters grow bigger and more complex as more products are introduced into the mix and as users drill down into Amazon's product vault looking for something specific.
It can get pretty ugly, pretty fast!
As fate would have it, I was asked to build something similar for an ecommerce company and implemented this particular technique using a Builder, or fluent, design pattern.
The idea is to build a flexible backend algorithm to allow you and your users an easy way to turn a ton of product options into a smaller subset of products. As the user constructs the filter through your web interface, you are building it for them...through your code. Think of it as saying "Ok, users, here is a haystack. Find the needle." You are providing them a way to whittle down a insurmountable amount of products by providing them with the proper tools to find what they are looking for on your site.
On an ecommerce site, this search method is extremely valuable! Amazon has already proved that. Heck, every ecommerce site has proved it. Why not give the power to the consumer to find what they want.
Ok, enough yapping! Let's get into it.
Search Parameters
One thing every search engine needs are some parameters. At the very least, you need a search term. Here's our basic SearchParameters class.
SearchParameters.cs
public class SearchParameters { public string SearchTerm { get; set; } public SearchParameters() { SearchTerm = String.Empty; } }
Very basic search term, right? They type in the search term and it returns data. Now, let's add some more search criteria to our search class and make a more "meaty" example.
public interface ISearchParameters { string SearchTerm { get; set; } SortCriteria SortBy { get; set; } ProcessorType Processor { get; set; } RamCapacity Ram { get; set; } int ReviewRating { get; set; } double PriceLow { get; set; } double PriceHigh { get; set; } bool NewArrival { get; set; } } public class SearchParameters : ISearchParameters { public string SearchTerm { get; set; } public SortCriteria SortBy { get; set; } public ProcessorType Processor { get; set; } public RamCapacity Ram { get; set; } public int ReviewRating { get; set; } public double PriceLow { get; set; } public double PriceHigh { get; set; } public bool NewArrival { get; set; } public SearchParameters() { // Default values SearchTerm = String.Empty; SortBy = SortCriteria.Relevance; Processor = ProcessorType.None; Ram = RamCapacity.None; ReviewRating = 0; PriceLow = PriceHigh = 0.00; NewArrival = false; } }
I almost forgot...we need the enums to complete this class and show how it's used.
public enum SortCriteria { [Description("Relevance")] Relevance = 0, [Description("Price: Low to High")] PriceLowToHigh = 1, [Description("Price: High to Low")] PriceHighToLow = 2 } public enum RamCapacity { [Description("None")] None = 0, [Description("12 GB & Up")] TwelveGbAndUp = 1, [Description("8 GB")] EightGb = 2, [Description("6 GB")] SixGb = 3, [Description("4 GB")] FourGb = 4, [Description("3 GB & Under")] ThreeGbAndLower = 5 } public enum ProcessorType { [Description("None")] None = 0, [Description("Intel Core i5")] IntelCorei5 = 1, [Description("Intel Core i7")] IntelCorei7 = 2, [Description("Intel Core i3")] IntelCorei3 = 3, [Description("Intel Core 2")] IntelCore2 = 4, [Description("Intel Xeon")] IntelXeon = 5 }
Of course, every company has their own criteria on how to filter down their results. I defer to you to know how to model your product filtering parameters.
What you want to do is provide your own filtering criteria in a class (SearchParameters) so you can pass in parameters and know what is being executed in your database call.
A little sidenote...the reason I added the Description attribute is for your labels on your UI. You can easily make a list of NameValue pair collection or SelectListItems based on the text and integer in the enum. Makes things a lot easier.
Building the Builder
Our SearchBuilder is a simple class where we implement the SearchParameters and create our query that returns the products to the user (more on that later).
Our class should also have the ability to pass, or "inject," the parameters into our SearchBuilder so we can unit test them later.
Therefore, we need a Test Double.
public class SearchBuilder { private ISearchParameters _searchParameters; public SearchBuilder(): this(new SearchParameters()) { } public SearchBuilder(ISearchParameters searchParameters) { _searchParameters = searchParameters; } }
This is just overloading the constructor to include parameters that we can pass in that will modify the behavior of the results returned for our testing.
Simple, but effective.
Add the parameter settings
Since we have our builder class started, we need to add the parameters for each user filter on the web page (UI). For example, if the user wants to sort the price, we call the SortBy method and pass in what they selected.
Let's start adding in those parameters that will create the user filter.
public class SearchBuilder { private ISearchParameters _searchParameters; public SearchBuilder(): this(new SearchParameters()) { } public SearchBuilder(ISearchParameters searchParameters) { _searchParameters = searchParameters; } public SearchBuilder SetSearchTerm(string searchTerm) { _searchParameters.SearchTerm = searchTerm; return this; } public SearchBuilder SortBy(SortCriteria criteria) { _searchParameters.SortBy = criteria; return this; } public SearchBuilder SetProcessor(ProcessorType processor) { _searchParameters.Processor = processor; return this; } public SearchBuilder SetRAM(RamCapacity capacity) { _searchParameters.Ram = capacity; return this; } public SearchBuilder SetReviewRating(int rating) { _searchParameters.ReviewRating = rating; return this; } public SearchBuilder SetLowPrice(double lowPrice) { _searchParameters.PriceLow = lowPrice; return this; } public SearchBuilder SetHighPrice(double highPrice) { _searchParameters.PriceHigh = highPrice; return this; } public SearchBuilder SetNewArrival(bool newArrival) { _searchParameters.NewArrival = newArrival; return this; }
}
If you notice I'm constantly returning "this" after every method. This is what gives us the "fluent" part of the class so you can do "this":
var searchBuilder = new SearchBuilder() .SetSearchTerm("Dell") .SortBy(SortCriteria.PriceHighToLow) .SetRAM(RamCapacity.TwelveGbAndUp) .SetProcessor(ProcessorType.IntelCorei7);
It makes your code almost readable, doesn't it?
"I want to search for a Dell that has 12GB or more of memory and uses an Intel Core i7 and please sort it by Price from High to Low."
Your users are going to love ya! ;-)
Clear for Takeoff?
"Ok, Jonathan, that's great and all, but how do you translate the parameters into the SQL?"
Well, we are missing one last piece of the puzzle to make this work. We need a way to build these parameters into a SQL query.
There are two possible ways to do this: use an ORM or use Dynamic SQL.
While I don't like Dynamic SQL anymore (it CAN be done, but it may get messy), I will defer to the Entity Framework way of creating the query.
The last piece of our puzzle is the Build method (or Run if you want to return results or like that name better). This is the method that pulls everything together and prepares or runs the query for you.
We will also be using the PredicateBuilder used in the book C# 5.0 in a Nutshell (affiliate link). The PredicateBuilder is a class that allows you to create dynamic LINQ predicates and easily pass them into your LINQ statements. The complete source code is on that page and, honestly, it's probably the most amazing and smallest piece of code I've ever seen performing the most complex of tasks in LINQ (The size of it still blows my mind and it's one of my most valuable algorithms in my library today).
As you will see, the PredicateBuilder is the linchpin to making this builder class work.
public Expression<Func<Product, bool>> Build() { var predicate = PredicateBuilder.True<Product>(); // Search Term if (!String.IsNullOrEmpty(_searchParameters.SearchTerm)) { predicate = predicate.AndAlso( e => e.Title.Contains(_searchParameters.SearchTerm)); } // Price if (_searchParameters.PriceHigh > 0 && _searchParameters.PriceLow > 0) { predicate = predicate.AndAlso( e => e.Price >= _searchParameters.PriceLow && e.Price <= _searchParameters.PriceHigh); } // RAM if (!_searchParameters.Ram.Equals(RamCapacity.None)) { predicate = predicate.AndAlso( e => e.RAM.Equals(_searchParameters.Ram)); } // Processor if (!_searchParameters.Processor.Equals(ProcessorType.None)) { predicate = predicate.AndAlso( e => e.Processor.Equals(_searchParameters.Processor)); } // Rating if (_searchParameters.ReviewRating > 0) { predicate = predicate.AndAlso( e => e.Rating >= _searchParameters.ReviewRating); } // either execute the query here... // var records = dbContext.Products.Where(predicate); // Sorting - no test. //switch (_searchParameters.SortBy) //{ // case SortCriteria.PriceHighToLow: // records = records.OrderByDescending(e => e.Price); // break; // case SortCriteria.PriceLowToHigh: // records = records.OrderBy(e => e.Price); // break; //} // or just return the predicate for filtering outside the method. return predicate; }
Notice at the bottom, we can do one of two things:
- Actually execute the predicate and return the records to the calling function, complete with ordering by the user's preference (but that would mean our method is doing waaaaayy too much, or...
- Return the completed predicate to perform a few other tasks before actually calling the product results, which is what I'm doing now. We may need the predicate later.
So now our fluent class at runtime will look like this:
var searchQuery = new SearchBuilder() .SetSearchTerm("Dell") .SortBy(SortCriteria.PriceHighToLow) .SetRAM(RamCapacity.TwelveGbAndUp) .SetProcessor(ProcessorType.IntelCorei7)
.Build();
Also, I created a simple product class just to make this example work with the predicate builder.
public class Product { public string Title { get; set; } public double Price { get; set; } public int Rating { get; set; } public int Processor { get; set; } public int RAM { get; set; } }
Fast Forward: Three years later, I've updated the code to reference a NuGet library thanks to a reader. Here's the updated post.
Conclusion
When you are working with a filtering criteria, placing the controls in the user's hands is simpler than trying to let them write a query and execute on it. Give them the tools to make their own decisions...
...because we haven't created the ESP module yet (at least not that I know of). ;-)
I hope this builder design pattern with a fluent interface helps you out in the future. If you couple this with the Real-World Refactoring: Switch Statement to a Class Hierarchy, your code will become extremely maintainable.
Code on, guys, Code on!
Did I miss something in this Real-World Design Pattern? Post in the comments below so we can get the discussion moving!