Problem statement
One of the most important features a modern application needs is the ability to send notifications. While working on a web API, I encountered the need to implement this functionality. The API was a simple monolithic project, and I developed an email service to handle email notifications. My initial goal was to integrate the Brevo .NET SDK for sending emails efficiently.
However, as the project evolved, a new requirement emerged: the need for an opt-out feature. This presented a challenge because the email service was built as an independent class assembly. I wanted to avoid modifying its core functionality to accommodate the new requirement.
Design Decisions
I have taken the decision to leave the email service untouched because I wanted to adhere to the open-closed principle, which encourages designing components that are open to extension but closed to modification. Additionally, the opt-out table, which stored the opted-out email addresses, was located within the main database of the monolithic project. I wanted to ensure that the email service remained reusable and decoupled from the application’s primary database.
Rationale
By keeping the email service independent, I aimed to achieve the following:
- Reusability - the service could be extracted and repurposed for other projects or as part of a separate microservice
- Decoupling - avoiding dependencies on the application database ensured the service’s functionality was not tightly bound to the specific implementation details of the current project
Proposed solution:
To address the challenge, I decided to implement the decorator pattern by enhancing the existing email service with the opt-out functionality. During my research, I came across an interesting NuGet package called Scrutor, which provides an excellent abstraction for the decorator pattern. This package allowed me to implement the solution in an elegant way with minimum effort.
Initial implementation
- IEmailService interface - defines the contract for sending emails with placeholders and optional footers
public interface IEmailSender
{
//toEmail - email address of the recipient
//placeholders - A dictionary where the key represents the string to be replaced within the email, and the value specifies what it should be replaced with.
//hasOptOutFooter - indicates whether or not an optout footer should be appended to the final email
public Task<bool> SendEmailWithPlaceholders(string toEmail, Dictionary<string, string> placeholders,
string emailCode, bool hasOptOutFooter = false);
}
- EmailSender service - handles template resolution, placeholder substitution, and email dispatch via Brevo API
public class EmailSender : IEmailSender
{
private readonly EmailSettings _emailSettings;
private readonly TransactionalEmailsApi _apiClient; //brevo api
public EmailSender(IOptions<EmailSettings> emailSettings)
{
_emailSettings = emailSettings.Value; //email settings fetched using options patter from appsettings.json
if (!Configuration.Default.ApiKey.ContainsKey("api-key"))
{
Configuration.Default.ApiKey.Add("api-key", _emailSettings.ApiKey);
}
_apiClient = new TransactionalEmailsApi();
}
public async Task<bool> SendEmailWithPlaceholders(string toEmail, Dictionary<string, string> placeholders,
string emailCode, bool hasOptOutFooter = false)
{
var (subject, template) = ResolveTemplate(placeholders, emailCode, hasOptOutFooter);
var sendSmtpEmail = new SendSmtpEmail
{
To = [new SendSmtpEmailTo(toEmail, toEmail)],
Subject = subject,
HtmlContent = template,
Sender = new SendSmtpEmailSender
{
Email = _emailSettings.DefaultSenderEmail,
Name = _emailSettings.DefaultSenderName
}
};
//send email
var response = await _apiClient.SendTransacEmailAsync(sendSmtpEmail);
return response?.MessageId != null;
}
private (string, string) ResolveTemplate(Dictionary<string, string> placeholders,
string emailCode, bool hasOptOutFooter = false)
{
var subject = new StringBuilder(TemplatesRepository.Entities[emailCode].Subject);
var template = new StringBuilder(TemplatesRepository.Entities[emailCode].Template);
//if the email has an optout footer append it else append the regular footer
template.Append(hasOptOutFooter ?
TemplatesRepository.Entities[EmailCodes.OptOutFooter].Template :
TemplatesRepository.Entities[EmailCodes.RegularFooter].Template);
//replaces the placeholders with the actual values provided to the email service
//e.g. key is {{user_name}} will be replaced nad value Bob, Bob will be inserted instead of the placeholder
foreach (var placeholder in placeholders)
{
template.Replace($"{{{{{placeholder.Key}}}}}", placeholder.Value);
subject.Replace($"{{{{{placeholder.Key}}}}}", placeholder.Value);
}
return (subject.ToString(), template.ToString());
}
}
- TemplatesRepository - stores email template, subjects, and metadata (e.g., opt-out eligibility). In my case, I have a limited number of templates so storing them in memory is good enough for me
public static class EmailCodes
{
public const string ResetPassword = "ResetPassword";
public const string ChangeEmail = "ChangeEmail";
//...other codes omitted for brevity
}
public static class TemplatesRepository
{
public static readonly Dictionary<string, EmailEntity> Entities = new()
{
{
EmailCodes.ChangeEmail,
new()
{
Subject = "Change Email Address",
Template = @"
<body style='text-align: center;'>
<h1>Hello {{user_name}},</h1>
<p>
You have requested to change the address associated with your account.
</p>
<p>To change your email address, please click the link below:</p>
<p><a href='{{change_email_url}}'>Change Email Address</a></p>
<p>Thank you!</p>
</body>",
CanBeOptedOut = false,
AccountEmail = true
}
},
{
EmailCodes.ResetPassword,
new()
{
Subject = "Password Reset",
Template = @"
<body style='text-align: center;'>
<h1>Hello {{user_name}},</h1>
<p>
You have requested to reset the password for your account.
</p>
<p>To reset your password, please click the link below:</p>
<p><a href='{{reset_password_url}}'>Reset Password</a></p>
<p>Thank you!</p>
</body>",
CanBeOptedOut = true,
AccountEmail = true
}
}
//...other templates omitted for brevity
};
}
- SectionExtensions - extension method used for registering the EmailService in the DI container
public static class SectionExtensions
{
public static IServiceCollection AddEmailSenderServices(this IServiceCollection services)
{
services.AddScoped<IEmailSender, EmailSender>();
return services
}
}
- appsettings.json - stores email settings like API keys and default sender details
{
//configurations omitted for brevity
"UiBaseUrl": "https://www.myawesomesite.com/",
"EmailSettings": {
"ApiKey": "super-secret-api-key",
"DefaultSenderName": "Company",
"DefaultSenderEmail": "default.sender@company.com",
"AccountEmailsOnly": false
},
"EncryptionKeys": {
"EmailKey": "supersecret",
"EmailIv": "supersecret"
}
//configurations omitted for brevity
}
- Example usage of EmailService in code (sending a reset password email)
public class AuthenticationService(IEmailSender emailSender, UserManager userManager, IConfiguration configuration) : IAuthenticationService
{
//code omitted for brevity
public async Task<BaseResponse> SendRequestPasswordEmail(string email)
{
var user = await userManager.FindByEmailAsync(email);
if (user?.Email == null)
{
return new BaseResponse(ErrorCodes.CouldNotFindUser);
}
var passwordResetToken = await userManager.GeneratePasswordResetTokenAsync(user);
if (string.IsNullOrWhiteSpace(passwordResetToken))
{
return new BaseResponse(ErrorCodes.CouldNotGeneratePasswordToken);
}
var resetPasswordUrl = $"{configuration.GetValue<string>("UiBaseUrl")}" +
$"reset-password-new?token={EncodingHelper.Base64Encode(passwordResetToken)}&user={user.Id}";
var placeholders = new Dictionary<string, string>
{
{ "user_name", user.UserName ?? string.Empty },
{ "reset_password_url", resetPasswordUrl }
};
var success = await emailSender.SendEmailWithPlaceholders(user.Email, placeholders,
EmailCodes.ResetPassword);
return success ? new BaseResponse() : new BaseResponse(ErrorCodes.ResetPasswordEmailNotSent);
}
//code omitted for brevity
}
Solution implementation
Extending the existing EmailSender service with the Decorator Pattern
- Step 1 - implementing EmailSenderDecorator for enhancing the email service with opt-out validation and dynamic footer addition
public class EmailSenderDecorator(IEmailSender emailSender, IOptOutEmailRepository optOutEmailRepository,
IConfiguration configuration, IOptions<EncryptionKeysModel> encryptionKeys) : IEmailSender
{
public async Task<bool> SendEmailWithPlaceholders(string toEmail, Dictionary<string, string> placeholders,
string emailCode, bool hasOptOutFooter = false)
{
try
{
var optOutEmailEntity = await optOutEmailRepository.GetOptOutEmail(toEmail);
//the decorator also alows for additional valiation that is not present in the original service
if (optOutEmailEntity is not null && TemplatesRepository.Entities.TryGetValue(emailCode, out var emailEntity) &&
emailEntity.CanBeOptedOut)
{
return false;
}
//add the opt out footer to the placeholder array
if (hasOptOutFooter && optOutEmailEntity.EmailAddress is not null)
{
//helper method used for creating a encrypted opt out token
var token = EncryptionHelper.EncryptObject(optOutEmailEntity.EmailAddress, encryptionKeys.Value.EmailKey, encryptionKeys.Value.EmailIv);
var encryptedUrl = $"{configuration.GetValue<string>("UiBaseUrl")}opt-out?token={token}";
placeholders.Add("opt_out_url", encryptedUrl);
}
return await emailSender.SendEmailWithPlaceholders(toEmail, placeholders, emailCode, hasOptOutFooter);
}
catch
{
//handle exception
return false;
}
}
}
public record EncryptionKeysModel
{
public required string EmailKey { get; set; }
public required string EmailIv { get; set; }
}
- Step 2 - registering the decorator in DI, using Scrutor, to wrap the existing EmailSender
public static class SectionExtensions
{
public static IServiceCollection AddEmailSenderServices(this IServiceCollection services)
{
services.AddScoped<IEmailSender, EmailSender>();
//registering the decorator using Scrutor
services.Decorate<IEmailSender, EmailSenderDecorator>();
return services;
}
}
- Step 3 - by setting hasOptOutFooter to true in the SendEmailWithPlaceholders call, the decorator logic is applied. Without changing this flag, the email service works as before, bypassing the decorator logic
//omitted for brevity
var resetPasswordUrl = $"{configuration.GetValue<string>("UiBaseUrl")}" +
$"reset-password-new?token={EncodingHelper.Base64Encode(passwordResetToken)}&user={user.Id}";
var placeholders = new Dictionary<string, string>
{
{ "user_name", user.UserName ?? string.Empty },
{ "reset_password_url", resetPasswordUrl }
};
var success = await emailSender.SendEmailWithPlaceholders(user.Email, placeholders,
EmailCodes.ResetPassword, true);
//omitted for brevity
Analysis of the solution
- Suitability - the decorator pattern is ideal for enhancing the functionality of a component without modifying its existing logic. It provides a clean and modular way to extend functionality while adhering to the open-closed principle
- Extensibility - the decorator pattern offers an extensible way to add more features, such as logging, analytics, or additional validation layers, without changing the core service. However, care must be taken to avoid excessive layering, which could complicate debugging and increase maintenance overhead
- Simplified example - this implementation represents a simplified version of a notification service. If templates were stored in a database instead of memory, the service would inherently have access to the database, making the decorator unnecessary for accessing the opt-out table. In such cases, a direct database query within the core service could replace the need for a separate decorator
- Transition to a standalone notification service - this solution serves as a step toward decoupling the notification service by adhering to the single responsibility principle. Moving all email logic (template management, opt-out functionality, etc.) into a standalone service would enhance scalability, reusability, and maintainability. A standalone service approach would eliminate dependencies on the primary application’s database while enabling independent scaling and deployment
Top comments (0)