Dall’interfaccia al binding nel container: come costruire un repository che serve davvero a qualcosa, e come riconoscere quello che non serve a niente
Il Repository pattern è probabilmente l’argomento più litigioso dell’ecosistema Laravel. Da una parte c’è chi lo considera indispensabile in qualsiasi applicazione seria; dall’altra chi lo bolla come astrazione inutile costruita sopra un’astrazione che già esiste. Entrambe le posizioni hanno ragione, e hanno ragione sullo stesso codice: dipende interamente da come il repository è scritto.
Perché la maggior parte dei repository che si incontrano nei progetti reali assomiglia a questo:
class OrdineRepository
{
public function all() { return Ordine::all(); }
public function find($id) { return Ordine::find($id); }
public function create(array $data) { return Ordine::create($data); }
public function update($id, array $d) { return Ordine::find($id)->update($d); }
public function delete($id) { return Ordine::destroy($id); }
public function query() { return Ordine::query(); } // il colpo di grazia
}Questa classe non disaccoppia nulla. Ha gli stessi metodi di Eloquent, gli stessi tipi di ritorno di Eloquent, restituisce modelli Eloquent e — con quell’ultimo metodo — consegna il query builder a chiunque lo chieda. Ha aggiunto un file, un’interfaccia, un binding nel container e zero libertà: il giorno in cui si volesse cambiare qualcosa, ogni chiamante andrebbe riscritto comunque. È il cargo cult del pattern, e chi lo critica sta criticando esattamente questo.
Il repository utile è un’altra cosa, e nasce da una domanda diversa. Non “come incapsulo Eloquent”, ma “di quali dati ha bisogno la mia logica di business, e come li chiederebbe se il database non esistesse?”. Questo articolo percorre gli otto passi che portano da un controller pieno di query a un dominio che non sa nemmeno che Eloquent sia installato — con l’avvertenza, che vale la pena anticipare, che l’ultimo capitolo spiega quando tutto questo è tempo perso.
Che problema risolve il repository (e quali non risolve)
Prima dei passi operativi conviene essere precisi sui benefici, perché i motivi che si citano di solito non sono tutti veri.
I tre benefici reali
La logica di business diventa leggibile e testabile senza database. È il beneficio principale e il più sottovalutato. Un servizio che dipende da un’interfaccia può essere collaudato con un’implementazione in memoria: i test passano da secondi a millisecondi, e soprattutto diventano test della regola anziché test dello schema.
Le necessità del dominio diventano esplicite. Un’interfaccia con cinque metodi dai nomi parlanti — apertiDelCliente(), scadutiAllaData() — documenta cosa l’applicazione chiede alla persistenza. Un modello Eloquent con trenta scope non documenta niente: dice solo che qualcuno, una volta, ha avuto bisogno di filtrare per quella colonna.
I dettagli di persistenza restano in un posto solo. Se la tabella degli ordini viene partizionata, se le righe migrano su un altro servizio, se si aggiunge una cache: si modifica una classe. Senza repository, si modificano tutti i punti in cui qualcuno ha scritto Ordine::where(...), e quei punti sono sempre più di quanti si ricordi.
I due benefici che vengono citati e non esistono
“Serve per poter cambiare database.” Non è vero, o meglio: è vero ma irrilevante. Per cambiare da MySQL a PostgreSQL basta Eloquent. Il repository serve a cambiare sorgente — dal database a un’API, a una cache, a un servizio esterno — che è un caso raro ma reale; passare a un altro motore SQL non è mai stato il problema.
“Serve a rispettare i principi SOLID.” Il pattern non è un obiettivo: è uno strumento. Un repository introdotto per conformità a un principio, senza un beneficio concreto identificabile, aggiunge indirezione e complessità di navigazione al codice. La domanda giusta non è “sto rispettando la dependency inversion”, è “che cosa posso fare oggi che ieri non potevo”.
Passo 1 — Riconoscere dove Eloquent è penetrato nella logica
Il primo lavoro è diagnostico: capire dove la persistenza si è mescolata alle regole di business. I sintomi sono riconoscibili e si cercano con un grep.
- Query nei controller.
Ordine::where('stato', 'aperto')->whereDate(...)->get()dentro un metodo che dovrebbe solo coordinare. - Query nelle viste Blade.
@foreach($cliente->ordini()->where(...)->get() as $o): il caso peggiore, perché la regola di business vive nel livello di presentazione ed è invisibile a qualsiasi test. - Regole di business dentro gli scope.
scopeAttivo($q) => $q->where('stato', 'aperto')->where('scadenza', '>', now()). La definizione di “attivo” è una decisione aziendale e sta in una classe che dovrebbe occuparsi di righe di tabella. - Modelli che sanno troppo. Un modello Eloquent di novecento righe che invia email, chiama API di pagamento e genera PDF nei suoi eventi.
- Array grezzi che attraversano l’applicazione.
$repo->create($request->all()): nessuno sa più quali campi esistano davvero, e la validazione è l’unico argine fra l’input HTTP e il database.
Un indicatore sintetico e brutale: quante classi diverse menzionano il nome di un modello Eloquent? Se la risposta è quaranta, ci sono quaranta punti da modificare a ogni cambio di schema. È questo numero, non un principio architetturale, la misura del problema.
Passo 2 — Derivare l’interfaccia dai casi d’uso, non dal CRUD
Questo è il passo che separa un repository utile da uno decorativo, e la regola è una sola: l’interfaccia si scrive guardando i chiamanti, non la tabella.
Il metodo concreto: si elencano le operazioni che la logica di business compie davvero, con le parole del dominio. In un sistema di ordini suonano così — “recupera l’ordine su cui devo lavorare”, “trova gli ordini ancora aperti di questo cliente, perché non può averne più di tre”, “salva le modifiche che ho fatto all’ordine”, “elenca gli ordini scaduti da riconciliare stanotte”. Sono quattro frasi, e diventano quattro metodi.
Notare cosa non compare in quell’elenco: nessuno ha detto “restituiscimi tutti gli ordini”, perché nessun caso d’uso reale carica in memoria l’intera tabella. E nessuno ha detto “aggiorna l’ordine con questo array di campi”, perché un aggiornamento nasce sempre da un’operazione di dominio con un nome — confermare, annullare, spedire.
Un repository per aggregato, non per tabella
La seconda regola di progettazione: il confine del repository è l’aggregato, cioè il gruppo di entità che si modificano insieme e che devono restare coerenti fra loro. Un ordine con le sue righe è un aggregato: le righe non hanno vita propria, non si salvano da sole, non si cercano indipendentemente. Quindi esiste OrdineRepository e non esiste RigaOrdineRepository, anche se nel database ci sono due tabelle.
La regola pratica per riconoscere l’aggregato: se per rispondere a una domanda devi caricare A per arrivare a B, e B non ha senso da solo, B fa parte dell’aggregato di A. Un repository per tabella riproduce lo schema del database nella struttura delle classi, ed è esattamente il disaccoppiamento che si voleva evitare.
Passo 3 — Scrivere il contratto
Con i casi d’uso in mano, l’interfaccia si scrive in dieci minuti. La struttura delle cartelle che uso — una fra le tante possibili — separa il dominio, che non dipende da nulla, dall’infrastruttura, che dipende da tutto.
app/
Ordini/
Domain/
Ordine.php ← l'entità, con le regole di business
RigaOrdine.php
OrdineId.php ← value object
StatoOrdine.php ← enum
OrdineRepository.php ← l'INTERFACCIA sta nel dominio
OrdineNonTrovato.php ← eccezione di dominio
Application/
ConfermaOrdine.php ← il caso d'uso
AnnullaOrdine.php
Infrastructure/
OrdineRepositoryEloquent.php ← l'IMPLEMENTAZIONE sta fuori
OrdineRecord.php ← il modello Eloquent, nascosto qui dentroLa collocazione dell’interfaccia nel dominio non è pignoleria: è la dipendenza invertita fatta di cartelle. Il dominio dichiara ciò di cui ha bisogno, l’infrastruttura si adegua. Se domani si cancellasse la cartella Infrastructure, il dominio continuerebbe a compilare.
namespace App\Ordini\Domain;
interface OrdineRepository
{
/** Genera un identificativo nuovo senza toccare il database */
public function nuovoId(): OrdineId;
/** @throws OrdineNonTrovato quando l'ordine non esiste */
public function perId(OrdineId $id): Ordine;
public function trovaPerId(OrdineId $id): ?Ordine;
/** @return list<Ordine> */
public function apertiDelCliente(ClienteId $cliente): array;
/** @return list<Ordine> */
public function scadutiAlla(\DateTimeImmutable $data): array;
public function salva(Ordine $ordine): void;
public function rimuovi(Ordine $ordine): void;
}Le quattro scelte che rendono il contratto onesto
I tipi di ritorno non nominano mai Eloquent. Nessun Builder, nessun Model, nessuna Eloquent\Collection. Se un metodo restituisce un Builder, il chiamante può concatenarci qualsiasi cosa e l’incapsulamento è finito prima di cominciare. È il singolo errore che annulla tutto il lavoro.
L’assenza esplicita è informazione. Due metodi per la ricerca per id: perId() lancia un’eccezione, trovaPerId() restituisce null. La differenza non è stilistica — dice al chiamante se l’assenza è un caso previsto o un errore di programmazione, e gli risparmia un controllo su null nel 90% dei casi in cui l’ordine c’è per costruzione.
salva() riceve l’entità intera, non un array. Non esiste aggiorna(int $id, array $campi): la logica modifica l’oggetto attraverso i suoi metodi e poi lo consegna al repository. Il repository non decide cosa cambia, si limita a rendere durevole uno stato.
L’identificativo lo genera il dominio. Il metodo nuovoId() — con un ULID o un UUID — risolve un problema pratico fastidioso: senza di esso, un’entità non ha identità finché non è stata salvata, e diventa impossibile costruire un grafo di oggetti in memoria o pubblicare un evento prima del commit. Con gli identificativi generati a monte, l’ordine ha un’identità dal momento in cui esiste.
namespace App\Ordini\Domain;
final readonly class OrdineId implements \Stringable
{
private function __construct(public string $valore) {}
public static function nuovo(): self
{
return new self((string) \Illuminate\Support\Str::ulid());
}
public static function da(string $valore): self
{
if (! \Illuminate\Support\Str::isUlid($valore)) {
throw new \InvalidArgumentException("Identificativo ordine non valido: {$valore}");
}
return new self($valore);
}
public function equals(self $altro): bool { return $this->valore === $altro->valore; }
public function __toString(): string { return $this->valore; }
}Passo 4 — Gestire le ricerche complesse senza esporre il query builder
Arriva presto l’obiezione pratica: e se servono venti combinazioni di filtri? Un metodo per combinazione è insostenibile, un metodo che accetta un Builder vanifica tutto. La risposta è un oggetto di ricerca: i criteri diventano un tipo, non una chiamata concatenata.
namespace App\Ordini\Domain;
final readonly class FiltroOrdini
{
public function __construct(
public ?ClienteId $cliente = null,
public ?StatoOrdine $stato = null,
public ?Periodo $periodo = null,
public ?Denaro $importoMin = null,
public OrdinamentoOrdini $ordinamento = OrdinamentoOrdini::DataDesc,
) {}
/** Costruzione fluente, senza mai esporre SQL */
public function conStato(StatoOrdine $stato): self
{
return new self($this->cliente, $stato, $this->periodo, $this->importoMin, $this->ordinamento);
}
}// Nel dominio: leggibile, tipizzato, impossibile da usare male
$filtro = (new FiltroOrdini(cliente: $clienteId, periodo: Periodo::ultimiGiorni(30)))
->conStato(StatoOrdine::InAttesaDiPagamento);
$ordini = $this->ordini->cerca($filtro);Il vantaggio è duplice: i filtri ammessi sono un insieme finito e documentato, e l’implementazione resta libera di tradurli come preferisce — in SQL oggi, in una chiamata a un motore di ricerca domani, senza che il chiamante se ne accorga.
Il caso in cui il repository è lo strumento sbagliato
Va detto chiaramente, perché è la causa più comune di repository degenerati: le letture complesse per il reporting non passano dal repository. Una tabella con dodici colonne calcolate, tre join, un raggruppamento e la paginazione non ha niente a che fare con l’aggregato: non serve idratare entità di dominio per poi buttarle in una vista, è lavoro inutile e spesso un problema di prestazioni.
Per quei casi si usa un query service separato, che interroga il database e restituisce direttamente oggetti di sola lettura. È una separazione fra scrittura e lettura in versione leggera, e mantiene il repository concentrato su ciò che sa fare.
namespace App\Ordini\Application\Query;
/** Sola lettura: nessuna entità, nessuna regola, solo dati per una schermata */
final readonly class RigaElencoOrdini
{
public function __construct(
public string $id,
public string $numero,
public string $ragioneSociale,
public string $stato,
public int $totaleCentesimi,
public string $dataEmissione,
) {}
}
final class ElencoOrdiniQuery
{
public function __construct(private ConnectionInterface $db) {}
/** @return LengthAwarePaginator<RigaElencoOrdini> */
public function esegui(FiltroOrdini $filtro, int $perPagina = 25): LengthAwarePaginator
{
return $this->db->table('ordini as o')
->join('clienti as c', 'c.id', '=', 'o.cliente_id')
->select('o.id', 'o.numero', 'c.ragione_sociale', 'o.stato', 'o.totale', 'o.data_emissione')
->when($filtro->stato, fn ($q, $s) => $q->where('o.stato', $s->value))
->when($filtro->cliente, fn ($q, $c) => $q->where('o.cliente_id', (string) $c))
->orderByDesc('o.data_emissione')
->paginate($perPagina)
->through(fn ($r) => new RigaElencoOrdini(
$r->id, $r->numero, $r->ragione_sociale, $r->stato, (int) $r->totale,
$r->data_emissione,
));
}
}Questa classe usa il query builder senza vergogna, e va bene: non fa parte del dominio, sta nel livello applicativo, e il suo contratto sono i DTO che restituisce. Chi prova a far passare tutte le letture dal repository finisce con un’interfaccia da trenta metodi che nessuno riesce più a implementare.
Passo 5 — Implementare il repository con Eloquent
Ora l’implementazione concreta. Qui si presenta la decisione più discussa del pattern in Laravel, e conviene affrontarla in modo esplicito perché non c’è una risposta unica.
Le due strade: entità pure o modelli come entità
| Entità pure (POPO) | Modello Eloquent come entità | |
|---|---|---|
| Cosa restituisce il repository | Oggetti PHP semplici, senza dipendenze dal framework | Modelli Eloquent, con il divieto di usarne il query builder fuori dall’infrastruttura |
| Disaccoppiamento | Completo: il dominio non conosce Laravel | Parziale: il tipo è di Eloquent, ma l’accesso ai dati è incapsulato |
| Costo | Idratazione e disidratazione da scrivere e mantenere | Quasi nullo |
| Test del dominio | Istanziazione diretta, nessun database, nessun framework | Possibili senza database, ma il modello va comunque costruito |
| Quando conviene | Dominio ricco di regole, invarianti da proteggere, ciclo di vita lungo | Logica moderata, squadra piccola, priorità alla velocità |
Il consiglio pragmatico: partire dalla seconda e passare alla prima solo dove le regole di business sono davvero ricche. Un’applicazione può convivere benissimo con due o tre aggregati modellati con entità pure — quelli dove le invarianti contano — e tutto il resto con modelli Eloquent trattati come entità. L’architettura uniforme è un’aspirazione estetica; il dominio ha zone a densità diversa.
Vediamo la versione completa, con entità pure, perché contiene tutti i pezzi interessanti. Il modello Eloquent viene retrocesso a quello che è: una mappatura di righe, con un nome che lo dichiara.
namespace App\Ordini\Infrastructure;
use Illuminate\Database\Eloquent\Model;
/**
* Mappatura della tabella. NON è l'entità di dominio e non contiene regole.
* Questa classe non deve essere referenziata fuori dal namespace Infrastructure.
*/
class OrdineRecord extends Model
{
protected $table = 'ordini';
protected $keyType = 'string';
public $incrementing = false;
protected $fillable = ['id', 'numero', 'cliente_id', 'stato', 'totale', 'data_emissione'];
protected $casts = [
'data_emissione' => 'immutable_datetime',
'confermato_il' => 'immutable_datetime',
];
public function righe() { return $this->hasMany(RigaOrdineRecord::class, 'ordine_id'); }
}namespace App\Ordini\Infrastructure;
use App\Ordini\Domain\{Ordine, OrdineId, OrdineRepository, OrdineNonTrovato,
ClienteId, StatoOrdine, Denaro, RigaOrdine, FiltroOrdini};
use Illuminate\Support\Facades\DB;
final class OrdineRepositoryEloquent implements OrdineRepository
{
public function nuovoId(): OrdineId
{
return OrdineId::nuovo();
}
public function perId(OrdineId $id): Ordine
{
return $this->trovaPerId($id) ?? throw new OrdineNonTrovato($id);
}
public function trovaPerId(OrdineId $id): ?Ordine
{
// L'aggregato si carica sempre completo: il chiamante non può fare with()
$record = OrdineRecord::with('righe')->find($id->valore);
return $record ? $this->idrata($record) : null;
}
/** @return list<Ordine> */
public function apertiDelCliente(ClienteId $cliente): array
{
return OrdineRecord::with('righe')
->where('cliente_id', (string) $cliente)
->whereIn('stato', [StatoOrdine::Bozza->value, StatoOrdine::InAttesaDiPagamento->value])
->get()
->map($this->idrata(...))
->all();
}
public function salva(Ordine $ordine): void
{
// Le transazioni annidate in Laravel usano i savepoint:
// se il chiamante ha già aperto una transazione, questa vi si innesta.
DB::transaction(function () use ($ordine) {
$record = OrdineRecord::firstOrNew(['id' => (string) $ordine->id()]);
$record->fill([
'numero' => $ordine->numero(),
'cliente_id' => (string) $ordine->cliente(),
'stato' => $ordine->stato()->value,
'totale' => $ordine->totale()->inCentesimi(),
'data_emissione' => $ordine->dataEmissione(),
'confermato_il' => $ordine->confermatoIl(),
])->save();
$this->sincronizzaRighe($record, $ordine);
});
}
public function rimuovi(Ordine $ordine): void
{
OrdineRecord::where('id', (string) $ordine->id())->delete();
}
// ---------- traduzione fra i due mondi: tutto il resto dell'app non la vede ----------
private function idrata(OrdineRecord $r): Ordine
{
return Ordine::ricostruisci(
id: OrdineId::da($r->id),
numero: $r->numero,
cliente: ClienteId::da($r->cliente_id),
stato: StatoOrdine::from($r->stato),
dataEmissione: $r->data_emissione->toImmutable(),
confermatoIl: $r->confermato_il?->toImmutable(),
righe: $r->righe->map(fn ($riga) => new RigaOrdine(
codiceArticolo: $riga->codice_articolo,
quantita: (int) $riga->quantita,
prezzoUnitario: Denaro::daCentesimi((int) $riga->prezzo_unitario),
))->all(),
);
}
private function sincronizzaRighe(OrdineRecord $record, Ordine $ordine): void
{
$record->righe()->delete(); // aggregato piccolo: sostituzione integrale
$record->righe()->createMany(
array_map(fn (RigaOrdine $riga) => [
'codice_articolo' => $riga->codiceArticolo,
'quantita' => $riga->quantita,
'prezzo_unitario' => $riga->prezzoUnitario->inCentesimi(),
], $ordine->righe())
);
}
}Tre dettagli di questa implementazione meritano attenzione.
L’eager loading è una responsabilità del repository. Poiché il chiamante non può invocare with(), la strategia di caricamento deve essere decisa qui — e la regola sana è che l’aggregato si carica sempre intero. È anche la ragione per cui l’aggregato deve restare piccolo: se l’ordine avesse diecimila righe, questa scelta diventerebbe insostenibile e sarebbe il segnale che il confine è disegnato male.
Il metodo di ricostruzione dell’entità è distinto dal costruttore. Ordine::ricostruisci() non applica le validazioni di creazione, perché un ordine che viene dal database esisteva già ed era valido: non deve rifiutarsi di essere caricato per una regola introdotta il mese scorso. Il costruttore pubblico, usato per ordini nuovi, applica invece tutte le invarianti.
La sostituzione integrale delle righe è una scelta, non una pigrizia. Su aggregati piccoli è semplice e corretta; su aggregati grandi va sostituita con un confronto fra lo stato attuale e quello nuovo. La scelta va dichiarata in un commento, perché il primo lettore si chiederà perché.
Passo 6 — Registrare il binding nel container
Il collegamento fra interfaccia e implementazione è l’unico punto dell’applicazione che conosce entrambe. In Laravel sta in un service provider e occupa una riga.
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->bind(OrdineRepository::class, OrdineRepositoryEloquent::class);
}Da questo momento ogni classe che dichiara OrdineRepository nel costruttore riceve l’implementazione Eloquent, e nessuna di esse lo sa. Su questo meccanismo si costruiscono tre cose molto utili.
Il decoratore di cache
Aggiungere una cache senza toccare né il dominio né l’implementazione è l’esempio più convincente del pattern. Si scrive una seconda classe che implementa la stessa interfaccia e delega.
namespace App\Ordini\Infrastructure;
final class OrdineRepositoryConCache implements OrdineRepository
{
public function __construct(
private OrdineRepository $interno,
private \Illuminate\Contracts\Cache\Repository $cache,
) {}
public function trovaPerId(OrdineId $id): ?Ordine
{
return $this->cache->remember(
"ordine:{$id}",
now()->addMinutes(10),
fn () => $this->interno->trovaPerId($id),
);
}
public function salva(Ordine $ordine): void
{
$this->interno->salva($ordine);
$this->cache->forget("ordine:{$ordine->id()}"); // invalidazione nello stesso punto della scrittura
}
// gli altri metodi delegano senza cache
public function perId(OrdineId $id): Ordine { return $this->interno->perId($id); }
public function nuovoId(): OrdineId { return $this->interno->nuovoId(); }
// ...
}$this->app->bind(OrdineRepository::class, OrdineRepositoryEloquent::class);
// Il decoratore si innesta senza che nessun chiamante cambi
$this->app->extend(OrdineRepository::class, fn ($interno, $app) =>
new OrdineRepositoryConCache($interno, $app['cache']->store())
);Il punto importante è che l’invalidazione sta nello stesso metodo che scrive. È l’unico modo per non ritrovarsi con cache incoerenti sparse in dodici punti dell’applicazione, ed è possibile solo perché tutte le scritture passano da un’unica porta.
Il binding contestuale
Serve quando un consumatore specifico ha bisogno di un’implementazione diversa: un comando di importazione che deve leggere senza passare dalla cache, o un job che legge da una replica.
$this->app->when(ImportaOrdiniCommand::class)
->needs(OrdineRepository::class)
->give(OrdineRepositoryEloquent::class); // niente cache in importazioneBind o singleton
Per un repository senza stato, bind e singleton sono equivalenti nella pratica di una richiesta HTTP. La differenza diventa rilevante nei processi persistenti — le code, o un server applicativo che mantiene il processo vivo fra le richieste: un repository registrato come singleton che conservi risultati in memoria comincerà a restituire dati stantii. La regola prudente: bind per default, singleton solo per oggetti costosi da costruire e verificatamente senza stato.
Passo 7 — Usare il repository nella logica di business
Con il contratto in piedi, il caso d’uso diventa breve e quasi noioso da leggere — che è esattamente il segno che le cose stanno al posto giusto.
namespace App\Ordini\Application;
use App\Ordini\Domain\{OrdineId, OrdineRepository};
use App\Shared\Domain\{Transazioni, Eventi};
final readonly class ConfermaOrdine
{
public function __construct(
private OrdineRepository $ordini,
private Transazioni $transazioni,
private Eventi $eventi,
) {}
public function esegui(OrdineId $id): void
{
$ordine = $this->transazioni->esegui(function () use ($id) {
$ordine = $this->ordini->perId($id);
// La regola di business sta nell'entità, non qui e non nel repository
$ordine->conferma(new \DateTimeImmutable());
$this->ordini->salva($ordine);
return $ordine;
});
// Gli eventi si pubblicano dopo il commit, mai dentro
$this->eventi->pubblica(...$ordine->eventiRilasciati());
}
}Il controller, a questo punto, non ha più niente da fare se non tradurre HTTP in dominio e ritorno:
final class ConfermaOrdineController
{
public function __invoke(string $id, ConfermaOrdine $conferma): RedirectResponse
{
try {
$conferma->esegui(OrdineId::da($id));
} catch (OrdineNonTrovato) {
abort(404);
} catch (OrdineNonConfermabile $e) {
return back()->withErrors(['ordine' => $e->getMessage()]);
}
return redirect()->route('ordini.show', $id)->with('stato', 'Ordine confermato');
}
}Due confini da non superare
Le regole di business non entrano nel repository. Un metodo confermaOrdine(OrdineId $id) nel repository sarebbe un errore di attribuzione: la decisione se un ordine sia confermabile appartiene all’entità, il repository sa solo leggere e scrivere. Un repository che contiene if di dominio è un service travestito.
Il confine transazionale sta nel caso d’uso, non nel repository. Quando un’operazione modifica due aggregati, solo il chiamante conosce il confine di coerenza. Un’astrazione minima su DB::transaction mantiene anche questo dettaglio fuori dal dominio:
// Domain
interface Transazioni { public function esegui(\Closure $operazione): mixed; }
// Infrastructure
final class TransazioniEloquent implements Transazioni
{
public function esegui(\Closure $operazione): mixed
{
return \Illuminate\Support\Facades\DB::transaction($operazione);
}
}Passo 8 — Testare senza database
Qui il lavoro dei passi precedenti viene ripagato. Si scrive un’implementazione in memoria dell’interfaccia — una trentina di righe, una volta sola — e la logica di business diventa collaudabile alla velocità di un array.
namespace Tests\Doppi;
use App\Ordini\Domain\{Ordine, OrdineId, OrdineRepository, OrdineNonTrovato, ClienteId, StatoOrdine};
final class OrdineRepositoryInMemoria implements OrdineRepository
{
/** @var array<string, Ordine> */
private array $ordini = [];
public function nuovoId(): OrdineId { return OrdineId::nuovo(); }
public function perId(OrdineId $id): Ordine
{
return $this->ordini[(string) $id] ?? throw new OrdineNonTrovato($id);
}
public function trovaPerId(OrdineId $id): ?Ordine
{
return $this->ordini[(string) $id] ?? null;
}
public function apertiDelCliente(ClienteId $cliente): array
{
return array_values(array_filter(
$this->ordini,
fn (Ordine $o) => $o->cliente()->equals($cliente)
&& in_array($o->stato(), [StatoOrdine::Bozza, StatoOrdine::InAttesaDiPagamento], true),
));
}
public function salva(Ordine $ordine): void { $this->ordini[(string) $ordine->id()] = $ordine; }
public function rimuovi(Ordine $ordine): void { unset($this->ordini[(string) $ordine->id()]); }
/** Comodità per i test: precarica lo stato iniziale */
public function precarica(Ordine ...$ordini): void
{
foreach ($ordini as $ordine) { $this->salva($ordine); }
}
}// tests/Unit/ConfermaOrdineTest.php (Pest)
it('non consente di confermare un ordine già annullato', function () {
$repo = new OrdineRepositoryInMemoria();
$ordine = OrdineFactory::annullato();
$repo->precarica($ordine);
$conferma = new ConfermaOrdine($repo, new TransazioniFinte(), new EventiFinti());
expect(fn () => $conferma->esegui($ordine->id()))
->toThrow(OrdineNonConfermabile::class);
expect($repo->perId($ordine->id())->stato())->toBe(StatoOrdine::Annullato);
});
it('registra la data di conferma', function () {
$repo = new OrdineRepositoryInMemoria();
$ordine = OrdineFactory::inAttesaDiPagamento();
$repo->precarica($ordine);
(new ConfermaOrdine($repo, new TransazioniFinte(), new EventiFinti()))
->esegui($ordine->id());
expect($repo->perId($ordine->id())->confermatoIl())->not->toBeNull();
});Nessuna migrazione, nessun RefreshDatabase, nessuna factory che scrive su disco: solo la regola sotto esame. Su una suite di qualche centinaio di test la differenza di tempo si misura in minuti, e il vero guadagno è indiretto — una suite veloce viene eseguita a ogni salvataggio, una suite lenta viene eseguita quando capita.
Il test che non si può eliminare
C’è però un’insidia da nominare: l’implementazione in memoria può divergere da quella reale. Un filtro tradotto male in SQL, un whereIn che si comporta diversamente su valori nulli, un ordinamento che in memoria è stabile e nel database no. I test unitari passano e la produzione sbaglia.
La difesa è una suite di conformità eseguita contro entrambe le implementazioni: gli stessi casi, gli stessi assert, una volta in memoria e una volta sul database reale. Con Pest si scrive una volta e si esegue due volte.
// tests/Contract/OrdineRepositoryTest.php
dataset('implementazioni', [
'in memoria' => fn () => new OrdineRepositoryInMemoria(),
'eloquent' => fn () => new OrdineRepositoryEloquent(), // con RefreshDatabase
]);
it('restituisce solo gli ordini aperti del cliente richiesto', function (OrdineRepository $repo) {
$cliente = ClienteId::nuovo();
$repo->salva($aperto = OrdineFactory::inAttesaDiPagamento(cliente: $cliente));
$repo->salva($annullato = OrdineFactory::annullato(cliente: $cliente));
$repo->salva($altrui = OrdineFactory::inAttesaDiPagamento(cliente: ClienteId::nuovo()));
$risultato = $repo->apertiDelCliente($cliente);
expect($risultato)->toHaveCount(1)
->and($risultato[0]->id()->equals($aperto->id()))->toBeTrue();
})->with('implementazioni');È il pezzo che quasi tutti omettono e che rende il doppio in memoria affidabile anziché pericoloso. Senza di esso, il repository in memoria è un’illusione di collaudo.
Quando il Repository pattern è tempo perso
Questa sezione è la più importante dell’articolo, perché il pattern applicato dove non serve produce più danni di quanti ne eviti.
Applicazioni prevalentemente CRUD. Se l’80% delle operazioni è “mostra un elenco, apri un form, salva”, non c’è logica di business da proteggere: il repository aggiunge solo indirezione. Eloquent usato direttamente nei controller, con i form request per la validazione, è la scelta corretta e più leggibile.
Pannelli di amministrazione costruiti su strumenti generici. Filament, Nova e simili sono progettati per lavorare con Eloquent: hanno bisogno del modello e del query builder per funzionare. Provare a farli passare da un repository significa combattere lo strumento. La convivenza sensata è quella che ho visto funzionare meglio: il pannello amministrativo lavora direttamente con i modelli, l’area applicativa con logica ricca passa dal repository, e le due zone hanno confini dichiarati.
Prototipi e applicazioni con un ciclo di vita breve. Il repository ripaga sulla manutenzione a medio termine. Su un progetto che vivrà tre mesi, l’investimento non rientra.
Squadre che non condividono la convenzione. Un repository che metà del team scavalca — perché “era più rapido fare la query nel controller” — è peggio di nessun repository: dà l’illusione dell’incapsulamento mentre le scritture passano da due strade diverse, e i bug che nascono da lì sono fra i più difficili da trovare. Prima della classe, serve l’accordo.
Il criterio riassuntivo, in una domanda: esiste logica di business che vale la pena testare senza toccare il database? Se la risposta è sì, il repository si ripaga. Se la logica è “prendi i dati dal form e salvali”, non c’è niente da disaccoppiare.
Sette errori che rendono il repository inutile
- Esporre il query builder. Un metodo che restituisce
Builder, o un__callche inoltra al modello, annulla l’intero pattern. Se serve concatenare filtri, la risposta è un oggetto di ricerca, non una porta di servizio. - Il repository base generico.
BaseRepositoryconall/find/create/update/deleteereditato da venti classi è la copia di Eloquent con un livello di ereditarietà in più. I metodi li dettano i casi d’uso, non una classe astratta. - Un repository per tabella. Riproduce lo schema del database nella struttura del codice. Il confine è l’aggregato: le entità che si salvano insieme hanno un repository solo.
- Accettare array grezzi.
salva(array $dati)rinuncia a qualsiasi garanzia: il repository non sa cosa sta scrivendo e nessun tipo lo protegge. L’entità è il contratto. - Mettere le regole di business dentro il repository. È il modo più rapido per ricreare il problema di partenza, con un file in più e la logica ancora più nascosta.
- Far passare dal repository anche il reporting. Produce interfacce da trenta metodi e implementazioni ingestibili. Le letture complesse hanno i loro query service e i loro DTO.
- Non verificare la conformità fra le implementazioni. Il doppio in memoria che si comporta diversamente da quello reale trasforma i test verdi in falsa sicurezza.
Checklist prima di considerarlo fatto
- Nessun tipo di ritorno dell’interfaccia nomina
Builder,Modelo una collezione di Eloquent. - I nomi dei metodi vengono dal dominio, non dal CRUD.
- Esiste un repository per aggregato, non per tabella.
- L’interfaccia sta nel dominio, l’implementazione nell’infrastruttura.
- Il modello Eloquent non è referenziato fuori dall’infrastruttura, e una regola statica o una revisione lo verifica.
- Le ricerche multi-criterio passano da un oggetto di filtro tipizzato.
- Le letture per elenchi e report passano da query service separati che restituiscono DTO.
- Il confine transazionale sta nel caso d’uso, non nel repository.
- Il repository non contiene
ifche esprimono regole di business. - Esiste un’implementazione in memoria usata nei test di dominio.
- Esiste una suite di conformità eseguita contro tutte le implementazioni.
- Il binding è in un unico service provider, ed è l’unico posto che conosce entrambe le parti.
Conclusioni
Il Repository pattern non è un modo di scrivere query in un file diverso. È la decisione di far dichiarare al dominio che cosa gli serve, invece di lasciargli prendere da sé ciò che trova. Tutto il resto — l’interfaccia, il binding, l’implementazione — è la conseguenza meccanica di quella decisione.
Il metro per giudicare un repository è concreto e si applica in trenta secondi: si riesce a collaudare la logica di business senza toccare il database? Se sì, il pattern sta funzionando. Se per testare una regola serve una migrazione, una factory e RefreshDatabase, è stato aggiunto un file senza ottenere niente, e vale la pena chiedersi perché sia lì.
Il consiglio operativo per iniziare, nella pratica di un progetto Laravel già avviato, è di non introdurlo dappertutto. Si sceglie un aggregato — quello con più regole, quello che genera più bug, quello che tutti temono di modificare — si scrive la sua interfaccia partendo dai chiamanti esistenti, si sposta l’accesso ai dati nell’implementazione e si scrivono i primi test senza database. Se dopo quell’esercizio la logica è più chiara e i test più rapidi, il pattern ha dimostrato di servire e si può estendere al prossimo aggregato. Se invece il risultato è lo stesso codice con due file in più, l’esperimento ha dato la risposta più utile di tutte: in questa applicazione, per ora, Eloquent bastava.
