Identifying abstractions and programming to interfaces can be helpful. We’ve previously seen how doing so can help to limit the modifications needed when requirements change. We’ve also seen how it can make our code more reusable.
Today, we’ll explore how doing so can help us to avoid unnecessarily using (potentially costly) third-party services while building and fine-tuning our systems.
Integrating Other Services
Let’s imagine we’re adding the ability for users to create accounts on a new service we’re building. When they sign up, we want to send them an email to confirm their account’s creation. In this example, we’ll integrate with Twilio SendGrid for this (I’m not affiliated with them). Based on their example, we might write some code that looks like the following to send an email:
public class UserAccountService
{
public async Task SendAccountCreationConfirmation(
string apiKey, string name, string email)
{
var client = new SendGridClient(apiKey);
var from = new EmailAddress("support@newservice.com", "Support");
var subject = "Account Confirmation";
var to = new EmailAddress(email, name);
var plainTextContent = "Thanks for creating an account!";
var msg = MailHelper.CreateSingleEmail(
from,
to,
subject,
plainTextContent,
null);
await client.SendEmailAsync(msg);
}
}
However, this code will also run when we’re hosting our system locally to build other parts of it. Beyond testing and verifying the integration, we don’t need (or want) to send ourselves emails to welcome us: we already know our system and don’t want to needlessly spend our email credits. With the current code structure, we have two ways to prevent this from happening:
Comment out the body of
SendAccountCreationConfirmation
.Pass in an invalid API key.
Option (1) will work, but we’ll need to constantly be mindful of two things:
This change should not be committed into source control, and
It will need to be reapplied whenever we have a clean working environment, e.g. after a fresh repository clone, or after resetting changes on a working branch.
Option (2) also prevents emails from being sent. It’s better than option (1) in that we don’t necessarily need to manually make the change each time.
We can instead choose to read the API key in from a configuration file. By setting its value to a placeholder in committed code, any requests sent to SendGrid will (by default) not authenticate; to enable correct functionality in production, we can configure the deployment settings to overwrite the corresponding variable with the correct key.
While this approach will work, it isn’t the cleanest way to prevent emails from being sent (and could potentially be interpreted as suspicious/malicious activity).
Another Option
In our current implementation, the logic for sending emails is coupled tightly with the other logic in UserAccountService
. (In the previously presented code, we don’t have any other logic. But we might, for example, add other related methods for creating and deleting accounts; or for updating a user’s details.)
By refactoring our service to abstract away the specifics of interacting with SendGrid, we can separate these distinct concerns. In the following code, we’ve replaced the corresponding logic block with a call to a new interface (IEmailService.SendEmail
), expressing our intention rather than detailing how to do it.
public interface IEmailService
{
Task SendEmail(string apiKey, EmailData data);
}
public class UserAccountService
{
private readonly IEmailService _emailService;
public UserAccountService(IEmailService emailService)
{
_emailService = emailService;
}
public async Task SendAccountCreationConfirmation(
string apiKey, string name, string email)
{
var data = new EmailData
{
Content = "Thanks for creating an account!",
FromEmail = "support@newservice.com",
FromName = "Support",
Subject = "Account Confirmation",
ToEmail = email,
ToName = name
};
await _emailService.SendEmail(apiKey, data);
}
}
As part of the refactor, we can move the (now) missing logic into a separate class that implements the interface. We can use this in production, where we want emails to always be sent:
public class SendGridEmailService : IEmailService
{
public async Task SendEmail(string apiKey, EmailData data)
{
var client = new SendGridClient(apiKey);
var from = new EmailAddress(data.FromEmail, data.FromName);
var to = new EmailAddress(data.ToEmail, data.ToName);
var msg = MailHelper.CreateSingleEmail(
from,
to,
data.Subject,
data.Content,
null);
await client.SendEmailAsync(msg);
}
}
When running locally, we can replace it with an implementation that does nothing:
public class NullEmailService : IEmailService
{
public Task SendEmail(string apiKey, EmailData data)
{
return Task.CompletedTask;
}
}
Alternatively, we might be interested in the data. In that case, we can create an implementation that writes the email to a file on disk that we can inspect with a text editor:
public class DiskWriterEmailService : IEmailService
{
public async Task SendEmail(string apiKey, EmailData data)
{
var path = @$"C:\Development\Emails\{data.Subject}.txt";
File.WriteAllText(path, data.Content);
}
}
Summary
You don’t always want to activate integrations when building systems, e.g. sending emails when developing locally. Coding to an interface can prevent this from happening.
By identifying abstractions in your code, you can move specific implementations into separate modules, replacing them with calls to interface methods instead. You can then choose the most suitable implementation, potentially making development easier; saving you money (by not triggering third-party systems); or both.
Thanks for reading!
This article is from my newsletter. If you found it useful, please consider subscribing. You’ll get more articles like this delivered straight to your inbox (once per week), plus bonus developer tips too!
Top comments (0)