Il Service Layer è il pattern più facile da introdurre e il più facile da sbagliare: quale logica appartiene al modello Eloquent, quale al servizio, e come riconoscere il momento in cui il codice è diventato procedurale con una sintassi a oggetti
Il percorso è così comune che si può raccontare a memoria. Si comincia con la logica nei controller. Qualcuno fa notare che i controller devono essere magri, e nasce OrdineService. Per sei mesi funziona: la logica è “isolata”, i controller sono tornati di dieci righe, tutti sono contenti. Poi il file arriva a milleduecento righe, i metodi pubblici sono ventotto, il costruttore inietta nove dipendenze e, aprendo app/Models/Ordine.php, si trova questo:
class Ordine extends Model
{
protected $fillable = ['cliente_id', 'stato', 'totale', 'data_emissione'];
}Dodici righe in tutto. Il modello non sa niente di cosa sia un ordine: è una riga di tabella con un nome di classe. Tutte le regole — quando un ordine è confermabile, come si calcola il totale, quali transizioni di stato sono lecite — stanno in un servizio che le applica dall’esterno, leggendo e scrivendo proprietà pubbliche.
Questo è il punto d’arrivo prevedibile del Service Layer applicato senza criterio: un servizio che fa tutto e un modello che non fa niente. La combinazione ha due nomi noti in letteratura — God Service e Anemic Domain Model — e un difetto comune: il codice conserva la sintassi della programmazione a oggetti e ne perde completamente la sostanza. Classi, metodi, iniezione delle dipendenze, e sotto una sequenza di procedure che manipolano strutture dati passive.
Il pattern però non è sbagliato: è sbagliato il modo in cui viene applicato nella gran parte dei progetti. Questo articolo percorre i passi per costruire un Service Layer che resta magro, con i criteri espliciti per decidere, riga per riga, se un pezzo di logica appartiene al modello o al servizio.
Che cos’è davvero il Service Layer
Conviene partire dalla definizione originale, perché è più ristretta di come il pattern viene usato oggi. Martin Fowler, in Patterns of Enterprise Application Architecture, descrive il Service Layer come lo strato che definisce il confine dell’applicazione e l’insieme delle operazioni disponibili dal punto di vista di chi la usa. È una definizione di confine e di coordinamento: niente, in quella frase, dice che la logica di business debba vivere lì.
Fowler distingue poi due modi di realizzarlo, e la distinzione è esattamente il tema di questo articolo.
| Domain Facade | Operation Script | |
|---|---|---|
| Cosa contiene il servizio | Pochissimo: coordina le entità e delega | Tutta la logica dell’operazione |
| Com’è il modello | Ricco: contiene le regole e protegge il proprio stato | Anemico: contenitore di dati |
| Stile effettivo | Orientato agli oggetti | Procedurale (è di fatto un Transaction Script) |
| Regge bene con | Logica di dominio articolata, molte regole, molti stati | Logica semplice, poche regole, operazioni indipendenti |
| Come degrada | Rischia strati inutili se la logica è banale | Cresce senza limite e duplica le regole |
La seconda forma non è un errore in assoluto — per logica semplice è la scelta pragmatica e lo stesso Fowler la considera legittima. Diventa un errore quando la logica non è semplice, perché a quel punto si paga il costo della complessità senza avere gli strumenti per gestirla. Ed è lo scenario in cui finisce la maggioranza dei progetti, per una ragione banale: nessuno sceglie l’operation script, ci si arriva per accumulo.
Il tranello: perché l’Anemic Domain Model costa
Fowler ha dedicato al modello anemico un articolo del 2003 in cui lo definisce un anti-pattern, e l’argomento centrale è questo: si sostiene il costo della mappatura a oggetti — classi, relazioni, ciclo di vita — senza incassarne i benefici, che sono l’incapsulamento, le invarianti garantite e il polimorfismo. Nella pratica di un progetto Laravel i costi si manifestano in quattro modi molto concreti.
1. Le regole si duplicano, e divergono
Se la condizione “un ordine è confermabile” vive in un servizio, il secondo punto che ha bisogno della stessa verifica la riscrive. Nel codice qui sotto, la regola è scritta tre volte in tre forme diverse — e la terza, nella vista, è già sbagliata:
// OrdineService::conferma()
if ($ordine->stato !== 'bozza' && $ordine->stato !== 'attesa_pagamento') {
throw new \Exception('Ordine non confermabile');
}
// OrdineApiController::confirm()
if (! in_array($ordine->stato, ['bozza', 'attesa_pagamento'])) {
return response()->json(['errore' => 'stato non valido'], 422);
}
// resources/views/ordini/show.blade.php — qualcuno ha dimenticato 'attesa_pagamento'
@if ($ordine->stato === 'bozza')
<button>Conferma</button>
@endif2. Lo stato non è protetto
In un modello anemico ogni proprietà è scrivibile da qualunque punto. Questo significa che la frase “lo stato passa da attesa pagamento a confermato, e solo in presenza di un pagamento registrato” non è una regola del sistema: è una speranza.
// Tecnicamente legittimo, logicamente assurdo: nessuno lo impedisce
$ordine->stato = 'confermato';
$ordine->save();
// E anche questo, che salta perfino gli eventi del modello
Ordine::where('id', $id)->update(['stato' => 'spedito']);3. I calcoli derivati finiscono dove capita
Il totale di un ordine è una funzione delle sue righe: dovrebbe esistere in un posto solo. In un modello anemico viene calcolato nel servizio, poi in un helper per la vista, poi in un job per la reportistica — con tre arrotondamenti diversi e, prima o poi, tre risultati diversi sullo stesso ordine.
4. I test diventano test di procedure
Per verificare la regola di conferma bisogna istanziare il servizio con le sue nove dipendenze, salvare un ordine nel database e chiamare un metodo che fa anche altre sei cose. La regola, che è una condizione su tre campi, richiede una migrazione per essere collaudata.
La regola per decidere dove va la logica
Serve un criterio applicabile senza discussioni filosofiche, riga per riga. Questo funziona:
Se per prendere la decisione bastano i dati dell’oggetto stesso (e di ciò che gli appartiene), la logica sta nell’oggetto. Se servono altri oggetti, l’infrastruttura o il mondo esterno, la logica sta nel servizio.
Tradotto in una tabella operativa:
| Tipo di logica | Esempio | Dove va |
|---|---|---|
| Invariante dell’entità | Un ordine confermato non può avere zero righe | Modello |
| Valore derivato | Totale, margine, giorni di ritardo, percentuale di sconto | Modello |
| Transizione di stato | Da attesa pagamento a confermato | Modello (metodo dedicato) |
| Predicato di dominio | confermabile(), scaduto(), rimborsabile() | Modello |
| Coordinamento fra entità diverse | Confermare l’ordine e impegnare la scorta in magazzino | Servizio |
| Confine transazionale | Le due scritture sopra devono riuscire o fallire insieme | Servizio |
| Accesso alla persistenza | Trovare gli ordini da confermare, salvare | Servizio (o repository) |
| Effetti esterni | Email, chiamate API, PDF, code, notifiche | Servizio |
| Regole che dipendono da configurazione o da terzi | Verifica del limite di credito presso una centrale rischi | Servizio |
| Autorizzazione | Questo utente può confermare? | Policy, al confine |
Un corollario che risolve il 90% dei casi dubbi: se un metodo del servizio, dopo aver eliminato le chiamate al database e agli effetti esterni, contiene ancora degli if sui campi dell’entità, quegli if appartengono all’entità.
Passo 1 — Censire e classificare la logica esistente
Il refactoring non parte dallo spostare codice, ma dall’etichettarlo. Si prende il servizio più grosso e si scorre un metodo per volta annotando a margine, con la tabella sopra, dove ogni blocco dovrebbe stare. Il risultato tipico su un metodo di cinquanta righe è una distribuzione sorprendente: sette righe di dominio, dieci di persistenza, otto di effetti esterni, e venticinque che sono la stessa cosa scritta due volte perché il metodo gestisce due casi.
Tre domande rendono il censimento rapido:
- Questo
ifguarda solo i campi di un’entità? Allora è un predicato di dominio e ha un nome:confermabile(),scaduto(),spedibile(). - Questa assegnazione cambia lo stato di un’entità? Allora è una transizione e deve passare da un metodo con un nome, non da un
=. - Questo calcolo produce un valore che qualcun altro potrebbe volere? Allora è un valore derivato e appartiene all’entità che lo possiede.
Da questo esercizio nasce l’elenco di lavoro dei passi successivi. Vale la pena farlo su carta prima di toccare il codice, perché il risultato più frequente è scoprire che il servizio da ventotto metodi copre in realtà sei o sette operazioni distinte, e che il resto è duplicazione o codice morto.
Passo 2 — Arricchire il modello Eloquent
Qui si fa il lavoro vero, e c’è una buona notizia: Eloquent è un Active Record, quindi un modello ricco non richiede nessuna infrastruttura aggiuntiva. Bastano metodi.
L’enum che conosce le proprie transizioni
Il punto di partenza migliore è lo stato, perché è dove si concentrano le regole. Un enum con comportamento sposta la tabella delle transizioni lecite in un posto solo, verificabile e impossibile da ignorare.
namespace App\Models\Enums;
enum StatoOrdine: string
{
case Bozza = 'bozza';
case AttesaPagamento = 'attesa_pagamento';
case Confermato = 'confermato';
case Spedito = 'spedito';
case Annullato = 'annullato';
/** @return list<self> */
public function transizioniAmmesse(): array
{
return match ($this) {
self::Bozza => [self::AttesaPagamento, self::Annullato],
self::AttesaPagamento => [self::Confermato, self::Annullato],
self::Confermato => [self::Spedito, self::Annullato],
self::Spedito => [],
self::Annullato => [],
};
}
public function puoPassareA(self $nuovo): bool
{
return in_array($nuovo, $this->transizioniAmmesse(), true);
}
public function finale(): bool
{
return $this->transizioniAmmesse() === [];
}
public function etichetta(): string
{
return match ($this) {
self::Bozza => 'Bozza',
self::AttesaPagamento => 'In attesa di pagamento',
self::Confermato => 'Confermato',
self::Spedito => 'Spedito',
self::Annullato => 'Annullato',
};
}
}Il valore pratico va oltre l’eleganza: con match esaustivo, aggiungere un caso all’enum provoca un errore dove le transizioni non sono state aggiornate. La regola diventa difficile da dimenticare, che è più di quanto si ottenga da qualsiasi commento.
Il modello con i metodi di dominio
namespace App\Models;
use App\Models\Enums\StatoOrdine;
use App\Models\Casts\DenaroCast;
use App\Models\Exceptions\{OrdineNonConfermabile, TransizioneNonAmmessa};
use App\Support\Denaro;
class Ordine extends Model
{
/** Lo stato e le date di dominio NON sono assegnabili in massa */
protected $fillable = ['cliente_id', 'numero', 'note'];
protected $casts = [
'stato' => StatoOrdine::class,
'totale' => DenaroCast::class,
'data_emissione' => 'immutable_date',
'confermato_il' => 'immutable_datetime',
];
// ---------- valori derivati ----------
public function totale(): Denaro
{
return $this->righe->reduce(
fn (Denaro $somma, RigaOrdine $riga) => $somma->piu($riga->importo()),
Denaro::zero(),
);
}
public function scontoApplicato(): Percentuale
{
return $this->listino?->sconto() ?? Percentuale::zero();
}
// ---------- predicati di dominio ----------
public function confermabile(): bool
{
return $this->stato->puoPassareA(StatoOrdine::Confermato)
&& $this->righe->isNotEmpty()
&& $this->totale()->maggioreDi(Denaro::zero());
}
public function annullabile(): bool
{
return $this->stato->puoPassareA(StatoOrdine::Annullato);
}
// ---------- transizioni di stato: l'unica porta ----------
public function conferma(\DateTimeImmutable $quando, int $confermatoDa): void
{
if (! $this->confermabile()) {
throw OrdineNonConfermabile::perStato($this->numero, $this->stato);
}
$this->transizionaA(StatoOrdine::Confermato);
$this->confermato_il = $quando;
$this->confermato_da = $confermatoDa;
$this->totale = $this->totale(); // congelato al momento della conferma
}
public function annulla(string $motivo): void
{
if (! $this->annullabile()) {
throw TransizioneNonAmmessa::da($this->stato, StatoOrdine::Annullato);
}
$this->transizionaA(StatoOrdine::Annullato);
$this->motivo_annullamento = $motivo;
}
/** Unico punto in cui la colonna stato viene scritta */
private function transizionaA(StatoOrdine $nuovo): void
{
if (! $this->stato->puoPassareA($nuovo)) {
throw TransizioneNonAmmessa::da($this->stato, $nuovo);
}
$this->stato = $nuovo;
}
}Il modello ora dice cos’è un ordine. Si noti cosa non c’è dentro: nessun save(), nessun invio di email, nessuna chiamata a un’API, nessun accesso ad altri aggregati. Il metodo conferma() cambia lo stato dell’oggetto in memoria e si ferma lì — la persistenza e gli effetti sono responsabilità di chi lo ha chiamato.
Il value object tramite cast
I custom cast di Laravel sono lo strumento che permette di avere value object senza rinunciare a Eloquent, ed è probabilmente la funzionalità più sottoutilizzata del framework.
namespace App\Models\Casts;
use App\Support\Denaro;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
final class DenaroCast implements CastsAttributes
{
/** Dal database al dominio */
public function get($model, string $key, $value, array $attributes): ?Denaro
{
return $value === null ? null : Denaro::daCentesimi((int) $value);
}
/** Dal dominio al database */
public function set($model, string $key, $value, array $attributes): ?int
{
if ($value === null) { return null; }
if ($value instanceof Denaro) { return $value->inCentesimi(); }
throw new \InvalidArgumentException("{$key} richiede un oggetto Denaro");
}
}Il beneficio è che gli errori di unità di misura — euro contro centesimi, il classico fattore cento che compare in produzione — diventano impossibili per costruzione, perché il tipo non ammette un intero nudo.
Lo scope e la regola: tenerli allineati
Una trappola sottile: gli scope sono comodi, ma una regola di dominio scritta come scope esiste solo in SQL e non è utilizzabile su un oggetto in memoria. La soluzione non è eliminare gli scope — servono, perché filtrare in PHP diecimila righe è inaccettabile — ma riconoscere che sono una traduzione della stessa regola, e verificare che le due versioni coincidano.
// Nel modello: la regola, per un singolo oggetto
public function scaduto(\DateTimeImmutable $alla): bool
{
return $this->stato === StatoOrdine::AttesaPagamento
&& $this->data_scadenza_pagamento < $alla;
}
// Lo stesso predicato, tradotto per il database
public function scopeScaduti($query, \DateTimeImmutable $alla)
{
return $query->where('stato', StatoOrdine::AttesaPagamento->value)
->where('data_scadenza_pagamento', '<', $alla);
}// Il test che impedisce alle due versioni di divergere
it('lo scope scaduti coincide con il predicato scaduto()', function () {
$alla = new DateTimeImmutable('2026-10-15');
OrdineFactory::count(40)->statiEScadenzeMisti()->create();
$daSql = Ordine::scaduti($alla)->pluck('id')->sort()->values();
$daDominio = Ordine::with('righe')->get()
->filter(fn (Ordine $o) => $o->scaduto($alla))
->pluck('id')->sort()->values();
expect($daSql->all())->toBe($daDominio->all());
});È un test che costa cinque minuti e intercetta la classe di bug più insidiosa di tutte: quella in cui la schermata e il job notturno sono d’accordo sul nome della regola e in disaccordo sul suo contenuto.
Il limite onesto di Eloquent
Va detto con chiarezza, perché molti articoli sul tema lo omettono: in un Active Record le invarianti non sono garantite, sono una convenzione. Tutte queste strade restano aperte e scavalcano i metodi di dominio:
$ordine->stato = StatoOrdine::Spedito; // assegnazione diretta
Ordine::where('id', $id)->update(['stato' => 'spedito']); // salta modello ed eventi
DB::table('ordini')->update(['stato' => 'spedito']); // salta anche EloquentTre accorgimenti riducono il rischio a livelli accettabili, e vale la pena adottarli tutti e tre. Il primo: tenere fuori da $fillable le colonne di dominio, così che nessun fill() o create() le tocchi. Il secondo: attivare in sviluppo la verifica che Laravel offre contro gli attributi scartati silenziosamente, così che un tentativo di assegnazione in massa non ammessa esploda invece di passare inosservato.
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
}Il terzo: un test di architettura che vieti ai modelli di dipendere dall’infrastruttura, in modo che l’anemia non torni dalla porta di servizio.
// tests/Architecture/ModelliTest.php (test di architettura di Pest)
arch('i modelli non inviano email né chiamano API')
->expect('App\Models')
->not->toUse([
\Illuminate\Support\Facades\Mail::class,
\Illuminate\Support\Facades\Http::class,
\Illuminate\Support\Facades\Notification::class,
]);Se le invarianti sono critiche e non possono dipendere dalla disciplina del team, la risposta non è combattere Eloquent: è separare l’entità di dominio dal record di persistenza e usare un repository per la traduzione. È un costo reale, e va speso dove serve davvero — tipicamente su uno o due aggregati, non su tutti.
Passo 3 — Definire il confine e la firma del servizio
Con il modello che si difende da solo, il servizio ha molto meno da fare. Quattro regole ne definiscono la forma.
Un servizio per gruppo di operazioni coese, non per entità. La differenza è sostanziale: OrdineService attira qualunque cosa riguardi gli ordini e cresce senza limite; ConfermaOrdini o EvasioneOrdini hanno un confine naturale. La regola spartana e che funziona: massimo cinque metodi pubblici. Al sesto, il servizio sta accumulando responsabilità e va diviso.
Un metodo per operazione di business, con un nome che è un verbo. conferma(), annulla(), evadi(). Non processa(), non gestisci(), non handle(): i verbi vaghi sono quelli che poi contengono tre operazioni.
Niente parametri booleani. conferma($id, bool $senzaNotifica = false) sono due operazioni in un metodo, con il doppio dei percorsi da collaudare e un nome che non descrive nessuno dei due.
Niente oggetti del framework nella firma. Un metodo che accetta Request funziona solo via HTTP: è inservibile da un comando Artisan, da un job o da un pannello amministrativo. L’input è un DTO tipizzato o i dati espliciti.
Passo 4 — Scrivere il servizio come facciata di dominio
Questa è la forma a cui si vuole arrivare. Si confronti con il metodo da cinquanta righe da cui siamo partiti.
namespace App\Services;
use App\Models\Ordine;
use App\Models\Exceptions\OrdineNonConfermabile;
use Illuminate\Database\DatabaseManager;
use Illuminate\Contracts\Events\Dispatcher;
final readonly class ConfermaOrdini
{
public function __construct(
private DatabaseManager $db,
private ServizioMagazzino $magazzino,
private Dispatcher $eventi,
) {}
/**
* @throws OrdineNonConfermabile quando lo stato o il contenuto non lo consentono
* @throws ScortaInsufficiente quando il magazzino non copre le righe
*/
public function conferma(Ordine $ordine, DatiConferma $dati): Ordine
{
$this->db->transaction(function () use ($ordine, $dati) {
// La decisione è dell'entità: il servizio non sa cosa rende confermabile un ordine
$ordine->conferma($dati->quando, $dati->confermatoDa);
// Coordinamento fra aggregati: questo invece è compito del servizio
$this->magazzino->impegna($ordine->righe(), forza: $dati->forzaScorta);
$ordine->save();
});
// Effetti esterni: fuori dalla transazione, dopo il commit
$this->eventi->dispatch(new OrdineConfermato($ordine->id, $dati->quando));
return $ordine;
}
public function confermaLotto(Collection $ordini, DatiConferma $dati): EsitoLotto
{
$confermati = [];
$errori = [];
foreach ($ordini as $ordine) {
try {
$confermati[] = $this->conferma($ordine, $dati);
} catch (OrdineNonConfermabile | ScortaInsufficiente $e) {
// Transazione per ordine: un fallimento non annulla gli altri
$errori[$ordine->numero] = $e->getMessage();
}
}
return new EsitoLotto($confermati, $errori);
}
}Dodici righe nel metodo principale, e ognuna fa una cosa diversa: apre la transazione, chiede all’entità di cambiare stato, coordina il magazzino, persiste, pubblica l’evento. Non c’è un solo if di dominio, perché non ce n’è bisogno — le condizioni le verifica l’oggetto che possiede i dati.
Il metodo per i lotti mostra il valore della separazione: il confine transazionale per singolo ordine, nel caso massivo, è una decisione di orchestrazione, e sta esattamente dove deve stare. In un servizio con la logica dentro, lo stesso requisito avrebbe significato duplicare cinquanta righe.
Il controller che ne consegue
final class ConfermaOrdineController
{
public function __invoke(
Ordine $ordine, // route binding
ConfermaOrdineRequest $request,
ConfermaOrdini $servizio,
): RedirectResponse {
$this->authorize('conferma', $ordine); // l'autorizzazione sta qui, non nel servizio
try {
$servizio->conferma($ordine, DatiConferma::daRequest($request));
} catch (OrdineNonConfermabile | ScortaInsufficiente $e) {
return back()->withErrors(['ordine' => $e->getMessage()]);
}
return to_route('ordini.show', $ordine)->with('stato', 'Ordine confermato');
}
}E la vista, che prima duplicava la regola, ora la chiede a chi la conosce:
@if ($ordine->confermabile() && auth()->user()->can('conferma', $ordine))
<button>Conferma ordine</button>
@endifPasso 5 — Transazioni, eventi e confini infrastrutturali
Tre regole che valgono per qualsiasi servizio e che, violate, producono i bug che si manifestano solo sotto carico.
Il confine transazionale sta nel servizio, mai nel modello. Un metodo di dominio che apre una transazione impedisce al chiamante di comporre più operazioni in un’unica unità di lavoro. Quando i servizi si chiamano fra loro, il confine appartiene al più esterno: le transazioni annidate di Laravel si innestano con i savepoint, quindi la chiamata interna non rompe quella esterna, ma non la sostituisce.
Le chiamate a sistemi esterni stanno fuori dalla transazione. Un’API di spedizione con tre secondi di timeout, invocata dentro una transazione, tiene i lock sulle righe per tutta la sua durata. È la causa più comune di deadlock in produzione, e il test in locale non la rivela perché in locale l’API risponde in venti millisecondi.
Gli eventi si pubblicano dopo il commit. Un listener che parte prima può leggere uno stato non ancora scritto o reagire a un’operazione che verrà annullata. Laravel offre due strumenti: la proprietà $afterCommit su job e listener in coda, e DB::afterCommit() per il codice sincrono.
final class InviaConfermaOrdineListener implements ShouldQueue
{
public bool $afterCommit = true; // non parte se la transazione va in rollback
public function handle(OrdineConfermato $evento): void { /* ... */ }
}Passo 6 — Testare modello e servizio separatamente
Con le responsabilità al posto giusto, i test si dividono in due famiglie e ciascuna diventa più semplice.
Il modello: le regole, senza database
Un modello Eloquent si può istanziare senza toccare il database, purché non si chiami save(). È questo che rende i test delle regole istantanei.
it('non è confermabile se non ha righe', function () {
$ordine = OrdineFactory::make(['stato' => StatoOrdine::AttesaPagamento]); // make, non create
$ordine->setRelation('righe', collect());
expect($ordine->confermabile())->toBeFalse();
});
it('non ammette la transizione da spedito a confermato', function () {
$ordine = OrdineFactory::make(['stato' => StatoOrdine::Spedito]);
expect(fn () => $ordine->conferma(new DateTimeImmutable(), confermatoDa: 1))
->toThrow(OrdineNonConfermabile::class);
});
it('congela il totale al momento della conferma', function () {
$ordine = OrdineFactory::make(['stato' => StatoOrdine::AttesaPagamento]);
$ordine->setRelation('righe', collect([
RigaOrdineFactory::make(['quantita' => 2, 'prezzo_unitario' => Denaro::daEuro(50)]),
]));
$ordine->conferma($istante = new DateTimeImmutable('2026-10-15 09:00'), confermatoDa: 7);
expect($ordine->stato)->toBe(StatoOrdine::Confermato)
->and($ordine->confermato_il)->toEqual($istante)
->and($ordine->totale->inCentesimi())->toBe(10_000);
});Nessuna migrazione, nessun RefreshDatabase, nessun orologio congelato — l’istante arriva come parametro. Questi test si rompono solo quando cambia la regola, che è l’unico motivo accettabile.
Il servizio: il coordinamento, con i doppi
it('non impegna la scorta se l\'ordine non è confermabile', function () {
$magazzino = new ServizioMagazzinoFinto();
$servizio = new ConfermaOrdini(app(DatabaseManager::class), $magazzino, new EventiFinti());
$ordine = OrdineFactory::create(['stato' => StatoOrdine::Spedito]);
expect(fn () => $servizio->conferma($ordine, DatiConferma::adesso()))
->toThrow(OrdineNonConfermabile::class);
expect($magazzino->impegniEffettuati())->toBeEmpty();
});
it('annulla tutto se il magazzino rifiuta', function () {
$servizio = new ConfermaOrdini(
app(DatabaseManager::class),
new ServizioMagazzinoFinto(rifiutaSempre: true),
new EventiFinti(),
);
$ordine = OrdineFactory::create(['stato' => StatoOrdine::AttesaPagamento]);
expect(fn () => $servizio->conferma($ordine, DatiConferma::adesso()))
->toThrow(ScortaInsufficiente::class);
// Il rollback ha funzionato: lo stato sul database non è cambiato
expect($ordine->fresh()->stato)->toBe(StatoOrdine::AttesaPagamento);
});Il servizio viene collaudato su ciò che gli compete — l’ordine delle operazioni, il rollback, il non fare cose quando il dominio dice no — e non sulle regole, che sono già coperte dai test del modello. Nessun test duplicato, e quando una regola cambia si aggiorna un test solo.
Passo 7 — Impedire al servizio di ingrassare
Il God Service non nasce: si forma, una modifica alla volta. Cinque segnali anticipano il problema, e vanno trattati come soglie da sorvegliare, non come questioni di gusto.
| Segnale | Soglia | Che cosa significa |
|---|---|---|
| Metodi pubblici | più di 5 | Il servizio raccoglie operazioni non coese: va diviso |
| Dipendenze nel costruttore | più di 4 | Ogni chiamante ne costruisce otto per usarne due |
| Righe in un metodo | più di 30 | Dentro ci sono più operazioni, o logica di dominio da restituire al modello |
if sui campi di un’entità | più di 0 | Quella condizione appartiene all’entità |
| Parametri booleani | più di 0 | Due comportamenti in un metodo |
Il controllo si automatizza con poche righe di riflessione, da eseguire in integrazione continua insieme ai test:
it('nessun servizio supera cinque metodi pubblici o quattro dipendenze', function () {
$servizi = glob(app_path('Services/*.php'));
foreach ($servizi as $file) {
$classe = 'App\\Services\\' . basename($file, '.php');
$riflesso = new ReflectionClass($classe);
$pubblici = array_filter(
$riflesso->getMethods(ReflectionMethod::IS_PUBLIC),
fn ($m) => $m->class === $classe && ! $m->isConstructor(),
);
expect(count($pubblici))->toBeLessThanOrEqual(5, "{$classe} ha troppi metodi pubblici");
expect($riflesso->getConstructor()?->getNumberOfParameters() ?? 0)
->toBeLessThanOrEqual(4, "{$classe} ha troppe dipendenze");
}
});Può sembrare pedante, ed è invece il meccanismo che evita la riunione di refactoring fra due anni. Una soglia controllata da una macchina non si negozia a fine sprint.
L’uscita di sicurezza: dal servizio alle operazioni singole
Quando un servizio supera comunque le soglie e non si riesce a dividerlo in gruppi coesi, il passo successivo è portare ogni operazione in una classe propria — una classe per operazione, con un solo punto d’ingresso. È lo stesso principio spinto al limite: il servizio raggruppa poche operazioni affini, la classe singola ne contiene una. La scelta fra i due livelli è una questione di quante operazioni esistono e quanto condividono; la cosa che non funziona è il livello intermedio mal gestito, cioè il servizio che raggruppa per entità.
Quando il modello anemico è una scelta legittima
Sarebbe disonesto chiudere senza questa sezione, perché la posizione “modelli sempre ricchi” è altrettanto sbagliata della sua opposta.
Il CRUD non ha dominio da modellare. Un’entità Tag con nome e slug non ha invarianti, transizioni né valori derivati. Un modello con solo $fillable è esattamente la quantità giusta di codice: aggiungere metodi di dominio dove non c’è dominio produce cerimonia.
Per logica semplice, il Transaction Script è corretto. Se un’operazione è una sequenza lineare — leggi, calcola, scrivi, notifica — senza regole condizionali e senza stati, una procedura in una classe è più leggibile di un modello ricco. Il criterio è l’asimmetria: le zone del dominio hanno densità diverse, e un’applicazione può legittimamente avere due aggregati modellati con cura e quaranta tabelle gestite in modo diretto.
Le letture e il reporting non hanno bisogno di entità. Una schermata con dodici colonne calcolate e tre join non trae beneficio dall’idratazione di entità complete: un oggetto di sola lettura popolato dal query builder è più rapido e più chiaro. Caricare un aggregato per poi usarne due campi è lavoro sprecato.
I pannelli amministrativi generati lavorano con i modelli. Strumenti come Filament e Nova hanno bisogno del modello Eloquent e del suo query builder per funzionare. La convivenza che funziona è dichiarare i confini: il pannello amministrativo opera direttamente sui modelli per il CRUD, e invoca i servizi per le operazioni con regole.
Il criterio riassuntivo, in una domanda: esistono regole che devono valere sempre, indipendentemente da chi tocca questo oggetto? Se sì, quelle regole appartengono all’oggetto. Se l’unica regola è “i campi obbligatori devono essere compilati”, il posto giusto è la validazione al confine, e il modello può restare quello che è.
Otto errori che trasformano il Service Layer in codice procedurale
- Un servizio per entità.
OrdineServiceattira per gravità tutto ciò che riguarda gli ordini, e cresce senza avere un confine. Il raggruppamento corretto è per operazioni coese. - Assegnare lo stato dall’esterno.
$ordine->stato = 'confermato'in un servizio significa che le transizioni lecite non sono difese da nulla e che il prossimo servizio le interpreterà a modo suo. - Lasciare i calcoli derivati nel servizio. Il totale, il margine, i giorni di ritardo appartengono all’entità che possiede i dati. Fuori, si moltiplicano in versioni che divergono.
- Mettere
save()e gli effetti esterni nei metodi di dominio. Un metodo di modello che salva e invia email non è componibile: il chiamante perde il controllo della transazione e non può provarlo senza una casella di posta finta. - Accettare
Requestnella firma del servizio. Lo lega a HTTP e lo rende inutilizzabile da comandi, code e pannelli — che sono esattamente i punti dove la duplicazione delle regole poi nasce. - L’autorizzazione dentro il servizio. Un
Gate::authorize()nel metodo rende il servizio inservibile dai contesti senza utente autenticato: comandi schedulati, webhook, importazioni. - I parametri booleani.
conferma($ordine, true, false, true)è un metodo che fa otto cose diverse e non ne descrive nessuna. - Testare le regole attraverso il servizio. Se per verificare che un ordine spedito non sia confermabile servono una migrazione e tre doppi, la regola è nel posto sbagliato — e il test lo stava dicendo.
Checklist: il servizio e il modello sono al posto giusto?
- Il modello ha metodi di dominio con nomi del linguaggio aziendale, non solo
$fillable. - Le transizioni di stato passano da metodi dedicati; la colonna dello stato è scritta in un solo punto.
- Le colonne di dominio non sono assegnabili in massa.
- I valori derivati sono calcolati nell’entità che possiede i dati.
- I predicati di dominio esistono come metodi e vengono usati anche dalle viste.
- Dove una regola esiste anche come scope, un test verifica che le due versioni coincidano.
- Nessun metodo di modello esegue
save(), invia email o chiama API. - Nessun servizio ha più di cinque metodi pubblici o più di quattro dipendenze.
- Nessun metodo di servizio contiene
ifsui campi di un’entità. - Nessuna firma di servizio accetta
Requesto parametri booleani. - Il confine transazionale è nel servizio; le chiamate esterne e gli eventi stanno fuori dalla transazione.
- Le regole di dominio hanno test che girano senza database.
- Le soglie dei servizi sono verificate automaticamente in integrazione continua.
Conclusioni
Il Service Layer non è lo strato in cui mettere la logica di business: è lo strato che definisce il confine dell’applicazione e coordina le operazioni. Quando lo si interpreta nel primo modo si ottiene un God Service e un modello anemico — codice che ha la forma della programmazione a oggetti e il funzionamento di una sequenza di procedure, con il costo di entrambe e i vantaggi di nessuna delle due.
La linea di demarcazione è più semplice di quanto il dibattito suggerisca, e sta in una domanda da porsi per ogni riga: per decidere questo, mi bastano i dati di questo oggetto? Se sì — invarianti, calcoli derivati, transizioni di stato, predicati — la logica appartiene al modello. Se servono altri aggregati, il database, un’API o il mondo esterno, appartiene al servizio. Applicato con costanza, questo criterio produce servizi che restano di dodici righe e modelli che dicono cos’è un ordine, e nessuna delle due cose richiede un’architettura elaborata per esistere.
Il consiglio operativo per partire su un progetto già avviato è di non riscrivere niente in blocco. Si prende il metodo di servizio più lungo, si evidenziano le condizioni che guardano solo i campi di un’entità, e si spostano in quell’entità sotto forma di predicati con un nome. Poi si fa la stessa cosa con le assegnazioni di stato, che diventano metodi. Al terzo metodo spostato il servizio si è già accorciato di metà, i primi test senza database sono scritti, e la direzione è chiara — che è molto più di quanto si ottenga da un piano di rifacimento completo che non partirà mai.

