La constatazione

EnglishUp mostra quiz di lezioni di inglese, ogni domanda ha le sue risposte possibili e la sua risposta corretta. In locale con pochi dati, tutto sembrava veloce. In produzione, con qualche centinaio di domande, l'endpoint GET /api/lessons/:id/quiz impiegava 2,3 secondi a rispondere per 20 domande. Inaccettabile per un'esperienza di quiz che dovrebbe essere fluida.

Il codice colpevole

Il repository recuperava la lezione, poi iterava sulle sue domande per andare a prendere le risposte una per una:

// AVANT : une requête par question = N+1
async function getQuizForLesson(lessonId) {
    const lesson = await prisma.lesson.findUnique({ where: { id: lessonId } });
    const questions = await prisma.question.findMany({ where: { lessonId } });

    for (const q of questions) {
        q.answers = await prisma.answer.findMany({ where: { questionId: q.id } }); // N requêtes !
    }

    return { lesson, questions };
}

Per 20 domande, sono 22 query SQL sequenziali (1 lezione + 1 elenco di domande + 20 volte le risposte), ognuna con la sua latenza di rete verso MySQL. Il classico problema N+1, ma con Prisma che rende il codice così leggibile che non lo si vede arrivare.

La diagnosi

Prisma espone un logger di query molto utile per questo tipo di casi:

const prisma = new PrismaClient({
    log: [{ emit: 'event', level: 'query' }],
});

prisma.$on('query', (e) => {
    console.log(`${e.query} — ${e.duration}ms`);
});

La console ha mostrato 22 righe quasi identiche per una sola chiamata API. Il pattern era evidente fin dai primi log.

La correzione: include invece di iterare

Prisma permette di caricare le relazioni in una sola query tramite include, generando un JOIN (o più query ottimizzate in parallelo a seconda del motore, ma sempre in un solo round-trip logico):

// APRÈS : une seule requête avec include imbriqué
async function getQuizForLesson(lessonId) {
    return prisma.lesson.findUnique({
        where: { id: lessonId },
        include: {
            questions: {
                include: { answers: true },
                orderBy: { position: 'asc' },
            },
        },
    });
}

Risultato: da 22 query a 1 sola, e il tempo di risposta è passato da 2,3s a 90ms.

Cosa ne ricavo

  • L'N+1 si nasconde facilmente dietro codice Prisma pulito e leggibile — la leggibilità non è una garanzia di prestazioni.
  • Attivare il logger di query in sviluppo, anche temporaneamente, rivela istantaneamente i pattern N+1.
  • include annidato sostituisce elegantemente i cicli con await all'interno — un riflesso da avere non appena si vede un for attorno a una chiamata Prisma.
  • Su una relazione 1-N-N come lezione → domande → risposte, un solo include ben strutturato basta a caricare tutto in un colpo.