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
7 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 26 czerwca 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 + 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 } }, }) // 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 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 dotyka głównie implementacji repository, jeśli reszta aplikacji korzysta z neutralnych typów i metod
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.
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 musi znać szczegółów Prisma, ale może powiedzieć „ta operacja ma być atomowa”.
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 wie nic o bazie danych, ORM ani HTTP. Walidację
kształtu danych wejściowych zostaw adapterom, czyli Server Action, Route
Handlerowi, webhookowi albo zadaniu cyklicznemu.
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 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 — wtedy nie obejmuje 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.
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. 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ć 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 — tak, to zwykle 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 przekracza mniej więcej 30 linii i miesza w sobie walidację, dostęp do bazy i reguły biznesowe — albo gdy zauważasz, że ta sama logika jest potrzebna w dwóch miejscach. To wtedy duplikacja i splątanie zaczynają realnie spowalniać pracę, a separacja warstw się zwraca. Nie musisz refaktoryzować całej aplikacji naraz; zacznij od domeny, która najbardziej boli.
Czy repository powinno zwracać typy z Prisma?
Docelowo nie — repository powinno 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 wyłącznie za dostęp do danych — zna ORM i bazę, ale nie zna reguł biznesowych. Service zna reguły biznesowe i orkiestruje operacje, ale nie wie nic o bazie, ORM ani HTTP; korzysta z repository jak z czarnej skrzynki. Ten podział sprawia, że zapytania masz w jednym miejscu, logikę w drugim, a żadna warstwa nie musi się zmieniać, gdy zmienia się ta druga. To esencja całego wzorca.
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ą dużo mniejszą powierzchnię zmian.
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.
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.
Maciej Sala
Founder StriveLab
Wyobraź sobie, że dla każdego użytkownika z osobna sprawdzasz, czy wolno mu edytować artykuł. Przy dziesięciu osobach jeszcze ujdzie, przy tysiącu to droga przez mękę. RBAC odwraca pytanie: uprawnienia wiążesz z rolami admin, editor, user , role z ludźmi, a w kodzie pytasz tylko, czy rola ma uprawnienie posts:edit . Jedna reguła zamiast tysiąca wyjątków.