Suppose I want to abstract operations on a collection for different reasons:
Now for the sake of simplicity, let's reason on a collection of
class Book {
public string Title { get; set; };
public string SubTitle { get; set; }
public bool IsSold { get; set; }
public DateTime SoldDate { get; set; }
public int Volums { get; set; }
}
I've a type that need to search only the Book::Title
(case sensitive or not) so I can defined my abstraction:
interface ITitleSearcher {
bool ContainsTitle(string title);
}
and then implement
class CaseSensitiveTitleSearcher : ITitleSearcher { ... }
class NoCaseSensitiveTitleSearcher : ITitleSearcher { ... }
and consume it as
class TitleSearcherConsumer {
public TitleSearcherConsumer(ITitleSearcher searcher) { // <- ctor injection
}
}
Until here all is clear to me and for what I understand also the Interface Segregation Principle is complied.
Going on with development I've to satisfy other requirements, so I define and then implement other interface like ITitleSearcher
e.g.:
class CaseSensitiveSubTitleSearcher : ISubTitleSearcher { ... }
class SoldWithDateRangeSearcher : ISoldDateRangeSearcher { ... }
To not violating DRY (don't repeat yourself) I can create a wrapper around IEnumerable<Book>
:
class BookCollection : ITitleSearcher, ISubTitleSearcher, ISoldDateRangeSearcher
{
private readonly IEnumerable<Book> books;
public BookCollection(IEnumerable<Book> books)
{
this.books = books;
}
//...
}
Now if I've a consumer like TitleSearcherConsumer
I can pass without problems an instance of BookCollection
.
But if I've a consumer like this:
class TitleAndSoldSearcherConsumer {
public TitleAndSoldSearcherConsumer(ITitleSearcher src1, ISoldDateRangeSearcher src2) {
}
}
I can't inject a BookCollection
instance into TitleAndSoldSearcherConsumer
ctor; I've to pass the implementation of each interface.
Yes, I can define an IBookCollection
with all methods of the other interfaces and use it in all consumers, but doing so doesn't violate ISP?
Can I stay close to ISP/SOLID and DRY at the same time?
Yes, I can define an IBookCollection with all methods of the other interfaces and use it in all consumers, but doing so doesn't violate ISP?
You won't be violating the ISP, but your book collection will start to have too many responsibilities and you will be violating the Single Responsibility Principle.
Another thing that worries me is those multiple implementations of the ITitleSearcher
interface. I'm not sure if there is a violation of some design principle here, but there seems to be some ambiguity in your design that you probably should look at. Besides, for every search operation you are creating a new abstraction. You already have ITitleSearcher
, ISubTitleSearcher
, andISoldDateRangeSearcher
and are likely to add dozens more. What I think you are missing here is a general abstraction over queries in the system. So here is what you can do:
Define an abstraction for query parameters:
public interface IQuery<TResult> { }
This is an interface without members with a single generic type TResult
. The TResult
describes the return type of that query. For instance, you can define a query as follows:
public class SearchBooksByTitleCaseInsensitiveQuery : IQuery<Book[]>
{
public string Title;
}
This is the definition of a query that takes in a Title
and returns Book[]
.
What you will also need is an abstraction over classes that know how to handle a particular query:
public interface IQueryHandler<TQuery, TResult>
where TQuery : IQuery<TResult>
{
TResult Handle(TQuery query);
}
See how the method takes in a TQuery
and returns a TResult
? An implementation could look like this:
public class SearchBooksByTitleCaseInsensitiveQueryHandler :
IQueryHandler<SearchBooksByTitleCaseInsensitiveQuery, Book[]>
{
private readonly IRepository<Book> bookRepository;
public SearchBooksByTitleCaseInsensitiveQueryHandler(
IRepository<Book> bookRepository) {
this.bookRepository = bookRepository;
}
public Book[] Handle(SearchBooksByTitleCaseInsensitiveQuery query) {
return (
from book in this.bookRepository.GetAll()
where book.Title.StartsWith(query.Title)
select book)
.ToArray();
}
}
Now consumers can depend on a specific IQueryHandler<TQuery, TResult>
implementation like this:
class TitleSearcherConsumer {
IQueryHandler<SearchBooksByTitleCaseInsensitiveQuery, Book[]> query;
public TitleSearcherConsumer(
IQueryHandler<SearchBooksByTitleCaseInsensitiveQuery, Book[]> query) {
}
public void SomeOperation() {
this.query.Handle(new SearchBooksByTitleCaseInsensitiveQuery
{
Title = "Dependency Injection in .NET"
});
}
}
And what does this exactly bring me?
IQueryHandler<TQuery, TResult>
query we defined a general abstraction over a very common pattern (querying) in the system.IQueryHandler<TQuery, TResult>
defines a single member and adheres to the ISP.IQueryHandler<TQuery, TResult>
implementations implement a single query and adhere to the SRP.IQuery<TResult>
interface allows us to have compile-time support over the queries and their results. Consumers can't incorrectly depend on a handler with an incorrect return type, since that won't compile.IQueryHandler<TQuery, TResult>
abstraction allows us to apply all sorts of cross-cutting concerns to query handlers, without having to change any implementation.Especially this last point is an important one. Cross-cutting concerns such as validation, authorization, logging, audit trailing, monitoring, and caching, can all be implemented very easily using decorators, without a need to change both the handler implementations AND the consumers. Take a look at this:
public class ValidationQueryHandlerDecorator<TQuery, TResult>
: IQueryHandler<TQuery, TResult>
where TQuery : IQuery<TResult>
{
private readonly IServiceProvider provider;
private readonly IQueryHandler<TQuery, TResult> decorated;
public ValidationQueryHandlerDecorator(
Container container,
IQueryHandler<TQuery, TResult> decorated)
{
this.provider = container;
this.decorated = decorated;
}
public TResult Handle(TQuery query)
{
var validationContext =
new ValidationContext(query, this.provider, null);
Validator.ValidateObject(query, validationContext);
return this.decorated.Handle(query);
}
}
This is a decorator that can be wrapped around all command handler implementations at runtime that adds the ability for it to be validated.
For more background information, take a look at this article: Meanwhile... on the query side of my architecture.