- Published on
Mastering the Specification Pattern in .NET Core
- Authors
- Name
Introduction
In the world of software development, designing maintainable and flexible code is essential. One commonly used design pattern that helps achieve this goal is the Specification Pattern. The Specification Pattern allows developers to encapsulate complex business rules and conditions into reusable, composable objects. In this blog post, we will explore the Specification Pattern in the context of .NET Core and see how it can help improve the clarity and maintainability of your code.
What is the Specification Pattern?
The Specification Pattern is a behavioral design pattern that is used to define a set of criteria or conditions that an object must satisfy. It provides a way to separate the business logic that defines these conditions from the code that uses them. This separation of concerns makes the code more modular and easier to maintain.
In essence, a specification is an object that can answer the question, “Does this object meet a certain set of criteria?” By encapsulating these criteria in a specification, you can make your code more flexible and adaptable to changes in your business rules.
Implementing the Specification Pattern in .NET Core
Let’s dive into how we can implement the Specification Pattern in .NET Core. We’ll break it down into several steps:
- Define the Specification Interface
The first step is to define an interface that represents the Specification. This interface typically includes a single method, IsSatisfiedBy, which takes an object and returns a boolean value indicating whether the object meets the specified criteria.
public interface ISpecification<T>
{
bool IsSatisfiedBy(T item);
}
- Create Concrete Specifications
Next, you’ll create concrete classes that implement the ISpecification interface. These classes will encapsulate specific business rules or conditions.
public class CustomerIsPremiumSpecification : ISpecification<Customer>
{
public bool IsSatisfiedBy(Customer customer)
{
return customer.IsPremium;
}
}
public class OrderIsOver100Specification : ISpecification<Order>
{
public bool IsSatisfiedBy(Order order)
{
return order.TotalAmount > 100;
}
}
- Combine Specifications
One of the strengths of the Specification Pattern is the ability to combine multiple specifications using logical operators (AND, OR, NOT). You can create composite specifications to represent complex conditions.
public class AndSpecification<T> : ISpecification<T>
{
private readonly ISpecification<T> _left;
private readonly ISpecification<T> _right;
public AndSpecification(ISpecification<T> left, ISpecification<T> right)
{
_left = left;
_right = right;
}
public bool IsSatisfiedBy(T item)
{
return _left.IsSatisfiedBy(item) && _right.IsSatisfiedBy(item);
}
}
- Applying Specifications
Now that you have defined your specifications, you can use them to filter and query objects in your application. Here's an example of how you might use specifications to filter a list of customers
var premiumCustomerSpecification = new CustomerIsPremiumSpecification();
var over100OrderSpecification = new OrderIsOver100Specification();
var premiumAndOver100 = new AndSpecification<Customer>(
premiumCustomerSpecification,
over100OrderSpecification
);
var filteredCustomers = customers.Where(premiumAndOver100.IsSatisfiedBy);
Benefits of Using the Specification Pattern
- Separation of Concerns: The Specification Pattern allows you to separate business rules from the code that uses them, promoting cleaner and more maintainable code.
- Reusability: Specifications are reusable, so you can use the same conditions in multiple parts of your application without duplicating code.
- Composability: You can easily combine and compose specifications to create complex conditions, providing flexibility in querying your data.
- Testability: Because specifications are isolated units of code, they are easier to test in isolation, improving the overall testability of your application.
Implementation of Specification Pattern from Microservices example:
Controller Code:
[HttpGet]
[Route("GetAllProducts")]
[ProducesResponseType(typeof(IList<ProductResponse>), (int)HttpStatusCode.OK)]
public async Task<ActionResult<IList<ProductResponse>>> GetAllProducts([FromQuery]CatalogSpecParams catalogSpecParams)
{
try
{
var query = new GetAllProductsQuery(catalogSpecParams);
var result = await _mediator.Send(query);
_logger.LogInformation("All products retrieved");
return Ok(result);
}
catch (Exception e)
{
_logger.LogError(e, "An Exception has occured: {Exception}");
throw;
}
}
Here, controller mediator pattern first will come in picture which will invoke the GetAllProductsQuery method like shown below.
using Catalog.Application.Responses;
using Catalog.Core.Specs;
using MediatR;
namespace Catalog.Application.Queries;public class GetAllProductsQuery : IRequest<Pagination<ProductResponse>>
{
public CatalogSpecParams CatalogSpecParams { get; set; } public GetAllProductsQuery(CatalogSpecParams catalogSpecParams)
{
CatalogSpecParams = catalogSpecParams;
}
}
Which will be handled by its handler like
using Catalog.Application.Mappers;
using Catalog.Application.Queries;
using Catalog.Application.Responses;
using Catalog.Core.Repositories;
using Catalog.Core.Specs;
using MediatR;
using Microsoft.Extensions.Logging;
namespace Catalog.Application.Handlers;public class GetAllProductsHandler: IRequestHandler<GetAllProductsQuery, Pagination<ProductResponse>>
{
private readonly IProductRepository _productRepository;
private readonly ILogger<GetAllProductsHandler> _logger; public GetAllProductsHandler(IProductRepository productRepository, ILogger<GetAllProductsHandler> logger)
{
_productRepository = productRepository;
_logger = logger;
}
public async Task<Pagination<ProductResponse>> Handle(GetAllProductsQuery request, CancellationToken cancellationToken)
{
var productList = await _productRepository.GetProducts(request.CatalogSpecParams);
var productResponseList = ProductMapper.Mapper.Map<Pagination<ProductResponse>>(productList);
_logger.LogDebug("Received Product List.Total Count: {productList}", productResponseList.Count);
return productResponseList;
}
}
This code is taking the specifications which is getting addressed by the repository layer beneath like
public async Task<Pagination<Product>> GetProducts(CatalogSpecParams catalogSpecParams)
{
var builder = Builders<Product>.Filter;
var filter = builder.Empty;
if(!string.IsNullOrEmpty(catalogSpecParams.Search))
{
var searchFilter = builder.Regex(x => x.Name, new BsonRegularExpression(catalogSpecParams.Search));
filter &= searchFilter;
}
if(!string.IsNullOrEmpty(catalogSpecParams.BrandId))
{
var brandFilter = builder.Eq(x => x.Brands.Id,catalogSpecParams.BrandId);
filter &= brandFilter;
}
if(!string.IsNullOrEmpty(catalogSpecParams.TypeId))
{
var typeFilter = builder.Eq(x => x.Types.Id, catalogSpecParams.TypeId);
filter &= typeFilter;
}
if (!string.IsNullOrEmpty(catalogSpecParams.Sort))
{
return new Pagination<Product>
{
PageSize = catalogSpecParams.PageSize,
PageIndex = catalogSpecParams.PageIndex,
Data = await DataFilter(catalogSpecParams, filter),
Count = await _context.Products.CountDocumentsAsync(p =>
true) //TODO: Need to check while applying with UI
};
} return new Pagination<Product>
{
PageSize = catalogSpecParams.PageSize,
PageIndex = catalogSpecParams.PageIndex,
Data = await _context
.Products
.Find(filter)
.Sort(Builders<Product>.Sort.Ascending("Name"))
.Skip(catalogSpecParams.PageSize * (catalogSpecParams.PageIndex - 1))
.Limit(catalogSpecParams.PageSize)
.ToListAsync(),
Count = await _context.Products.CountDocumentsAsync(p => true)
};
} private async Task<IReadOnlyList<Product>> DataFilter(CatalogSpecParams catalogSpecParams, FilterDefinition<Product> filter)
{
switch (catalogSpecParams.Sort)
{
case "priceAsc":
return await _context
.Products
.Find(filter)
.Sort(Builders<Product>.Sort.Ascending("Price"))
.Skip(catalogSpecParams.PageSize * (catalogSpecParams.PageIndex - 1))
.Limit(catalogSpecParams.PageSize)
.ToListAsync();
case "priceDesc":
return await _context
.Products
.Find(filter)
.Sort(Builders<Product>.Sort.Descending("Price"))
.Skip(catalogSpecParams.PageSize * (catalogSpecParams.PageIndex - 1))
.Limit(catalogSpecParams.PageSize)
.ToListAsync();
default:
return await _context
.Products
.Find(filter)
.Sort(Builders<Product>.Sort.Ascending("Name"))
.Skip(catalogSpecParams.PageSize * (catalogSpecParams.PageIndex - 1))
.Limit(catalogSpecParams.PageSize)
.ToListAsync();
}
}
Therefore, basic idea is to delegate the complex and generic query to specification pattern. This keeps the code more maintainable and testable.
Github Link:- https://github.com/rahulsahay19/eShopping
In order to understand the complete stuff from scratch, you can also refer my 32 hours comprehensive course on Microservices using clean architecture and .Net Core.
Conclusion
The Specification Pattern is a potent tool in the .NET Core developer’s toolkit for tackling complex business rules and conditions. By encapsulating these rules within reusable specification objects, you can craft cleaner, more maintainable, and highly flexible code. Whether you’re developing web applications, desktop applications, or any other type of software, the Specification Pattern empowers you to build a resilient codebase that readily adapts to evolving requirements. Incorporate this pattern into your development practices to reap the rewards of cleaner and more maintainable code.