Passa al contenuto principale

Strategy pattern in C#

Questa pagina mostra due implementazioni idiomatiche dello Strategy pattern in ASP.NET Core, entrambe basate sulla Dependency Injection nativa.


1. Selezione a runtime con IEnumerable<T>

Idea

Si registrano tutte le implementazioni di un'interfaccia strategy nel container. Il consumatore riceve un IEnumerable<IStrategy> e sceglie l'implementazione corretta a runtime, in base a un discriminante (enum, stringa, proprietà del messaggio).

Interfaccia

public interface INotificationSender
{
string Channel { get; } // discriminante
Task SendAsync(string recipient, string message, CancellationToken ct);
}

Implementazioni

public class EmailSender : INotificationSender
{
public string Channel => "email";

public async Task SendAsync(string recipient, string message, CancellationToken ct)
{
// invio via SMTP
}
}

public class SmsSender : INotificationSender
{
public string Channel => "sms";

public async Task SendAsync(string recipient, string message, CancellationToken ct)
{
// invio via provider SMS
}
}

public class PushSender : INotificationSender
{
public string Channel => "push";

public async Task SendAsync(string recipient, string message, CancellationToken ct)
{
// invio via Firebase / APNs
}
}

Registrazione

builder.Services.AddScoped<INotificationSender, EmailSender>();
builder.Services.AddScoped<INotificationSender, SmsSender>();
builder.Services.AddScoped<INotificationSender, PushSender>();

Quando si registrano più implementazioni della stessa interfaccia, il container le espone tutte tramite IEnumerable<INotificationSender>.

Consumo (context)

public class NotificationUseCase
{
private readonly IEnumerable<INotificationSender> _senders;

public NotificationUseCase(IEnumerable<INotificationSender> senders)
{
_senders = senders;
}

public async Task NotifyAsync(string channel, string recipient, string message, CancellationToken ct)
{
var sender = _senders.FirstOrDefault(s => s.Channel == channel)
?? throw new ArgumentException($"Canale '{channel}' non supportato.");

await sender.SendAsync(recipient, message, ct);
}
}

Vantaggi

  • Aperto all'estensione: aggiungere un canale significa aggiungere una classe e una riga di registrazione.
  • Testabile: si inietta una lista con un solo mock per testare il context in isolamento.
  • Nessuna dipendenza da attributi o convenzioni .NET specifiche.

Limiti

  • La selezione avviene a runtime con una ricerca lineare. Per un numero elevato di strategy, si può costruire un Dictionary nel costruttore.
  • Il compilatore non aiuta a verificare che tutte le chiavi siano coperte.

2. Selezione dichiarativa con Keyed Services (.NET 8+)

Idea

Si sfruttano i keyed services introdotti in .NET 8 per associare ciascuna implementazione a una chiave (stringa o enum). Il container risolve direttamente l'implementazione corretta in base alla chiave, senza iterare.

Interfaccia

La stessa dell'esempio precedente, ma il discriminante non è più necessario nell'interfaccia:

public interface INotificationSender
{
Task SendAsync(string recipient, string message, CancellationToken ct);
}

Registrazione

builder.Services.AddKeyedScoped<INotificationSender, EmailSender>("email");
builder.Services.AddKeyedScoped<INotificationSender, SmsSender>("sms");
builder.Services.AddKeyedScoped<INotificationSender, PushSender>("push");

Consumo tramite attributo [FromKeyedServices]

Quando la chiave è nota a compile-time (es. un controller dedicato a un canale specifico):

public class EmailController : ControllerBase
{
private readonly INotificationSender _sender;

public EmailController([FromKeyedServices("email")] INotificationSender sender)
{
_sender = sender;
}
}

Consumo tramite IServiceProvider (selezione a runtime)

Quando la chiave è determinata a runtime:

public class NotificationUseCase
{
private readonly IServiceProvider _provider;

public NotificationUseCase(IServiceProvider provider)
{
_provider = provider;
}

public async Task NotifyAsync(string channel, string recipient, string message, CancellationToken ct)
{
var sender = _provider.GetRequiredKeyedService<INotificationSender>(channel);
await sender.SendAsync(recipient, message, ct);
}
}

Vantaggi

  • Risoluzione O(1): il container indicizza per chiave internamente.
  • Interfaccia più pulita: non serve un discriminante esposto dalla strategy.
  • Integrazione nativa: nessun codice custom di selezione.

Limiti

  • Richiede .NET 8 o superiore.
  • L'uso di IServiceProvider direttamente è un service locator, accettabile nello use case ma da non propagare ovunque.
  • La chiave è una stringa: refusi non vengono rilevati a compile-time. Si può mitigare usando costanti o un enum con .ToString().

Quale approccio scegliere

CriterioIEnumerable<T>Keyed Services
Versione .NETQualsiasi.NET 8+
SelezioneA runtime, logica nel contextDichiarativa o a runtime via provider
Numero di strategyBasso–medio (ricerca lineare)Qualsiasi (lookup O(1))
Interfaccia strategyInclude discriminantePulita, senza discriminante
TestabilitàOttima (lista di mock)Buona (mock del provider o test con container reale)

In generale:

  • Se il progetto è già su .NET 8+ e le chiavi sono note, i keyed services sono l'approccio più idiomatico.
  • Se si vuole massima portabilità o il numero di strategy è piccolo, IEnumerable<T> è più semplice e trasparente.
  • I due approcci non si escludono: si può usare IEnumerable<T> per scenari a runtime e keyed services per iniezioni statiche nello stesso progetto.