Dependency Injection In C# & .NET Part 2 - Making Implementations Swappable
[C#, .NET, Architecture, Domain Design]
This is Part 2 of a series on Dependency Injection
- Dependency Injection In C# & .NET Part 1 - Introduction & Basic Implementation
- Dependency Injection In C# & .NET Part 2 - Making Implementations Swappable (this post)
- Dependency Injection In C# & .NET Part 3 - Making Implementations Pluggable
- Dependency Injection In C# & .NET Part 4 - Making Implementations Hot-Pluggable
- Dependency Injection In C# & .NET Part 5 - Making All Implementations Available
- Dependency Injection In C# & .NET Part 6 - Implementation Testing
- Dependency Injection In C# & .NET Part 7 - Integration Testing
- Dependency Injection In C# & .NET Part 8 - Types Of Dependency Injection
- Dependency Injection In C# & .NET Part 9 - Life Cycles
- Dependency Injection In C# & .NET Part 10 - Conclusion
In our last post, we looked at what dependency injection is, how to set it up, and how it works to address some basic problems.
In this post, we will extend this further by allowing swapping. For purposes of this post I am defining swapping as achieving the changing of functionality of the system by making trivial changes in the application startup.
Suppose the business decides that due to whatever reasons, they want to use Office365 as the service to send alerts.
The implementation would look very similar.
First, there is the alert type:
public record Office365Alert(string Title, string Message);
Then there is the implementation of the sender, the Office365AlertSender
.
public sealed class Office365AlertSender
{
private readonly string _key;
public string Configuration { get; }
public Office365AlertSender(string key)
{
_key = key;
Configuration = $"Configuration - Key: {_key}";
}
public async Task<string> SendAlert(Office365Alert message)
{
await Task.Delay(TimeSpan.FromSeconds(5));
return Guid.NewGuid().ToString();
}
}
Unlike the GmailAlertSender
, this (fake) implementation only requires a key to operate.
We can then create a class to hold our Office365
settings.
public class Office365Settings
{
public string Key { get; set; } = "";
}
Then we update our appsettings.json
to add the new section
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"GmailSettings": {
"GmailUserName": "username",
"GmailPassword": "password",
"GmailPort": 4000
},
"Office365Settings": {
"Key": "Office365Key"
}
}
Then, we update our dependency injection in the program startup to register this sender, starting with registering the settings for injection
builder.Services.Configure<Office365Settings>(builder.Configuration.GetSection(nameof(Office365Settings)));
Then, we configure the DI to register our Office365AlertSender
to use the settings we have just registered for injection.
// Register our Office365 sender, passing our settings
builder.Services.AddSingleton<Office365AlertSender>(provider =>
{
// Fetch the settings from the DI Container
var settings = provider.GetService<IOptions<Office365Settings>>()!.Value;
return new Office365AlertSender(settings.Key);
});
Finally, we update our endpoints to replace the injected GmailAlertSender
with an Office365AlertSender
.
app.MapPost("/v4/SendOffice365EmergencyAlert", async ([FromBody] Alert alert,
[FromServices] Office365AlertSender mailer, [FromServices] ILogger<Program> logger) =>
{
logger.LogInformation("Active Configuration: {Configuration}", mailer.Configuration);
var office365Alert = new Office365Alert(alert.Title, alert.Message);
var alertID = await mailer.SendAlert(office365Alert);
return Results.Ok(alertID);
});
So our change here (apart from renaming the route) is to inject an Office365AlertSender
and, within the endpoint, build an Office365Alert
with which to send the message.
If we run our API, we should get the identifier returned.
And in our logs we should see the settings were correctly injected.
info: Program[0]
Active Configuration: Configuration - Key: Office365Key
And then, as is often the case with life, the business decides that that they want the option to choose which provider to use every calendar year.
Now, here we have some options.
We can change our endpoints every calendar year to reflect the relevant service. After all, the code is already written—it is just a matter of moving things around. However, this means touching the code, which is error-prone and can quickly become monotonous.
Things get more complicated when one of the nephews of the CEO mentions that where he works, they use Zoho email and have had a great experience, and we should consider using that.
At this point, we must return to the drawing board.
Dependency injection can help with this problem.
Let us start with the data types.
public record GmailAlert(string Title, string Body);
public record Office365Alert(string Title, string Message);
The properties are exactly the same.
We can refactor to a common type
public record GeneralAlert(string Title, string Message);
I am calling it GeneralAlert
and not Alert
because Alert is already in use to signify the object coming from the client calling the endpoint.
Which begs the question why don’t we use that Alert
directly? Why create a totally new type? It would certainly be simpler.
That is true. But this potentially could be a problem downstream if we use the same object on the client and server sides. Perhaps in the future, we want the client to provide additional information, perhaps a username and a timestamp for logging passwords. Now, our server-side code would need to change, regardless of the fact that those parameters are not necessary for sending alerts. And vice-versa - we might need to change the object on the API side to pass additional properties to the sender, which would break the client.
In short, it is important to segregate client-side concerns from server-side concerns.
Next, let us look at the senders closely, particularly what each requires to send a message.
// Gmail sender
public async Task<string> SendAlert(GmailAlert message)
{
await Task.Delay(TimeSpan.FromSeconds(5));
return Guid.NewGuid().ToString();
}
// Office 365 sender
public async Task<string> SendAlert(Office365Alert message)
{
await Task.Delay(TimeSpan.FromSeconds(5));
return Guid.NewGuid().ToString();
}
If we look closely, we can see that the parameters these take are similar and, given our refactoring, can take a common parameter.
We can thus add an additional method to both that takes a GeneralAlert
// Gmail sender
public async Task<string> SendAlert(GeneralAlert message)
{
await Task.Delay(TimeSpan.FromSeconds(5));
return Guid.NewGuid().ToString();
}
// Office 365 sender
public async Task<string> SendAlert(GeneralAlert message)
{
await Task.Delay(TimeSpan.FromSeconds(5));
return Guid.NewGuid().ToString();
}
We can then extract a general contract that all alert senders should adhere to by way of an interface.
interface IAlertSender
{
public Task<string> SendAlert(GeneralAlert message);
}
Finally, we update both senders to indicate that they are compliant with the IAlertSender interface.
The GmailAlertSender
now looks like this:
public sealed class GmailAlertSender : IAlertSender
{
private readonly int _port;
private readonly string _username;
private readonly string _password;
public string Configuration { get; }
public GmailAlertSender(int port, string username, string password)
{
_port = port;
_username = username;
_password = password;
Configuration = $"Configuration - Port: {_port}; Username: {_username}; Password: {_password}";
}
public async Task<string> SendAlert(GmailAlert message)
{
await Task.Delay(TimeSpan.FromSeconds(5));
return Guid.NewGuid().ToString();
}
// New method that sends a generic GeneralAlert
public async Task<string> SendAlert(GeneralAlert message)
{
await Task.Delay(TimeSpan.FromSeconds(5));
return Guid.NewGuid().ToString();
}
}
And the Office365AlertSender
now looks like this:
public sealed class Office365AlertSender : IAlertSender
{
private readonly string _key;
public string Configuration { get; }
public Office365AlertSender(string key)
{
_key = key;
Configuration = $"Configuration - Key: {_key}";
}
public async Task<string> SendAlert(Office365Alert message)
{
await Task.Delay(TimeSpan.FromSeconds(5));
return Guid.NewGuid().ToString();
}
// New method that sends a generic GeneralAlert
public async Task<string> SendAlert(GeneralAlert message)
{
await Task.Delay(TimeSpan.FromSeconds(5));
return Guid.NewGuid().ToString();
}
}
You might ask why we have retained the old SendAlert
that takes specific alert types. Removing those would be a breaking change, and other parts of the system may still use the existing endpoints as they are.
The next order of business is to modify the dependency injection slightly.
Currently, we are doing this:
// Register our GmailSender, passing our settings
builder.Services.AddSingleton<GmailAlertSender>(provider =>
{
// Fetch the settings from the DI Container
var settings = provider.GetService<IOptions<GmailSettings>>()!.Value;
return new GmailAlertSender(settings.GmailPort, settings.GmailUserName,
settings.GmailPassword);
});
// Register our Office365 sender, passing our settings
builder.Services.AddSingleton<Office365AlertSender>(provider =>
{
// Fetch the settings from the DI Container
var settings = provider.GetService<IOptions<Office365Settings>>()!.Value;
return new Office365AlertSender(settings.Key);
});
And each endpoint has to be explicitly told what to inject.
Let us assume for the current financial year we want to use Gmail.
We update our DI as follows:
// Register our generic Office 365 sender, passing our settings
builder.Services.AddSingleton<IAlertSender>(provider =>
{
// Fetch the settings from the DI Container
var settings = provider.GetService<IOptions<Office365Settings>>()!.Value;
return new Office365AlertSender(settings.Key);
});
Notice the parameter here to AddSingleton
is an IAlertSender
and not a GmailAlertSender
What this means is “any request for an IAlertSender should have a Office365AlertSender returned.”
Finally, we update our endpoint to inject an IAlertSender
, rather than the GmailAlertSender or the Office365AlertSender
. And since we now use a single endpoint regardless of the sender, we can also rename that to the generic SendEmergencyAlert
.
Our endpoints now look like this:
app.MapPost("/v5/SendEmergencyAlert", async ([FromBody] Alert alert,
[FromServices] Office365AlertSender mailer, [FromServices] ILogger<Program> logger) =>
{
logger.LogInformation("Active Configuration: {Configuration}", mailer.Configuration);
// Map the client provide alert to the server side alert
var genericAlert = new GeneralAlert(alert.Title, alert.Message);
var alertID = await mailer.SendAlert(genericAlert);
return Results.Ok(alertID);
});
If now run this app and check the logs, you should see:
info: Program[0]
Active Configuration: Configuration - Key: Office365Key
Indicating that the Office365AlertSender
serviced that request.
If we want to use a GmailAlertSender
instead, we change our DI as follows:
// Register our generic Gmail sender, passing our settings
builder.Services.AddSingleton<IAlertSender>(provider =>
{
// Fetch the settings from the DI Container
var settings = provider.GetService<IOptions<GmailSettings>>()!.Value;
return new GmailAlertSender(settings.GmailPort, settings.GmailUserName,
settings.GmailPassword);
});
If we re-run the code, we should see this in the logs:
info: Program[0]
Active Configuration: Configuration - Port: 4000; Username: username; Password: password
Indicating that a GmailAlertSender
serviced the request.
Now, say we want to add support for Zoho.
All we need to do is to implement the IAlertSender interface. Suppose Zoho requires senders to authenticate with an OrganizationID and a Secret Key.
Our Zoho sender would look like this:
public sealed class ZohoAlertSender : IAlertSender
{
private readonly string _organizationID;
private readonly string _secretKey;
public string Configuration { get; }
public ZohoAlertSender(string organizationID, string secretKey)
{
_organizationID = organizationID;
_secretKey = secretKey;
Configuration = $"Configuration - Organization ID: {_organizationID}, secretKey: {_secretKey}";
}
public async Task<string> SendAlert(GeneralAlert message)
{
await Task.Delay(TimeSpan.FromSeconds(5));
return Guid.NewGuid().ToString();
}
}
Notice here that our SendAlert
is a single method that only sends a GeneralAlert
. There isn’t one that sends a ZohoAlert
, nor do we need to create one. We don’t have any backward compatibility issues, so we can afford to do this.
We then, as usual, create a class to store these settings.
public class ZohoSettings
{
public string OrganizationID { get; set; } = "";
public string SecretKey { get; set; } = "";
}
We then update our appsettings.json
to add the new settings
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"GmailSettings": {
"GmailUserName": "username",
"GmailPassword": "password",
"GmailPort": 4000
},
"Office365Settings": {
"Key": "Office365Key"
},
"ZohoSettings": {
"OrganizationID": "MyOrg",
"SecretKey": "Secret"
}
}
We then register our ZohoSettings for DI
builder.Services.Configure<ZohoSettings>(builder.Configuration.GetSection(nameof(ZohoSettings)));
Finally. we change our DI to register our ZohoAlertSender
as the IAlertSender
.
// Register our generic Zoho sender, passing our settings
builder.Services.AddSingleton<IAlertSender>(provider =>
{
// Fetch the settings from the DI Container
var settings = provider.GetService<IOptions<ZohoSettings>>()!.Value;
return new ZohoAlertSender(settings.OrganizationID, settings.SecretKey);
});
If we run this now we should see this in the logs.
info: Program[0]
Active Configuration: Configuration - Organization ID: MyOrg, secretKey: Secret
What improvements have we made here?
- We have unified the object model to make the system much more flexible for future improvements
- We have eliminated provider-specific endpoints (for Gmail and Office365) and now have a single generic endpoint capable of sending alerts to any provider.
- We have made it very easy to add a new provider - just implement the
IAlertSender
interface and register it for DI - The endpoints will generally never need to change when implementing a new provider - making maintenance and improvements much easier.
It is important to note that only one IAlertSender
should be registered at a time.
There are definitely great improvements, but we are not quite there.
In the next post, we will look at how to use dependency injection to change providers dynamically.
The code is in my GitHub. The source code builds from first principles as outlined in this series of posts with different versions of the API demonstrating the improvements.
Happy hacking!