Wzorzec Repository + Service Layer w Next.js full-stack — backend, który nie boli
Backend w Next.js zamienia się w spaghetti szybciej niż się wydaje. Repository + Service Layer ogranicza chaos, jeśli dobrze ustawisz transakcje, walidację i granice warstw.
// ŹLE — Server Action, który robi wszystko'use server'export async function createOrder(formData: FormData) { const session = await auth() if (!session) throw new Error('Unauthorized') const productId = formData.get('productId') as string const quantity = Number(formData.get('quantity')) // Walidacja if (!productId || quantity < 1) throw new Error('Invalid data') // Logika biznesowa + dostęp do bazy — wymieszane const product = await prisma.product.findUnique({ where: { id: productId } }) if (!product) throw new Error('Product not found') if (product.stock < quantity) throw new Error('Not enough stock') const total = product.price * quantity const discount = total > 500 ? 0.1 : 0 const finalPrice = total * (1 - discount) const order = await prisma.order.create({ data: { userId: session.user.id, productId, quantity, total: finalPrice, status: 'PENDING', }, }) await prisma.product.update({ where: { id: productId }, data: { stock: { decrement: quantity } }, }) revalidatePath('/orders') return { success: true, orderId: order.id }}
Problemy: nie da się tego testować bez bazy danych, logika cenowa jest ukryta w Server Action, zmiana ORM wymaga przepisania całej funkcji, duplikacja gdy potrzebujesz tego samego w Route Handlerze.
Warstwa Repository — abstrakcja dostępu do danych
Repository to warstwa (obiekt lub zestaw funkcji), która enkapsuluje cały dostęp do bazy danych. Reszta aplikacji nie wykonuje zapytań bezpośrednio — woła metody repository jak findById czy create. Dzięki temu zapytania ORM są w jednym miejscu i można podmienić bazę albo ORM bez ruszania logiki biznesowej. to obiekt (lub zestaw funkcji), który
enkapsuluje dostęp do bazy danych. Zapytania SQL/ORM są w jednym miejscu —
reszta aplikacji operuje na czystych interfejsach TypeScript.
Jedno miejsce na zapytania — zmiana schematu bazy wymaga edycji w jednym pliku
Testowalność — mockujesz repository, nie całą bazę danych
Wymienialność — przejście z Prisma na Drizzle to zmiana implementacji repository, nie całej aplikacji
Czytelność — productRepository.findById(id) jest jaśniejsze niż surowe zapytanie Prisma
Zwróć uwagę na parametr client. Repository może działać na globalnym db, ale może też dostać klienta transakcyjnego tx. To detal, który decyduje, czy wzorzec nadaje się do realnego backendu, czy tylko ładnie wygląda na diagramie.
Unit of Work — transakcja dla przypadku użycia
Jeśli operacja biznesowa dotyka kilku tabel, transakcja powinna obejmować cały przypadek użycia. Nie chcesz sytuacji, w której zamówienie powstało, ale stan magazynowy nie został zmniejszony.
To jest prosty wariant wzorca Unit of Work: service nie musi znać szczegółów Prisma, ale może powiedzieć „ta operacja ma być atomowa”.
Warstwa Service — logika biznesowa
Service Layer (warstwa serwisowa) to miejsce na reguły biznesowe i orkiestrację operacji — co i w jakiej kolejności ma się wydarzyć. Korzysta z repository do dostępu do danych, ale celowo nie wie nic o bazie, ORM ani HTTP, dzięki czemu jest czysta i testowalna w izolacji. zawiera reguły biznesowe,
walidację i orkiestrację operacji. Serwis korzysta z repository do dostępu do
danych, ale nie wie nic o bazie danych, ORM ani HTTP.
Code
// services/order-service.tsimport { unitOfWork, type UnitOfWork } from '@/lib/unit-of-work'import type { Order } from '@/types'interface CreateOrderInput { userId: string productId: string quantity: number}interface CreateOrderResult { success: boolean order?: Order error?: string}interface OrderServiceDeps { unitOfWork: UnitOfWork}export function createOrderService({ unitOfWork }: OrderServiceDeps) { return { async createOrder(input: CreateOrderInput): Promise<CreateOrderResult> { // 1. Walidacja biznesowa if (input.quantity < 1 || input.quantity > 100) { return { success: false, error: 'Ilość musi być między 1 a 100' } } return unitOfWork.transaction(async (repositories) => { // 2. Sprawdzenie dostępności produktu const product = await repositories.product.findById(input.productId) if (!product) { return { success: false, error: 'Produkt nie istnieje' } } if (product.stock < input.quantity) { return { success: false, error: `Dostępne sztuki: ${product.stock}` } } // 3. Logika cenowa const pricing = calculatePricing(product.price, input.quantity) // 4. Atomowe zmniejszenie stanu magazynowego const stockUpdate = await repositories.product.decrementStockIfAvailable( input.productId, input.quantity, ) if (stockUpdate.count === 0) { return { success: false, error: 'Produkt właśnie się wyprzedał' } } // 5. Utworzenie zamówienia w tej samej transakcji const order = await repositories.order.create({ userId: input.userId, productId: input.productId, quantity: input.quantity, total: pricing.finalPrice, status: 'PENDING', }) return { success: true, order } }) }, async cancelOrder( orderId: string, userId: string, ): Promise<CreateOrderResult> { return unitOfWork.transaction(async (repositories) => { const order = await repositories.order.findById(orderId) if (!order) { return { success: false, error: 'Zamówienie nie istnieje' } } if (order.userId !== userId) { return { success: false, error: 'Brak uprawnień' } } if (order.status !== 'PENDING') { return { success: false, error: 'Zamówienie nie może być anulowane' } } await repositories.product.incrementStock( order.productId, order.quantity, ) const updated = await repositories.order.updateStatus( orderId, 'CANCELLED', ) return { success: true, order: updated } }) }, }}export const orderService = createOrderService({ unitOfWork })// Czysta funkcja — łatwa do testowaniafunction calculatePricing(unitPrice: number, quantity: number) { const subtotal = unitPrice * quantity const discountRate = subtotal > 500 ? 0.1 : subtotal > 200 ? 0.05 : 0 const discount = subtotal * discountRate const finalPrice = subtotal - discount return { subtotal, discountRate, discount, finalPrice }}
Ten serwis nadal jest prosty, ale ma trzy ważne cechy: operacja zamówienia jest atomowa, zapobieganie oversellingowi jest w zapytaniu aktualizującym stock, a zależności można podmienić w teście bez mockowania importów modułów.
Granice odpowiedzialności
Ten podział działa tylko wtedy, gdy każda warstwa robi swoją część pracy:
HTTP, statusy odpowiedzi, walidacja JSON, mapowanie błędów na response
Service
reguły biznesowe, orkiestracja, decyzja o transakcji
Repository
zapytania do bazy i szczegóły ORM
Czyste funkcje domenowe
obliczenia bez efektów ubocznych, np. cena, rabat, limity
revalidatePath / cache
na zewnątrz service, bo to szczegół interfejsu Next.js
Jeśli service zaczyna importować revalidatePath, Request, Response albo cookies, granica pęka. Jeśli repository zaczyna decydować, czy użytkownik może anulować zamówienie, granica też pęka.
Wspólna walidacja wejścia
Server Action i Route Handler mogą mieć różne protokoły wejścia, ale powinny kończyć z tym samym bezpiecznym typem domenowym. Najprościej wynieść schemat Zod do wspólnego pliku:
Code
// schemas/order-schema.tsimport { z } from 'zod'export const createOrderSchema = z.object({ productId: z.string().uuid(), quantity: z.coerce.number().int().min(1).max(100),})export type CreateOrderDto = z.infer<typeof createOrderSchema>
Server Actions — cienka warstwa wejścia
Po separacji Server Actions stają się cienką warstwą wejścia dla formularzy i mutacji Reacta: autoryzacja, walidacja inputu, wywołanie serwisu, rewalidacja cache.
Code
// actions/order-actions.ts'use server'import { auth } from '@/lib/auth'import { revalidatePath } from 'next/cache'import { orderService } from '@/services/order-service'import { createOrderSchema } from '@/schemas/order-schema'export async function createOrderAction(formData: FormData) { // 1. Autoryzacja const session = await auth() if (!session) return { error: 'Musisz być zalogowany' } // 2. Walidacja inputu const parsed = createOrderSchema.safeParse({ productId: formData.get('productId'), quantity: formData.get('quantity'), }) if (!parsed.success) { return { error: 'Nieprawidłowe dane', details: parsed.error.flatten() } } // 3. Delegacja do serwisu const result = await orderService.createOrder({ userId: session.user.id, ...parsed.data, }) // 4. Rewalidacja cache if (result.success) { revalidatePath('/orders') revalidatePath('/products') } return result}export async function cancelOrderAction(orderId: string) { const session = await auth() if (!session) return { error: 'Musisz być zalogowany' } const result = await orderService.cancelOrder(orderId, session.user.id) if (result.success) { revalidatePath('/orders') } return result}
src/
├── repositories/ ← Dostęp do danych (ORM)
│ └── create-repositories.ts
├── services/ ← Logika biznesowa
│ ├── order-service.ts
│ ├── pricing-service.ts
│ └── notification-service.ts
├── schemas/ ← Wspólna walidacja wejścia
│ └── order-schema.ts
├── actions/ ← Server Actions (cienkie adaptery)
│ ├── order-actions.ts
│ └── product-actions.ts
├── lib/
│ ├── db.ts
│ └── unit-of-work.ts ← Transakcje dla przypadków użycia
├── app/
│ ├── api/ ← Route Handlers (cienkie adaptery)
│ └── ...
└── types/ ← Interfejsy i typy
└── index.ts
Kiedy nie używać tego wzorca
Nie każdy projekt potrzebuje pełnego zestawu warstw. Jeśli masz małą stronę z panelem administracyjnym, trzy tabele i proste operacje CRUD, zacznij od prostszej struktury:
schema Zod blisko Server Action,
jedno zapytanie Prisma/Drizzle w funkcji,
brak osobnego service, dopóki nie ma reguł biznesowych,
refaktor dopiero wtedy, gdy ta sama logika pojawia się w drugim miejscu.
Wzorzec Repository + Service Layer ma sens, gdy pojawiają się transakcje, kilka wejść do tej samej operacji, integracje zewnętrzne, reguły stanów albo realne testy jednostkowe logiki domenowej. Najpierw funkcja, potem warstwa — to często zdrowsza ścieżka niż budowanie katalogów „na zapas”.
Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty side-projecty.