Repository i Service Layer w Next.js dla Server Actions
Jak podzielić backend w Next.js na Repository, Service Layer i Unit of Work? Praktyczny wzorzec dla Server Actions, Route Handlers, transakcji i testów.
Maciej Sala
Founder StriveLab
9 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 24 lipca 2026)
Jak podzielić backend w Next.js na warstwy?
Kiedy Repository i Service Layer mają sens w Next.js?
Najpierw rozpoznaj problem. Jeśli budujesz full-stack w Next.js i logika ląduje tam, gdzie najwygodniej, czyli prosto w Server Actions i Route Handlers, początkowo wszystko działa bez zarzutu. Po kilku miesiącach jedna funkcja miesza walidację, dostęp do bazy, reguły biznesowe, transakcje i rewalidację cache. To jest moment, w którym warstwy przestają być teorią.
Ten wzorzec ma sens szczególnie wtedy, gdy:
ta sama operacja jest wywoływana z formularza, Route Handlera, webhooka albo
zadania cyklicznego,
jedna akcja musi zmienić kilka tabel atomowo,
logika biznesowa ma reguły, które nie powinny zależeć od HTTP ani Reacta,
chcesz testować reguły bez prawdziwej bazy danych,
chcesz ograniczyć wpływ przyszłej zmiany ORM.
W przykładach domeną przykładowej aplikacji jest example.com, czyli domena zarezerwowana do dokumentacji. Dzięki temu żaden adres w snippetach nie wygląda jak realna część tej strony.
Code
// Ź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 i dostęp do bazy są 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 totalCents = product.priceCents * quantity const discountCents = totalCents > 50_000 ? totalCents / 10 : 0 const order = await prisma.order.create({ data: { userId: session.user.id, productId, quantity, totalCents: totalCents - discountCents, status: 'PENDING', }, }) await prisma.product.update({ where: { id: productId }, data: { stock: { decrement: quantity } }, }) // Odświeża widoki w przykładowej aplikacji example.com. 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 jako abstrakcja dostępu do danych
to obiekt (lub zestaw funkcji), który
enkapsuluje dostęp do bazy danych. Zapytania /ORM są w
jednym miejscu. Reszta aplikacji operuje na interfejsach TypeScript, które nie
zawierają typów klienta Prisma. W tym przykładzie kwoty są zapisane jako liczba
całkowita w najmniejszej jednostce waluty. priceCents i totalCents unikają
błędów zaokrągleń typowych dla obliczeń pieniężnych na number z częścią
ułamkową.
Code
// types/index.tsexport interface Product { id: string name: string category: string priceCents: number stock: number}export type OrderStatus = 'PENDING' | 'CANCELLED'export interface Order { id: string userId: string productId: string quantity: number totalCents: number status: OrderStatus}export interface CreateOrderData { userId: string productId: string quantity: number totalCents: number status: OrderStatus}
Mapowanie rekordów nie jest obowiązkowe w każdej aplikacji, ale bez niego
deklaracja Promise<Product> sama nie odcina typów ORM. Szczególną uwagę zwróć
na Decimal, Date, relacje i enumy. To właśnie takie typy najczęściej
przeciekają przez pozornie neutralny interfejs.
Korzyści wzorca Repository
Jedno miejsce na zapytania. Zmiana schematu bazy ma mniejszą powierzchnię.
Testowalność. W testach serwisu podstawiasz fałszywe repository.
Wymienialność. Przejście z Prisma na Drizzle dotyka głównie implementacji repository, jeśli reszta aplikacji korzysta z neutralnych typów i metod.
Czytelność.productRepository.findById(id) opisuje intencję operacji.
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.
W przykładzie filtry mają wartości opcjonalne, dlatego where powstaje warunkowo. Nie opieraj ważnej logiki filtrowania na przypadkowym przekazywaniu undefined: w zależności od konfiguracji Prisma może pominąć takie pole, a przy strictUndefinedChecks wymagać jawnego Prisma.skip.
Unit of Work i 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 zna API Prisma, ale zna
abstrakcję transakcji i może określić, że cały przypadek użycia ma być atomowy.
Sama transakcja nie rozwiązuje jednak każdego konfliktu współbieżności. Domyślny
poziom izolacji zależy od bazy danych. Gdy inwariant wymaga serializacji,
ustaw odpowiedni isolationLevel i obsłuż ponowienie błędu konfliktu P2034.
W pokazanym przypadku warunek stock >= quantity znajduje się bezpośrednio w
atomowym updateMany, dlatego równoległe żądania nie mogą sprowadzić stanu
poniżej zera.
Ważny szczegół dotyczy błędów zwracanych z callbacku. return { success: false }
nie wycofuje wcześniejszych zapisów. Prisma zatwierdzi transakcję, jeśli callback
zakończy się poprawnie. W przykładzie wszystkie przewidywane niepowodzenia
występują przed zapisem albo po warunkowym updateMany, które niczego nie
zmieniło. Jeśli błąd pojawi się po pierwszym zapisie i całość ma zostać
wycofana, rzuć wewnętrzny wyjątek domenowy, przechwyć go poza $transaction i
zamień na bezpieczny wynik serwisu.
Warstwa Service i logika biznesowa
zawiera reguły biznesowe,
walidację biznesową i orkiestrację operacji. Serwis korzysta z repository do
dostępu do danych, ale nie importuje bazy danych, ORM ani HTTP. Walidację
kształtu danych wejściowych zostaw adapterom, czyli Server Action, Route
Handlerowi, webhookowi albo zadaniu cyklicznemu. Serwis może znać abstrakcję
trwałości danych, taką jak Unit of Work. Nie powinien natomiast importować
klienta Prisma ani obiektów związanych z transportem.
Code
// services/order-service.tsimport 'server-only'import { unitOfWork, type UnitOfWork } from '@/lib/unit-of-work'interface CreateOrderInput { userId: string productId: string quantity: number}type OrderServiceResult = | { success: true; orderId: string } | { success: false code: 'INVALID_QUANTITY' | 'NOT_FOUND' | 'FORBIDDEN' | 'CONFLICT' error: string }interface OrderServiceDeps { unitOfWork: UnitOfWork}export function createOrderService({ unitOfWork }: OrderServiceDeps) { return { async createOrder(input: CreateOrderInput): Promise<OrderServiceResult> { // 1. Walidacja biznesowa if (input.quantity < 1 || input.quantity > 100) { return { success: false, code: 'INVALID_QUANTITY', 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, code: 'NOT_FOUND', error: 'Produkt nie istnieje', } } if (product.stock < input.quantity) { return { success: false, code: 'CONFLICT', error: `Dostępne sztuki: ${product.stock}`, } } // 3. Logika cenowa const pricing = calculatePricing(product.priceCents, input.quantity) // 4. Atomowe zmniejszenie stanu magazynowego const stockUpdate = await repositories.product.decrementStockIfAvailable( input.productId, input.quantity, ) if (stockUpdate.count === 0) { return { success: false, code: 'CONFLICT', 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, totalCents: pricing.finalPriceCents, status: 'PENDING', }) return { success: true, orderId: order.id } }) }, async cancelOrder( orderId: string, userId: string, ): Promise<OrderServiceResult> { return unitOfWork.transaction(async (repositories) => { const order = await repositories.order.findById(orderId) if (!order) { return { success: false, code: 'NOT_FOUND', error: 'Zamówienie nie istnieje', } } if (order.userId !== userId) { return { success: false, code: 'FORBIDDEN', error: 'Brak uprawnień', } } if (order.status !== 'PENDING') { return { success: false, code: 'CONFLICT', error: 'Zamówienie nie może być anulowane', } } // Przejście statusu jest warunkowe. Tylko jedno równoległe żądanie wygra. const statusUpdate = await repositories.order.cancelIfPending( orderId, userId, ) if (statusUpdate.count === 0) { return { success: false, code: 'CONFLICT', error: 'Zamówienie zostało już zmienione', } } await repositories.product.incrementStock( order.productId, order.quantity, ) return { success: true, orderId } }) }, }}export const orderService = createOrderService({ unitOfWork })// Czysta funkcja, którą łatwo przetestowaćfunction calculatePricing(unitPriceCents: number, quantity: number) { const subtotalCents = unitPriceCents * quantity const discountRate = subtotalCents > 50_000 ? 0.1 : subtotalCents > 20_000 ? 0.05 : 0 const discountCents = Math.round(subtotalCents * discountRate) const finalPriceCents = subtotalCents - discountCents return { subtotalCents, discountRate, discountCents, finalPriceCents }}
Ten serwis nadal jest prosty, ale ma trzy ważne cechy. Operacja zamówienia jest
atomowa, zapobieganie oversellingowi znajduje się w zapytaniu aktualizującym
stan, a zależności można podmienić w teście bez mockowania importów modułów.
Warunkowe cancelIfPending zapobiega także podwójnemu zwróceniu towaru na stan,
gdy dwa żądania anulowania dotrą niemal jednocześnie.
Interaktywna transakcja powinna być krótka. Nie wysyłaj w niej e-maila, nie
wywołuj bramki płatniczej i nie czekaj na zewnętrzne API. Takie efekty wykonuj
po zatwierdzeniu transakcji. Jeśli muszą być niezawodne, zapisz zdarzenie w
tabeli outbox w tej samej transakcji, a następnie przetwórz je asynchronicznie.
Granice odpowiedzialności między Repository i Service Layer
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.
Najczęstsze błędy w Repository i Service Layer
Największe problemy nie wynikają z samego wzorca, tylko z pomieszania granic:
Transakcja zamknięta w pojedynczym repository. Nie obejmuje wtedy całego przypadku użycia. Jeśli zamówienie i aktualizacja stanu magazynowego mają być atomowe, transakcję otwiera service przez Unit of Work.
Service importuje API Next.js.revalidatePath, cookies, headers, Request i Response należą do adapterów wejścia, nie do logiki biznesowej.
Repository zwraca przypadkowe typy ORM. To szybkie na starcie, ale ogranicza wymienialność. Jeśli niezależność od ORM jest ważna, zwracaj typy domenowe.
Walidacja jest zduplikowana w kilku wejściach. Server Action i Route Handler mogą mieć różny protokół, ale powinny kończyć z tym samym sprawdzonym DTO.
Warstwy powstają za wcześnie. Jeśli masz jeden formularz i jedno zapytanie, prosta funkcja będzie lepsza niż katalogi tworzone na zapas.
Idempotencja oraz efekty wykonywane po transakcji
Transakcja gwarantuje atomowość pojedynczego wywołania, ale nie zapobiega
utworzeniu dwóch zamówień po ponowieniu tego samego żądania. Dla operacji, które
mogą zostać powtórzone przez klienta, kolejkę albo webhook, dodaj klucz
idempotencji. Powinien być zapisany w bazie z ograniczeniem UNIQUE, najlepiej
w zakresie użytkownika i rodzaju operacji. Samo wcześniejsze zapytanie
findUnique nie wystarcza, ponieważ dwa równoległe żądania mogą jednocześnie
nie znaleźć rekordu. To ograniczenie bazy rozstrzyga wyścig.
Code
model Order { id String @id @default(cuid()) userId String idempotencyKey String // pozostałe pola zamówienia @@unique([userId, idempotencyKey])}
Po konflikcie unikalności adapter lub warstwa trwałości może odczytać wcześniej
utworzone zamówienie i zwrócić ten sam orderId. Klucz musi identyfikować jedno
logiczne żądanie. Nie generuj nowego klucza przy każdym automatycznym ponowieniu.
Webhook wymaga dodatkowo weryfikacji podpisu, a zadanie cykliczne własnej
autoryzacji.
Wspólna walidacja wejścia w backendzie Next.js
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 jako 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. Uwierzytelnienie. userId pochodzi z zaufanej sesji, nie z formularza. 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 { success: true, orderId: result.orderId } } return { success: false, code: result.code, error: result.error }}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}
Server Action i Route Handler używają tego samego serwisu, więc logika
biznesowa istnieje w jednym miejscu. Każde wejście nadal musi samodzielnie
uwierzytelnić wywołanie, a serwis musi egzekwować reguły dostępu do konkretnego
zasobu. Server Action należy traktować jak publiczne wejście do mutacji.
Oczekiwane błędy są zwracane jako wartości, a nie rzucane. Nieoczekiwane awarie
mogą trafić do mechanizmu obsługi wyjątków i Error Boundary.
Testowanie Repository i Service Layer
Logikę serwisu można testować bez bazy danych. Takie testy sprawdzają reguły i
orkiestrację, ale nie potwierdzają poprawności zapytań, mapowania rekordów,
ograniczeń UNIQUE ani zachowania transakcji. Repository pokryj osobnymi
testami integracyjnymi na prawdziwym silniku bazy. Dla stanów magazynowych i
anulowania dodaj też test, który uruchamia dwa żądania równolegle.
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ć Repository i Service Layer?
Nie każdy projekt potrzebuje pełnego zestawu warstw. Jeśli masz małą stronę z panelem administracyjnym, trzy tabele i proste operacje , 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.
Czy to nie za dużo abstrakcji dla małego projektu?
Dla prostego CRUD z dwiema czy trzema encjami zwykle jest to przerost formy nad treścią i lepiej zostać przy bezpośrednich zapytaniach w Server Action. Wzorzec zaczyna się opłacać, gdy pojawia się realna logika biznesowa: kalkulacja cen, stany magazynowe, powiadomienia, reguły przejść statusów albo ta sama operacja wywoływana z formularza, API i joba. Wtedy warto wprowadzić separację wcześnie, zanim logika rozproszy się po wielu funkcjach i stanie się trudna do zmiany.
Kiedy wprowadzić warstwy repository i service?
Dobrym sygnałem jest moment, w którym Server Action albo Route Handler zaczyna łączyć walidację, dostęp do bazy i reguły biznesowe albo gdy ta sama logika jest potrzebna w kilku wejściach. Liczba linii sama w sobie nie jest dobrą granicą. Ważniejsze są powielone reguły, trudne testy i kilka odpowiedzialności w jednej funkcji. Nie musisz refaktoryzować całej aplikacji naraz. Zacznij od domeny, która najbardziej boli.
Czy repository powinno zwracać typy z Prisma?
Docelowo nie powinno, jeśli rzeczywiście chcesz izolować ORM. Repository może zwracać własne typy domenowe, niezależne od ORM, bo dopiero one tworzą granicę, która ogranicza wpływ ewentualnej zmiany Prismy na Drizzle. W praktyce jednak na początku używanie typów generowanych przez Prismę jest w pełni akceptowalne i pragmatyczne. Wprowadzasz własne typy domenowe dopiero wtedy, gdy zmiana ORM albo niezależność od niego staje się realnym wymaganiem, a nie teoretycznym.
Czym repository różni się od service?
Repository odpowiada za dostęp do danych. Zna ORM i bazę, ale nie zna reguł biznesowych. Service zna reguły biznesowe i orkiestruje operacje, ale nie zna HTTP ani API konkretnego ORM. Może natomiast znać abstrakcję transakcji, taką jak Unit of Work. Ten podział skupia zapytania i reguły w osobnych miejscach, lecz nie gwarantuje pełnej niezależności warstw.
Czy ten wzorzec działa z Drizzle, a nie tylko Prisma?
Tak, jeśli interfejs repository nie przecieka typami i idiomami konkretnego ORM. Repository jest właśnie tą warstwą, która może ukryć Prismę, Drizzle albo surowy SQL przed resztą aplikacji. Wtedy wymiana ORM sprowadza się głównie do przepisania implementacji metod repository przy zachowaniu ich sygnatur. Logika biznesowa i warstwa wejścia mają wtedy dużo mniejszą powierzchnię zmian. Nadal mogą być potrzebne zmiany w transakcjach, mapowaniu błędów, paginacji lub testach integracyjnych.
Gdzie trzymać transakcje?
Transakcja powinna obejmować cały przypadek użycia, a nie pojedyncze wywołanie repository. Najczęściej robi to service przez Unit of Work: otwiera transakcję, tworzy repozytoria na kliencie transakcyjnym i wykonuje wszystkie operacje atomowo. Dzięki temu utworzenie zamówienia i zmniejszenie stanu magazynowego kończą się razem albo razem się wycofują.
Czy walidacja powinna być w Server Action czy w service?
Walidacja wejścia należy do warstwy wejścia: Server Action, Route Handler, zadanie cykliczne albo webhook powinny zamienić obce dane na bezpieczny input domenowy. Reguły biznesowe, np. limity ilości, dostępność produktu, uprawnienia do anulowania zamówienia czy stany przejść, należą do service. Schemat Zod możesz współdzielić między Server Action i Route Handlerem, żeby nie rozjechały się kontrakty.
Czy Server Action jest prywatna, jeśli nie ma własnego endpointu API?
Nie. Server Action uruchamia się po stronie serwera, ale nadal trzeba traktować ją jak publiczne wejście do mutacji: walidować dane, sprawdzać sesję i egzekwować uprawnienia. To, że Next.js ukrywa szczegóły transportu, nie zwalnia z kontroli bezpieczeństwa. Właśnie dlatego Server Action powinna być cienkim adapterem, a nie miejscem, w którym mieszasz autoryzację, reguły biznesowe i zapytania do bazy.
O autorze
Maciej Sala
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 rozwijam własne projekty.
Server Actions to publiczne endpointy POST, które każdy może wywołać z poziomu narzędzi deweloperskich, narzędzia curl lub skryptu, dlatego formularza w interfejsie nie wolno traktować jak granicy bezpieczeństwa. Jeśli akcja nie waliduje danych wejściowych i nie sprawdza uprawnień, staje się podatna na ataki. Testy pilnują, aby niezweryfikowane dane nie trafiły do bazy, niezalogowany użytkownik nie wykonał niedozwolonej modyfikacji, a rewalidacja oraz inne efekty uboczne uruchomiły się dopiero po pomyślnym zakończeniu operacji.
Maciej Sala
Founder StriveLab
Prisma i Drizzle zdominowały świat Next.js i ogólnie Node.js, wyrastając na dwa najpotężniejsze ORM-y dostępne na rynku. Choć służą podobnemu celowi, reprezentują dwie fundamentalnie różne wizje komunikacji z bazą danych.
Maciej Sala
Founder StriveLab
App Router daje Ci dwa sposoby na uruchomienie kodu po stronie serwera, poprzez Route Handlers i Server Actions . Może wyglądają podobnie, ponieważ oba działają na serwerze, sięgają do bazy i do zmiennych środowiskowych, ale każdy z nich rozwiązuje zupełnie inny problem. Użycie jednego tam, gdzie pasuje drugi, nie jest odpowiednim rozwiązaniem, ponieważ zły wybór odbija się potem na architekturze.