Fat Controller in Laravel 12: dal controller da 200 righe a Service Layer, Action pattern e Dependency Injection

Fat Controller in Laravel 12: dal controller da 200 righe a Service Layer, Action pattern e Dependency Injection

In una piattaforma marketplace con migliaia di utenti attivi, l'OrderController di un'applicazione Laravel 10 conteneva un metodo store() di 180 righe: validazione inline, calcolo totali, verifica disponibilità prodotti, decremento stock, creazione ordine e item, invio notifiche, logging - tutto nello stesso metodo. Quando è stato necessario replicare la logica di creazione ordine in un comando Artisan per l'importazione bulk, il team ha copiato 120 righe dal controller. Due mesi dopo, una modifica alla logica di sconto è stata applicata nel controller ma non nel comando - un bug in produzione che un test unitario avrebbe intercettato, se la logica fosse stata testabile in isolamento. Robert C. Martin definisce il principio fondamentale della Clean Architecture: le dipendenze del codice sorgente possono puntare solo verso l'interno, dai framework verso la logica di business, mai il contrario. Un controller che contiene logica di business viola questa regola alla radice.

Cos'è un Fat Controller e perché il Service Layer è la prima estrazione da fare?

Un Fat Controller è un controller che gestisce validazione, logica di business, accesso ai dati e side-effect (notifiche, eventi) nello stesso metodo. Viola il Single Responsibility Principle (SOLID) - "un modulo deve essere responsabile verso un solo attore" - perché ha molteplici motivi per cambiare: una modifica al calcolo dei prezzi, un cambio nello schema del database, una nuova regola di notifica. Martin Fowler definisce il Service Layer come "il confine dell'applicazione con un layer di servizi che stabilisce l'insieme delle operazioni disponibili e coordina la risposta dell'applicazione a ciascuna operazione." In pratica, il Service Layer estrae la logica di business dal controller in una classe dedicata, iniettabile e testabile.

L'Architettura Esagonale (Alistair Cockburn, 2005) formalizza questa separazione: l'applicazione è al centro, i framework (Laravel, Eloquent) e i dispositivi I/O (HTTP, database) sono adattatori esterni intercambiabili. Il controller è un adattatore che traduce una richiesta HTTP in una chiamata al servizio - non contiene logica, la delega. Fowler definisce il Repository come mediatore tra dominio e data mapping, ma nella comunità Laravel la discussione è aperta: Eloquent è già un'astrazione, e wrapparlo in un Repository aggiunge ceremony senza beneficio nella maggior parte dei casi. Il Service Layer, invece, è quasi sempre il primo refactoring ad alto impatto.

Come refactorizzare un Fat Controller con Service e Dependency Injection in Laravel 12?

Il Service Container di Laravel risolve automaticamente le dipendenze dal type-hint del costruttore. Un servizio type-hintato nel controller viene iniettato senza configurazione aggiuntiva - basta che la classe sia istanziabile o che un'interfaccia sia bindata a un'implementazione in un ServiceProvider:

/* PRIMA: Fat Controller - 5 responsabilità in un metodo */
class OrderController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $data = $request->validate([/* regole */]);
        $user = Auth::user();
        $total = 0;
        $items = [];

        DB::beginTransaction();
        foreach ($data['products'] as $input) {
            $product = Product::findOrFail($input['id']);
            if ($product->stock < $input['quantity']) {
                DB::rollBack();
                return response()->json(['error' => "Stock insufficiente"], 409);
            }
            $product->decrement('stock', $input['quantity']);
            $total += $product->price * $input['quantity'];
            $items[] = ['product_id' => $product->id, 'quantity' => $input['quantity'],
                        'price_at_purchase' => $product->price];
        }
        $order = Order::create(['user_id' => $user->id, 'total' => $total,
                                'address' => $data['address'], 'status' => 'pending']);
        $order->items()->createMany($items);
        $user->notify(new OrderPlacedNotification($order));
        DB::commit();

        return response()->json(['order_id' => $order->id], 201);
    }
}

/* DOPO: Controller snello + Service con logica di business */
class OrderController extends Controller
{
    public function store(
        StoreOrderRequest $request,
        OrderProcessingService $service
    ): JsonResponse {
        $order = $service->placeOrder($request->user(), $request->validated());
        return OrderResource::make($order)->response()->setStatusCode(201);
    }
}

/* app/Services/OrderProcessingService.php */
class OrderProcessingService
{
    public function placeOrder(User $user, array $data): Order
    {
        return DB::transaction(function () use ($user, $data) {
            $total = 0;
            $items = [];

            foreach ($data['products'] as $input) {
                $product = Product::lockForUpdate()->findOrFail($input['id']);
                if ($product->stock < $input['quantity']) {
                    throw new ProductOutOfStockException($product->name);
                }
                $product->decrement('stock', $input['quantity']);
                $total += $product->price * $input['quantity'];
                $items[] = ['product_id' => $product->id,
                            'quantity' => $input['quantity'],
                            'price_at_purchase' => $product->price];
            }

            $order = $user->orders()->create([
                'total' => $total,
                'address' => $data['address'],
                'status' => 'pending',
            ]);
            $order->items()->createMany($items);

            OrderPlaced::dispatch($order);

            return $order;
        });
    }
}

Il controller "dopo" ha 4 righe. La StoreOrderRequest gestisce la validazione, il servizio contiene la logica, l'evento OrderPlaced disaccoppia le notifiche. Il servizio usa Eloquent direttamente - non un Repository - perché per un singolo database relazionale Eloquent è già l'astrazione. La lockForUpdate() previene race condition sullo stock - un miglioramento che nel Fat Controller era assente. Come alternativa ai servizi, il pattern Action (Freek Van der Herten, Spatie) usa classi con un singolo metodo execute() - ideale quando l'operazione non ha bisogno di stato interno o di metodi multipli.

/* Test unitario: la logica di business è testabile in isolamento */
public function test_place_order_calculates_total_and_decrements_stock(): void
{
    $user = User::factory()->create();
    Product::factory()->create(['id' => 1, 'price' => 50.00, 'stock' => 10]);
    Product::factory()->create(['id' => 2, 'price' => 30.00, 'stock' => 5]);

    $service = app(OrderProcessingService::class);
    $order = $service->placeOrder($user, [
        'products' => [
            ['id' => 1, 'quantity' => 2],
            ['id' => 2, 'quantity' => 1],
        ],
        'address' => 'Via Roma 1, 00100 Roma',
    ]);

    $this->assertEquals(130.00, $order->total);
    $this->assertEquals(8, Product::find(1)->stock);
    $this->assertEquals(4, Product::find(2)->stock);
    $this->assertCount(2, $order->items);
}

public function test_place_order_throws_when_stock_insufficient(): void
{
    $user = User::factory()->create();
    Product::factory()->create(['id' => 1, 'price' => 50.00, 'stock' => 1]);

    $this->expectException(ProductOutOfStockException::class);

    app(OrderProcessingService::class)->placeOrder($user, [
        'products' => [['id' => 1, 'quantity' => 5]],
        'address' => 'Via Roma 1',
    ]);
}

Errori comuni nel refactoring architetturale Laravel

Il primo errore è wrappare Eloquent in un Repository per ogni model. Creare UserRepositoryInterface, EloquentUserRepository, RepositoryServiceProvider per un User::find($id) aggiunge 3 file e 50 righe di codice senza valore. Il Repository ha senso quando l'accesso ai dati è complesso (query con join multipli, aggregazioni, caching), quando serve sostituire la sorgente dati (da MySQL a API esterna) o nei test dove si vuole un'implementazione in-memory. Per CRUD standard, Eloquent nel servizio è sufficiente.

Il secondo è estrarre servizi troppo granulari. Un OrderCalculationService, un StockManagementService, un OrderNotificationService e un OrderPersistenceService per un'operazione che ha senso come transazione atomica frammentano la logica senza migliorare la comprensibilità. La regola: un servizio per caso d'uso (use case), non un servizio per responsabilità tecnica.

Il terzo è non usare il Service Container per la Dependency Injection. Istanziare servizi con new OrderProcessingService() nel controller rende impossibile il mocking nei test e crea accoppiamento diretto. Il type-hint nel costruttore del controller lascia che Laravel risolva le dipendenze automaticamente - e permette di sostituire l'implementazione con un mock in un test con $this->mock(OrderProcessingService::class).

Il quarto è refactorizzare senza test. Estrarre logica da un controller funzionante in un servizio introduce il rischio di regressione. Il pattern sicuro: scrivere un feature test che copre il comportamento attuale del controller, poi estrarre la logica nel servizio senza modificarla, poi verificare che il test passi ancora. Solo dopo si può migliorare la logica nel servizio con la sicurezza della copertura. I test automatici sono il prerequisito del refactoring, non una fase successiva. La validazione con Rule Objects estrae un'altra responsabilità dal controller - insieme al Service Layer copre i due refactoring a più alto impatto per applicativi Laravel maturi. Per conoscere il mio approccio al refactoring architetturale, visita la mia pagina professionale. Se i tuoi controller superano le 100 righe e la logica di business è duplicata tra controller e comandi Artisan, contattami per una consulenza dedicata - partiamo dall'identificazione dei Fat Controller e dall'estrazione dei servizi critici.

Ultima modifica: