Strategy pattern in Laravel: selezione dinamica di implementazioni con Service Container, contextual binding e Pennant
In una piattaforma marketplace con migliaia di utenti attivi, il sistema di pricing aveva tre implementazioni: prezzo standard, prezzo con sconto enterprise, e un nuovo motore di pricing dinamico basato su domanda e offerta. La selezione dell'implementazione era gestita da un if/elseif/else in un Service Provider con 47 righe di logica condizionale che mescolava controlli su ruoli utente, piani di abbonamento, feature flag custom e fallback di configurazione. Ogni nuova implementazione richiedeva di modificare quella catena condizionale - violando l'Open/Closed Principle che il Gang of Four identifica come principio fondante dello Strategy pattern: il contesto che usa la strategia non dovrebbe cambiare quando si aggiunge una nuova strategia.
Come implementa Laravel lo Strategy pattern senza factory custom?
Il Service Container di Laravel è, di fatto, un resolver di Strategy pattern. Martin Fowler lo descrive come Dependency Injection container: un oggetto che sa quale implementazione concreta fornire quando il codice richiede un'interfaccia astratta. In Laravel, tre meccanismi coprono i tre livelli di dinamicità:
bind() con closure definisce la strategia di default con logica di selezione a runtime. when()->needs()->give() - il contextual binding, presente fin da Laravel 5.0 - fornisce implementazioni diverse della stessa interfaccia a consumer diversi. Feature::active() di Pennant dentro la closure di bind() aggiunge lo switching basato su feature flag:
/* AppServiceProvider::register() */
use App\Contracts\PricingEngineInterface;
use App\Services\StandardPricing;
use App\Services\EnterprisePricing;
use App\Services\DynamicPricing;
use Laravel\Pennant\Feature;
/* 1. Binding dinamico con Pennant per lo switching globale */
$this->app->bind(PricingEngineInterface::class, function ($app) {
$user = auth()->user();
if ($user && Feature::for($user)->active('dynamic-pricing')) {
return $app->make(DynamicPricing::class);
}
if ($user?->subscription()?->onPlan('enterprise')) {
return $app->make(EnterprisePricing::class);
}
return $app->make(StandardPricing::class);
});
/* 2. Contextual binding: AdminController usa SEMPRE DynamicPricing per le preview */
$this->app->when(AdminPricingController::class)
->needs(PricingEngineInterface::class)
->give(DynamicPricing::class);Il contextual binding ha precedenza sul binding globale: quando AdminPricingController richiede PricingEngineInterface, il container restituisce sempre DynamicPricing, indipendentemente dalla feature flag o dal piano utente. Per tutti gli altri consumer, la closure valuta le condizioni a runtime. Nessuna factory custom, nessuna catena if/elseif nel controller - la selezione è centralizzata nel provider e il controller riceve l'implementazione corretta via dependency injection.
Come testare che il container risolva l'implementazione corretta?
Il test verifica il binding, non la logica di business dell'implementazione. Feature::fake() di Pennant e actingAs() permettono di controllare le condizioni di selezione:
use App\Contracts\PricingEngineInterface;
use App\Services\StandardPricing;
use App\Services\DynamicPricing;
use App\Services\EnterprisePricing;
use App\Models\User;
use Laravel\Pennant\Feature;
public function test_standard_pricing_for_basic_user(): void
{
Feature::fake(['dynamic-pricing' => false]);
$user = User::factory()->create();
$this->actingAs($user);
$this->app->forgetInstance(PricingEngineInterface::class);
$engine = $this->app->make(PricingEngineInterface::class);
$this->assertInstanceOf(StandardPricing::class, $engine);
}
public function test_dynamic_pricing_when_feature_active(): void
{
Feature::fake(['dynamic-pricing' => true]);
$user = User::factory()->create();
$this->actingAs($user);
$this->app->forgetInstance(PricingEngineInterface::class);
$engine = $this->app->make(PricingEngineInterface::class);
$this->assertInstanceOf(DynamicPricing::class, $engine);
}
public function test_admin_controller_always_gets_dynamic_pricing(): void
{
Feature::fake(['dynamic-pricing' => false]);
$user = User::factory()->create();
$this->actingAs($user);
$controller = $this->app->make(AdminPricingController::class);
$engine = (new \ReflectionProperty($controller, 'pricingEngine'))->getValue($controller);
$this->assertInstanceOf(DynamicPricing::class, $engine);
}$this->app->forgetInstance() è necessario quando il binding è singleton - senza di esso, il container restituisce l'istanza cachata dalla prima risoluzione, ignorando le condizioni aggiornate nel test.
Errori comuni nella selezione dinamica dei servizi
Il primo errore è usare singleton per binding che dipendono dal contesto utente. Un singleton viene risolto una sola volta per l'intera vita dell'applicazione - se la prima richiesta è di un utente basic, tutti gli utenti successivi (inclusi quelli enterprise) riceveranno StandardPricing. Per binding dipendenti dall'utente corrente, usare bind() (risoluzione a ogni richiesta) invece di singleton().
Il secondo è mettere la logica di selezione nel controller. Un if (Feature::active('x')) nel controller per decidere quale service usare annulla il vantaggio della dependency injection - il controller diventa dipendente dalla logica di selezione oltre che dal servizio. Il Service Provider è l'unico posto dove questa logica appartiene.
Il terzo è non avere un'interfaccia. Senza PricingEngineInterface, il controller dipende dalla classe concreta - aggiungere una nuova implementazione richiede di modificare il controller. L'interfaccia è il contratto che permette al container di sostituire l'implementazione senza che il consumer cambi.
Il quarto è non gestire il fallback. Se Feature::active() lancia un'eccezione (database Pennant non raggiungibile, scope non risolvibile), il binding fallisce e il controller riceve un errore 500. Un try/catch nella closure con fallback all'implementazione di default è la rete di sicurezza necessaria in produzione.
Lo Strategy pattern tramite Service Container è il meccanismo architetturale che rende un applicativo estensibile senza modificare il codice esistente. I test automatici dei binding garantiscono che ogni implementazione venga risolta nel contesto corretto, e i middleware Laravel possono aggiungere guardie trasversali alle rotte che usano implementazioni diverse. Per conoscere il mio approccio all'architettura di applicativi Laravel, visita la mia pagina professionale. Se il tuo applicativo ha catene condizionali per la selezione dei servizi che crescono a ogni nuova implementazione, contattami per una consulenza dedicata - partiamo dall'inventario delle interfacce e dalla definizione dei binding.