Service Locator is not an Anti-Pattern
Well, it is, sometimes. It depends.
I often get pushback on MediatR for using service location for resolving handlers, often getting pointed at Mark Seemann's post that Service Locator is an Anti-Pattern. And for all of the examples in the post, I agree that service location in those cases should not be used.
However, like any pattern, it's not so cut and dry. Patterns have advantages and disadvantages, situations where it's advantageous or even necessary to use them, and situations where it's not. In Mark's post, I see only situations that service location is not required and better alternatives exist. However, if you're building anything on .NET Core/6 these days, it's nearly impossible to avoid service location because many of the underlying infrastructure components use it or only expose interfaces that only allow for service location. Why is that the case? Let's examine the scenarios involved to see when service location is required or even preferable.
Mismatched Lifetimes
Modern dependency injection containers have a concept of service lifetimes. Different containers have different lifetimes they support, but in general I see three:
- Transient
- Scoped
- Singleton
Transient lifecycle means every time you ask the container to resolve a service, all transient services will be a new instance. Scoped lifecycle means that for a given scope or context, all scoped services will resolve in the same instance for that given scope. Singleton means that for the lifetime of the container, you'll only get one single instance.
One must be careful not to mix and match these lifetimes that are incompatible - namely, when you inject a transient into a singleton, you'll only get that one instance. Scoped services should only be resolved from a scope, and shouldn't be injected into a singleton.
But sometimes that is unavoidable. If you use ASP.NET Core, you've likely already ran into these scenarios, where you want to use a scoped service like DbContext
inside a singleton (filters, hosted services, etc.). In these cases, you likely have no other option but to use service location.
In some cases, service location is provided through a method that provides access to the scoped composition root:
public class ValidatorActionFilter
: IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var serviceProvider = context.HttpContext.RequestServices;
var validator = serviceProvider.GetRequiredService<IRequestValidator>();
if (!await validator.IsValidAsync(context))
{
context.Result = new BadRequestResult();
}
await next();
}
}
In this case, context.HttpContext.RequestServices
is the scope from which we can resolve other scoped services. In the case of an IHostedService
, there is no such built-in mechanism. We have to inject our root service provider ourselves and do our own scope creation:
public class EmailSenderService : IHostedService
{
private readonly IServiceProvider _serviceProvider;
public EmailSenderService(IServiceProvider serviceProvider)
=> _serviceProvider = serviceProvider;
public async Task StartAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
await using (var scope = _serviceProvider.CreateAsyncScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<SchoolContext>();
var emailSender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
var emailsToSend = await dbContext.EmailMessages.ToListAsync(cancellationToken);
foreach (var email in emailsToSend)
{
await emailSender.SendAsync(email);
dbContext.EmailMessages.Remove(email);
}
await dbContext.SaveChangesAsync(cancellationToken);
}
await Task.Delay(TimeSpan.FromMinutes(5), cancellationToken);
}
}
In this example, we have a simple background job that sends emails every 5 minutes. Hosted services are always singleton, but we need to use a DbContext
to retrieve the emails to send. Since DbContext
is scoped, we have to inject our composition root IServiceProvider
and manually create a scope ourselves in order to retrieve and use a DbContext
each time our service wakes up to run.
In both examples, is the singleton lifecycle "wrong"? Well, no, there are always tradeoffs for different lifecycles but by using service location we can still resolve scoped services successfully. Are these code smells? Again, no, because we are either creating or are using our scoped composition root in common infrastructure or middleware code.
Types not known at runtime
Very often, I'll want to support dynamic resolution of different strategies in some general purpose code where I don't know the type (or would rather not know the type) until runtime. I've blogged quite a bit about this in the past, but using this pattern requires service location. And if you've used Fluent Validator, you've used service location too. Validators are a common example, but I've used this pattern numerous times with different sorts of pluggable strategy patterns. It starts with some generic interface that will be resolved from a container:
public interface ICommandValidator<in T>
{
Task<bool> IsValid(T command);
}
You'll register all validators in the container, probably using a type scanner like Scrutor to make your life easier.
That generic argument T however means that anywhere we want to validate a command, we MUST know the type T at compile time. That's not always possible, especially in middleware where we don't have a "T", we instead have Type
. For these cases, I'll typically create some factory class that can bridge the generics gap. The factory is not generic so that it can be used anywhere I don't know the type until runtime:
public interface ICommandValidatorFactory
{
ICommandValidator Resolve(ICommand command);
}
And the implementation resolves the dependencies based on the runtime type we interrogate from the incoming instance:
public class CommandValidatorFactory : ICommandValidatorFactory
{
private readonly IServiceProvider _serviceProvider;
public CommandValidatorFactory(IServiceProvider serviceProvider)
=> _serviceProvider = serviceProvider;
public ICommandValidator Resolve(ICommand command)
{
var commandType = command.GetType();
var validatorWrapper = typeof(CommandValidator<>).MakeGenericType(commandType);
var validator = (ICommandValidator)_serviceProvider.GetService(validatorWrapper);
return validator;
}
}
In this way, we can still use our generic interface of ICommandValidator<T>
but we don't force all consumers of validating commands to know exactly what command they're validating at runtime. If I want to validate in some middleware across all requests, I simply can't know that type unless I explicitly inject ICommandValidator<T>
into every single request. Which is possible, but painful, and makes things even more challenging if I want to introduce more cross-type patterns. And since I'm still resolving from our injected scope, it's much less prone to mistakes than static service location.
Teams that don't employ this manner of service location I tend to find avoid generics for services, because forcing constructor injection everywhere means we have to know all this type information up front. Which is unfortunate, generics are a great way to have our cake and eat it too but it requires us to stretch past just the simple "everything dependency must be injected in the constructor" rule.
Avoiding slow startup
One big limitation of constructor injection is that it requires us to have every instance of a composition root ready at the first request. But what if constructing an instance is expensive, or if it's not ready yet, or we want to have our application start as quickly as possible?
One way to avoid this is to use Lazy<T>
, and inject a lazy instance instead of the real one:
public class EmailSender : IEmailSender
{
private readonly Lazy<IEmailGateway> _emailGateway;
public EmailSender(Lazy<IEmailGateway> emailGateway)
=> _emailGateway = emailGateway;
public async Task SendAsync(EmailMessage email)
{
var gateway = _emailGateway.Value;
await gateway.SendAsync(email.To, email.Subject, email.Body);
}
}
We're using Lazy<T>
as a form of service location, where we don't resolve the service until we actually need to use the dependency and not before.
In mobile applications, startup time is critical. In these cases, we actually want to avoid constructor injection because of how much it affects startup time. We might use a library like ReactiveUI and Splat to manage dependencies in our ViewModels:
public FeedsViewModel(IBlobCache cache = null)
{
Cache = cache ?? Locator.Current.GetService<IBlobCache>();
}
We have the best of both worlds here - constructor injection for unit testing, but service location when we run our app so that our dependencies don't get resolved until use.
Conclusion: It Depends
Usage a pattern in certain scenarios can be advantageous/required or it might not be, but it really depends on the scenario. By taking a blanket "this pattern is always an antipattern", you close yourself off to the scenarios where that pattern IS useful.
And that's the case with service location - I use it in MediatR because I generally found the alternatives (directly injecting handlers for example) to be clunkier or prevented other scenarios - like behaviors, cross-cutting concerns, or simply to constrain usage to a single method. It's neither good nor bad, it's a pattern with tradeoffs.
Once you find the places where the pattern DOES work well, it opens the door to all sorts of valuable scenarios that were previously closed due to prejudice.