The goal of my task is to design a small system which can run scheduled recurring tasks. A recurring task is something like "send an email to administrator every hour from 8:00 am to 5:00 pm, Monday through Friday".
I have a base class called RecurringTask.
public abstract class RecurringTask{
// I've already figured out this part
public bool isOccuring(DateTime dateTime){
// implementation
}
// run the task
public abstract void Run(){
}
}
And I have several classes which are inherited from RecurringTask. One of them is called SendEmailTask.
public class SendEmailTask : RecurringTask{
private Email email;
public SendEmailTask(Email email){
this.email = email;
}
public override void Run(){
// need to send out email
}
}
And I have a EmailService which can helps me to send out an email.
The last class is RecurringTaskScheduler, it's responsible for loading tasks from cache or database and run the task.
public class RecurringTaskScheduler{
public void RunTasks(){
// Every minute, load all tasks from cache or database
foreach(RecuringTask task : tasks){
if(task.isOccuring(Datetime.UtcNow)){
task.run();
}
}
}
}
Here is my problem: where should I put EmailService?
Option1: Inject EmailService into SendEmailTask
public class SendEmailTask : RecurringTask{
private Email email;
public EmailService EmailService{ get; set;}
public SendEmailTask (Email email, EmailService emailService){
this.email = email;
this.EmailService = emailService;
}
public override void Run(){
this.EmailService.send(this.email);
}
}
There are already some discussions on whether we should inject a service into an entity, and most people agree it's not a good practice. See this article.
Option2: If…Else in RecurringTaskScheduler
public class RecurringTaskScheduler{
public EmailService EmailService{get;set;}
public class RecurringTaskScheduler(EmailService emailService){
this.EmailService = emailService;
}
public void RunTasks(){
// load all tasks from cache or database
foreach(RecuringTask task : tasks){
if(task.isOccuring(Datetime.UtcNow)){
if(task is SendEmailTask){
EmailService.send(task.email); // also need to make email public in SendEmailTask
}
}
}
}
}
I've been told If…Else and cast like above is not OO, and will bring more problems.
Option3: Change the signature of Run and create ServiceBundle.
public class ServiceBundle{
public EmailService EmailService{get;set}
public CleanDiskService CleanDiskService{get;set;}
// and other services for other recurring tasks
}
Inject this class into RecurringTaskScheduler
public class RecurringTaskScheduler{
public ServiceBundle ServiceBundle{get;set;}
public class RecurringTaskScheduler(ServiceBundle serviceBundle){
this.ServiceBundle = ServiceBundle;
}
public void RunTasks(){
// load all tasks from cache or database
foreach(RecuringTask task : tasks){
if(task.isOccuring(Datetime.UtcNow)){
task.run(serviceBundle);
}
}
}
}
The Run method of SendEmailTask would be
public void Run(ServiceBundle serviceBundle){
serviceBundle.EmailService.send(this.email);
}
I don't see any big problems with this approach.
Option4: Visitor pattern.
The basic idea is create a visitor which will encapsulate services just like ServiceBundle.
public class RunTaskVisitor : RecurringTaskVisitor{
public EmailService EmailService{get;set;}
public CleanDiskService CleanDiskService{get;set;}
public void Visit(SendEmailTask task){
EmailService.send(task.email);
}
public void Visit(ClearDiskTask task){
//
}
}
And we also need to change the signature of Run method. The Run method of SendEmailTask is
public void Run(RecurringTaskVisitor visitor){
visitor.visit(this);
}
It is a typical implementation of Visitor Pattern, and the visitor will be injected into RecurringTaskScheduler.
In summary:
Among these four approaches, which one is the best for my scenario? And are there any big difference between Option3 and Option4 for this problem?
Or do you have better idea on this problem? Thanks!
Update 5/22/2015: I think Andy's answer summarizes my intention really well; if you are still confused about the problem itself, I suggest reading his post first.
I just found out my problem is very similar to Message Dispatch problem, which leads to Option5.
Option5: Convert my problem to Message Dispatch.
There is a one-to-one mapping between my problem and Message Dispatch problem:
Message Dispatcher : Receive IMessage and dispatch sub classes of IMessage to their corresponding handlers. → RecurringTaskScheduler
IMessage : An interface or an abstract class. → RecurringTask
MessageA : Extends from IMessage, having some additional information. → SendEmailTask
MessageB : Another subclass of IMessage. → CleanDiskTask
MessageAHandler : When receive MessageA, handle it → SendEmailTaskHandler, which contains EmailService, and will send out an email when it receives SendEmailTask
MessageBHandler : Same as MessageAHandler, but handle MessageB instead. → CleanDiskTaskHandler
The hardest part is how to dispatch different kind of IMessage to different handlers. Here is a useful link.
I really like this approach, it doesn't pollute my entity with service, and it doesn't have any God class.
Best Answer
I would say Option 1 is the best route to take. The reason you should not dismiss it is that the
SendEmailTask
is not an entity. An entity is an object concerned with holding data and state. Your class has very little of that. In fact, it is not an entity, but it holds an entity: theEmail
object you are storing. That means thatEmail
should not take a service, or have a#Send
method. Instead, you should have services that take entities, such as yourEmailService
. So you are already following the idea of keeping services out of entities.Since
SendEmailTask
is not an entity, it is therefore perfectly fine to inject the email and the service into it, and that should be done through the constructor. By doing constructor injection, we can be sure thatSendEmailTask
is always ready to perform it's job.Now let's look at why not to do the other options (specifically with respect to SOLID).
Option 2
You've been rightly told that branching on type like that will bring more headaches down the road. Let's look at why. First,
if
s tend to cluster and grow. Today, it's a task to send emails, tomorrow, every different type of class needs a different service or other behavior. Managing thatif
statement becomes a nightmare. Since we are branching on type (and in this case explicit type), we are subverting the type system built into our language.Option 2 is not Single Responsibility (SRP) because the formerly reusable
RecurringTaskScheduler
now has to know about all these different types of tasks, and about all the different kinds of services and behaviors they might need. That class is much harder to reuse. It is also not Open/Closed (OCP). Because it needs to know about this kind of task or that one (or this kind of service or that one), disparate changes to tasks or services could force changes here. Add a new task? Add a new service? Change the way email is handled? ChangeRecurringTaskScheduler
. Because the type of task matters, it does not adhere to Liskov Substitution (LSP). It can't just get a task and be done. It has to ask for the type and based on the type do this or do that. Rather than encapsulating the differences into the tasks, we are pulling all of that into theRecurringTaskScheduler
.Option 3
Option 3 has some big problems. Even in the article you link to, the author discourages doing this:
You are creating a service locator with your
ServiceBundle
class. In this case, it doesn't appear to be static, but it still has many of the problems inherent in a service locator. Your dependencies are now hidden away under thisServiceBundle
. If I give you the following API of my cool new task:What are the services I am using? What services need to be mocked out in a test? What's to stop me from using every service in the system, just because?
If I want to use your task system to run some tasks, I now am dependent on every service in your system, even if I only use a few or even none at all.
The
ServiceBundle
is not really SRP because it needs to know about every service in your system. It is also not OCP. Adding new services means changes to theServiceBundle
, and changes to theServiceBundle
may mean disparate changes to tasks elsewhere.ServiceBundle
does not Segregate its Interface (ISP). It has a sprawling interface of all these services, and because it's just a provider for those services, we could consider its interface to encompass the interfaces of all of the services it provides as well. Tasks no longer adhere to Dependency Inversion (DIP), because their dependencies are obfuscated behind theServiceBundle
. This also does not adhere to the Principle of Least Knowledge (aka the Law of Demeter) because things know about many more things than they have to.Option 4
Previously, you had lots of small objects that were able to operate independently. Option 4 takes all of these objects and smashes them together into a single
Visitor
object. This object acts as a god object over all of your tasks. It reduces yourRecurringTask
objects to anemic shadows that simply call out to a visitor. All of the behavior moves to theVisitor
. Need to change behavior? Need to add a new task? ChangeVisitor
.The more challenging part is that, because all of the different behaviors are all in a single class, changing some polymorphicly drags along all of the other behavior. For example, we want to have two different ways to send email (they should use different servers maybe?). How would we do it? We could create an
IVisitor
interface and implement that, potentially duplicating code, like#Visit(ClearDiskTask)
from our original visitor. Then if we come up with a new way to clear a disk, we have to implement and duplicate again. Then we want both kinds of changes. Implement and duplicate again. These two different, disparate behaviors are inextricably linked.Maybe instead we could just subclass
Visitor
? Subclass with new email behavior, subclass with new disk behavior. No duplication so far! Subclass with both? Now one or the other needs to be duplicated (or both if that's your preference).Let's compare to option 1: We need a new email behavior. We can create a new
RecurringTask
that does the new behavior, inject in its dependencies, and add it to the collection of tasks in theRecurringTaskScheduler
. We don't even need to talk about clearing disks, because that responsibility is somewhere else entirely. We also still have the full array of OO tools at our disposal. We could decorate that task with logging, for example.Option 1 will give you the least pain, and is the most correct way to handle this situation.