I have an application. It needs to send emails. We've all been there, done that, got the t-shirt.
I've got an IEmailMessage
interface:
public interface IEmailMessage
{
string From { get; }
string To { get; }
string Subject { get; }
string Body { get; }
bool IsBodyHtml { get; }
}
with a readonly implementation:
public class DefaultEmailMessage : IEmailMessage
{
public DefaultEmailMessage(string from, string to, string subject, string body, bool isBodyHtml)
{
// you can probably guess what this does
}
public string From { get; }
public string To { get; }
public string Subject { get; }
public string Body { get; }
public bool IsBodyHtml { get; }
}
There's a service to actually send an email (implementations use actual email providers like SendGrid):
public interface IEmailSender
{
Task<bool> SendAsync(IEmailMessage email, CancellationToken cancellationToken = default(CancellationToken));
}
and finally, a service that handles pulling the messages from a queue, transforms them into generic IEmailMessage
s, then uses an instance of the above interface to actually send them off:
public interface IEmailDeliveryService
{
Task SendOutstandingEmailsAsync(int batchSize);
}
public class EmailDeliveryService : IEmailDeliveryService
{
public EmailDeliveryService(IEmailSender emailClient)
{
...
}
public async Task SendOutstandingEmailsAsync(int batchSize)
{
IEnumerable<IEmailMessage> serviceEmails = GetListOfEmailsFromDb(batchSize)
.ConvertAll(dbEmail => ToIEmailMessage(dbEmail));
foreach (var email in serviceEmails)
await _emailClient.SendAsync(email);
}
}
The reason for this somewhat convoluted chain is to allow the actual email sending provider IEmailSender
to be substituted (this is a PoC and SendGrid is unlikely to be the final choice).
In our dev environment we don't want test emails to potentially go out to clients, so we need to modify the "To" field to set it to a dev mailbox. For this purpose I've written the following visitor:
public interface IEmailTransformer
{
void Transform(IEmailMessage email);
}
The intention is that I will have 0...N instances of IEmailTransformer
injected into the EmailDeliveryService
, and each instance will get every email passed to it so it can do whatever it needs:
public interface IEmailDeliveryService
{
Task SendOutstandingEmailsAsync(int batchSize);
}
public class EmailDeliveryService : IEmailDeliveryService
{
public EmailDeliveryService(IEmailSender emailClient, IEnumerable<IEmailTransformer> transformers)
{
...
}
public async Task SendOutstandingEmailsAsync(int batchSize)
{
IEnumerable<IEmailMessage> serviceEmails = GetListOfEmailsFromDb(batchSize)
.ConvertAll(dbEmail => ToIEmailMessage(dbEmail));
foreach (var email in serviceEmails)
{
foreach (var transformer in _transformers)
transformer.Transform(email);
await _emailClient.SendAsync(email);
}
}
}
I'll then have an implementation that sets the "To" field, instantiated only in the dev environment:
public class EmailToTransformer : IEmailTransformer
{
public void Transform(IEmailMessage email)
{
email.To = _emailTo;
}
}
Problem
The above obviously doesn't work since the interface's properties are readonly.
Solution #1
Make the properties writable.
Issues #1
The properties shouldn't be writable because IEmailSender
shouldn't be able to mutate them.
Solution #2
Define an intermediate interface, IWritableEmailMessage
, that inherits from IEmailMessage
:
public interface IWritableEmailMessage : IEmailMessage
{
string From { get; set; }
string To { get; set; }
string Subject { get; set; }
string Body { get; set; }
bool IsBodyHtml { get; set; }
}
Change EmailDeliveryService
, DefaultEmailMessage
and IEmailTransformer
to use IWritableEmailMessage
.
Issues #2
Feels like using abstraction to achieve the same thing as #1, just in an unnecessarily convoluted manner.
Question
Neither of the above solutions is optimal, I feel like there must be a better way to achieve this, but I don't know what it is. I feel like I'm either overlooking something simple, or unnecessarily overcomplicating this. Any suggestions (especially pointing to particular design patterns) are highly appreciated!
Apologies for the large amount of code, but I felt it's necessary to fully explain the problem. I tried to follow the advice in this Meta answer.