Principi SOLID applicati a Laravel: refactoring da controller a service layer
Il debito tecnico più comune che trovo in un'applicazione Laravel ha una forma precisa: il controller gonfio. Un metodo store() da centocinquanta righe che valida l'input, applica le regole di business, scrive su tre tabelle, invia un'email, chiama un'API esterna e formatta la risposta, tutto in un blocco solo. Funziona, finché un giorno la stessa logica serve anche in un comando da console o in un job asincrono, e ti accorgi che non puoi riusarla perché è incastrata in un controller HTTP. La cura che molti applicano a questo problema, però, rischia di essere peggiore del male: "applichiamo i principi SOLID" diventa "astraiamo tutto", e ti ritrovi con dieci interfacce, venti classi e un'indirezione che nasconde la logica invece di chiarirla. In un refactoring su un gestionale legacy ho visto entrambi gli estremi, il controller-monolite e l'astrazione barocca, e nessuno dei due è ingegneria. SOLID è uno strumento per gestire il cambiamento, non una checklist da massimizzare. Vediamo come spostare la logica in un service layer in modo pragmatico, dove la separazione paga davvero e dove invece aggiunge solo cerimonia.
Il controller gonfio: perché succede, e perché è un problema
I controller si gonfiano per un motivo comprensibile: sono il punto dove arriva la richiesta, ed è naturale scriverci dentro tutto ciò che la richiesta deve fare. All'inizio è una manciata di righe, poi una regola, poi un'altra, e senza un momento di decisione consapevole ti ritrovi con un metodo che fa cinque cose. Il problema non è estetico. Un controller che fa tutto viola il primo principio SOLID, la Single Responsibility: ha più di una ragione per cambiare. Se cambia la regola di business cambi il controller; se cambia il formato della risposta cambi il controller; se cambia il modo di inviare l'email cambi il controller. Tre responsabilità diverse intrecciate in un punto solo.
Le conseguenze pratiche sono tre, e le pago sempre. La logica non è riusabile: quella validazione e quelle regole vivono dentro un metodo HTTP e non puoi richiamarle altrove. La logica non è testabile in isolamento: per testare la regola di business devi simulare un'intera richiesta HTTP. E la logica non è leggibile: chi arriva dopo deve districare cosa è orchestrazione e cosa è business in un blocco indistinto. Il service layer nasce per sciogliere esattamente questi tre nodi.
SRP applicato: il controller orchestra, non esegue
Il principio guida del refactoring è che il controller deve orchestrare, non eseguire. Il suo lavoro è ricevere la richiesta, delegare a chi sa fare il lavoro, e restituire una risposta. La validazione esce in una Form Request, la logica di business esce in un service, e il controller torna a essere sottile. Ecco il prima, il monolite tipico:
// PRIMA: il controller fa tutto, ha cinque ragioni per cambiare
public function store(Request $request)
{
$data = $request->validate([/* venti regole */]);
// regole di business, calcoli, sconti...
$order = Order::create([/* ... */]);
foreach ($data['items'] as $item) { /* righe d'ordine */ }
Mail::to($order->customer)->send(new OrderConfirmation($order));
// chiamata a un'API esterna...
return response()->json($order);
}E il dopo, dove ogni pezzo è al suo posto:
// DOPO: il controller orchestra e basta
public function store(StoreOrderRequest $request, OrderService $orders)
{
$order = $orders->place($request->validated());
return new OrderResource($order);
}La validazione è nella StoreOrderRequest, un argomento che ho approfondito parlando di validazione dell'input con le Form Request; la logica di business è nel metodo place() dell'OrderService; la formattazione della risposta è in una API Resource. Il controller ora ha una sola ragione per cambiare: il modo in cui gestisce la richiesta HTTP. Se cambia una regola di business, tocchi il service, non il controller. E quel OrderService::place() ora puoi chiamarlo da un controller, da un comando o da un job, perché non sa nulla di HTTP.
Se hai un'applicazione Laravel dove i controller sono diventati ingestibili e vuoi rimetterli in ordine senza cadere nell'eccesso opposto, nel mio profilo professionale trovi l'esperienza concreta sul refactoring di codebase reali verso un'architettura più pulita e mantenibile.
DIP e il container: dipendere da un'astrazione, ma solo quando serve
Il principio di Dependency Inversion dice di dipendere da astrazioni, non da implementazioni concrete. In Laravel questo si esprime attraverso il service container: invece di istanziare a mano le dipendenze, le si dichiara nel costruttore e il container le inietta. Questo da solo, l'iniezione del service nel controller come abbiamo visto, è già DIP applicato bene e a costo zero.
Il passo successivo, l'interfaccia, va fatto con giudizio. Se hai un servizio di pagamento che potrebbe avere implementazioni diverse (uno gateway reale e uno fake per i test, o due provider intercambiabili), allora ha senso definire un'interfaccia e legarla all'implementazione concreta nel container:
// nel ServiceProvider: lega l'astrazione all'implementazione
$this->app->bind(PaymentGateway::class, StripePaymentGateway::class);Così il codice dipende da PaymentGateway, e cambiare provider o iniettare un fake nei test è una riga di binding. Ma, e qui sta il pragmatismo, non si crea un'interfaccia per un'implementazione che è unica e resterà unica. Una OrderServiceInterface con una sola OrderService dietro non è DIP, è cerimonia: aggiunge un file e un livello di indirezione senza dare in cambio alcuna flessibilità reale. L'interfaccia si introduce quando esiste una seconda implementazione, o quando il confine è davvero un punto di sostituzione previsto, non per riflesso.
Gli altri principi SOLID, in pratica e con misura
Gli altri tre principi completano il quadro, ognuno con la sua applicazione concreta in Laravel e ognuno da dosare. L'Open/Closed suggerisce di estendere il comportamento aggiungendo classi nuove invece di modificare quelle esistenti: un buon esempio sono le strategie di calcolo (diversi tipi di sconto come classi separate che implementano un'interfaccia comune) invece di un if/else che cresce a ogni nuova regola. Il Liskov Substitution chiede che un sottotipo sia davvero sostituibile al suo tipo base senza sorprese, il che in pratica significa non far ereditare una classe da un'altra solo per riusarne il codice se poi ne rompe il contratto. L'Interface Segregation preferisce interfacce piccole e focalizzate a una grande interfaccia che costringe a implementare metodi che non servono.
Il filo che li lega tutti, e il modo in cui li uso, è che sono risposte a un cambiamento previsto, non profilassi universale. Applico l'Open/Closed dove so che le varianti cresceranno (i tipi di sconto, i metodi di spedizione); non lo applico dove la logica è stabile e una sola. La differenza tra un senior e chi ha appena letto il libro di SOLID è proprio questa: il senior sa bene che ogni principio ha un suo costo concreto in indirezione e in complessità, e lo paga soltanto dove il beneficio in flessibilità futura lo ripaga davvero. È lo stesso equilibrio che cerco quando intervengo sul refactoring di codice PHP legacy o quando sposto logica trasversale nei middleware avanzati di Laravel: la struttura giusta è quella minima che regge il cambiamento previsto, non quella massima che si possa immaginare.
Dove la separazione paga, e dove è solo indirezione
Questo è il cuore pragmatico, ed è dove la maggior parte degli articoli su SOLID mente per omissione, presentando l'astrazione come sempre buona. Non lo è. Il service layer paga, e paga molto, quando la logica è complessa, quando è condivisa tra più punti di ingresso (controller, comandi, job), e quando va testata in isolamento. In quei casi il service è esattamente il posto giusto, e ogni riga spostata lì restituisce testabilità e riuso.
Il service layer è invece solo indirezione quando avvolge un'operazione CRUD banale. Un controller che crea, legge, aggiorna e cancella un modello senza alcuna logica di business non guadagna nulla da un CategoryService che si limita a chiamare Category::create(): hai aggiunto un file, un'iniezione e un salto in più per leggere il codice, in cambio di niente. Lì il controller sottile che parla direttamente con il modello è la scelta corretta, e introdurre un service è over-engineering travestito da buona pratica. Il segnale d'allarme è quando apri un service e dentro trovi una sola riga che inoltra la chiamata al modello: quel service non sta separando nulla, sta solo aggiungendo un passaggio di lettura tra la richiesta e l'azione. Un'astrazione che non astrae niente è peggio dell'assenza di astrazione, perché costa attenzione a chi legge senza dare in cambio flessibilità a chi modifica.
La domanda da farsi prima di estrarre un service non è "è più pulito?", è "questa logica è complessa, condivisa o da testare a parte?". Se la risposta è no a tutte e tre, il service layer ti sta dando solo un file in più da aprire per capire cosa fa il codice.
La testabilità che guadagni davvero: un esempio
Il beneficio più concreto e più sottovalutato dello spostare la logica in un service è la testabilità, e vale la pena vederlo in pratica perché è ciò che ripaga il costo del refactoring. Con la logica di business chiusa nel controller, testare la regola "applica lo sconto del 10% sopra i mille euro" significa costruire una richiesta HTTP finta, passarla per il routing, gestire l'autenticazione e poi ispezionare una risposta JSON: un test lento, fragile e che verifica cinque cose insieme. Con la stessa regola in un service, il test diventa diretto e veloce.
// test unitario del service, senza toccare HTTP né database (se le dipendenze sono iniettate)
it('applica lo sconto sopra la soglia', function () {
$service = new OrderService(/* dipendenze mockate */);
$order = $service->place(['items' => [/* sopra i 1000 euro */]]);
expect($order->discount)->toBe(10.0);
});Questo test interroga la logica di business e nient'altro. Non gli importa come arriva la richiesta, non gli importa il formato della risposta: verifica una regola in isolamento, gira in millisecondi e non si rompe se cambi il routing o la serializzazione. È il genere di test che puoi avere a centinaia senza che la suite diventi lenta, ed è possibile solo perché la logica vive in un oggetto che non sa nulla di HTTP. La testabilità non è un effetto collaterale piacevole del service layer, è spesso la ragione principale per cui lo introduco: una logica che posso testare a parte è una logica di cui mi fido quando la cambio.
C'è poi il guadagno del riuso, che si manifesta nel momento meno atteso. Quella OrderService::place() che hai estratto per il controller, il giorno in cui ti serve creare ordini da un import massivo in un comando da console o da un job asincrono, è già pronta: la chiami e basta, con le stesse regole e le stesse garanzie. Se la logica fosse rimasta nel controller, l'avresti dovuta duplicare, e due copie della stessa regola di business sono due posti dove un domani la correggerai a metà.
Ogni controller ha bisogno di un service layer?
No, ed è importante dirlo chiaro contro la moda opposta. Un controller con logica di business reale, condivisa o complessa, beneficia enormemente di un service; un controller CRUD semplice no. La regola che applico è di refactorare quando il dolore appare, non in anticipo. Quando un metodo del controller supera le venti-trenta righe, quando la stessa logica serve in un secondo posto, quando scrivere un test richiede di simulare mezza richiesta HTTP: quelli sono i segnali che è ora di estrarre. Astrarre prima che il bisogno esista è scommettere su un futuro che potrebbe non arrivare, e nel frattempo paghi la complessità per niente. Il codice più mantenibile non è quello con più astrazioni, è quello con le astrazioni giuste nei posti giusti.
Tirando le somme, applicare SOLID a Laravel non significa coprire l'applicazione di interfacce e service, significa usare ciascun principio come quello che è: uno strumento per rendere gestibile un cambiamento che prevedi. Si sposta la logica di business fuori dai controller gonfi, perché lì la Single Responsibility paga sempre in testabilità e riuso; si dipende da astrazioni attraverso il container, ma si creano interfacce solo dove esiste davvero un punto di sostituzione; e si resiste alla tentazione di astrarre il CRUD banale, che dall'astrazione non guadagna nulla. Il refactoring giusto non è quello che aggiunge più livelli, è quello che mette ogni pezzo di logica dove sarà più facile trovarlo, cambiarlo e testarlo domani. Se hai un'applicazione Laravel con controller diventati ingestibili, o all'opposto un'architettura così astratta che nessuno capisce più dove vive la logica, contattami per una consulenza diretta: nella mia esperienza, il punto di equilibrio tra controller anemici e astrazione barocca è quasi sempre più vicino alla semplicità di quanto si pensi, e trovarlo vale più di qualsiasi diagramma di architettura.




