Przejdź do treści

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.ts
export 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
}
Code
// repositories/create-repositories.ts
import 'server-only'
 
import type { Prisma, PrismaClient } from '@prisma/client'
import { db } from '@/lib/db'
import type { CreateOrderData, Order, Product } from '@/types'
 
type DbClient = PrismaClient | Prisma.TransactionClient
 
export function createRepositories(client: DbClient = db) {
  return {
    product: createProductRepository(client),
    order: createOrderRepository(client),
  }
}
 
export type Repositories = ReturnType<typeof createRepositories>
 
function createProductRepository(client: DbClient) {
  return {
    async findById(id: string): Promise<Product | null> {
      const row = await client.product.findUnique({ where: { id } })
      return row ? toProduct(row) : null
    },
 
    async findMany(filters?: {
      category?: string
      minPriceCents?: number
      maxPriceCents?: number
    }): Promise<Product[]> {
      const where: Prisma.ProductWhereInput = {}
 
      if (filters?.category) {
        where.category = filters.category
      }
 
      if (
        filters?.minPriceCents !== undefined ||
        filters?.maxPriceCents !== undefined
      ) {
        where.priceCents = {
          ...(filters.minPriceCents !== undefined
            ? { gte: filters.minPriceCents }
            : {}),
          ...(filters.maxPriceCents !== undefined
            ? { lte: filters.maxPriceCents }
            : {}),
        }
      }
 
      const rows = await client.product.findMany({
        where,
        orderBy: { createdAt: 'desc' },
      })
      return rows.map(toProduct)
    },
 
    async decrementStockIfAvailable(id: string, quantity: number) {
      return client.product.updateMany({
        where: {
          id,
          stock: { gte: quantity },
        },
        data: {
          stock: { decrement: quantity },
        },
      })
    },
 
    async incrementStock(id: string, quantity: number): Promise<void> {
      await client.product.update({
        where: { id },
        data: { stock: { increment: quantity } },
      })
    },
  }
}
 
function createOrderRepository(client: DbClient) {
  return {
    async findById(id: string): Promise<Order | null> {
      const row = await client.order.findUnique({ where: { id } })
      return row ? toOrder(row) : null
    },
 
    async create(data: CreateOrderData): Promise<Order> {
      const row = await client.order.create({ data })
      return toOrder(row)
    },
 
    async cancelIfPending(id: string, userId: string) {
      return client.order.updateMany({
        where: { id, userId, status: 'PENDING' },
        data: { status: 'CANCELLED' },
      })
    },
  }
}
 
type ProductRow = Prisma.ProductGetPayload<{}>
type OrderRow = Prisma.OrderGetPayload<{}>
 
function toProduct(row: ProductRow): Product {
  return {
    id: row.id,
    name: row.name,
    category: row.category,
    priceCents: row.priceCents,
    stock: row.stock,
  }
}
 
function toOrder(row: OrderRow): Order {
  return {
    id: row.id,
    userId: row.userId,
    productId: row.productId,
    quantity: row.quantity,
    totalCents: row.totalCents,
    status: row.status,
  }
}

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.

Code
// lib/unit-of-work.ts
import 'server-only'
 
import { db } from '@/lib/db'
import {
  createRepositories,
  type Repositories,
} from '@/repositories/create-repositories'
 
export interface UnitOfWork {
  transaction<T>(
    callback: (repositories: Repositories) => Promise<T>,
  ): Promise<T>
}
 
export const unitOfWork: UnitOfWork = {
  async transaction(callback) {
    return db.$transaction(async (tx) => {
      const repositories = createRepositories(tx)
      return callback(repositories)
    })
  },
}

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.ts
import '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:

WarstwaOdpowiedzialność
Server Actionformularz, sesja użytkownika, walidacja inputu, rewalidacja cache
Route HandlerHTTP, statusy odpowiedzi, walidacja JSON, mapowanie błędów na response
Servicereguły biznesowe, orkiestracja, decyzja o transakcji
Repositoryzapytania do bazy i szczegóły ORM
Czyste funkcje domenoweobliczenia bez efektów ubocznych, np. cena, rabat, limity
revalidatePath / cachena 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.ts
import { 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
}

Route Handlers i ten sam serwis z innym wejściem

Code
// app/api/orders/route.ts
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 POST(request: Request) {
  const session = await auth()
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  let body: unknown
 
  try {
    body = await request.json()
  } catch {
    return Response.json({ error: 'Invalid JSON' }, { status: 400 })
  }
 
  const parsed = createOrderSchema.safeParse(body)
 
  if (!parsed.success) {
    return Response.json(
      { error: 'Invalid payload', details: parsed.error.flatten() },
      { status: 422 },
    )
  }
 
  const result = await orderService.createOrder({
    userId: session.user.id,
    ...parsed.data,
  })
 
  if (!result.success) {
    const statusByCode = {
      INVALID_QUANTITY: 422,
      NOT_FOUND: 404,
      FORBIDDEN: 403,
      CONFLICT: 409,
    } as const
 
    return Response.json(
      { error: result.error },
      { status: statusByCode[result.code] },
    )
  }
 
  revalidatePath('/orders')
  revalidatePath('/products')
  return Response.json({ id: result.orderId }, { status: 201 })
}

Publiczne wywołanie tego endpointu w dokumentacji pokazuj na domenie zarezerwowanej do przykładów:

Code
curl -X POST https://example.com/api/orders \
  -H 'Content-Type: application/json' \
  -d '{"productId":"2f4f2b1e-9f4a-4a8a-b5f5-8df9e6c3a901","quantity":2}'

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.

Code
// __tests__/services/order-service.test.ts
import { createOrderService } from '@/services/order-service'
import { vi, describe, it, expect, beforeEach } from 'vitest'
 
describe('orderService.createOrder', () => {
  const productRepository = {
    findById: vi.fn(),
    decrementStockIfAvailable: vi.fn(),
    incrementStock: vi.fn(),
  }
 
  const orderRepository = {
    findById: vi.fn(),
    create: vi.fn(),
    cancelIfPending: vi.fn(),
  }
 
  const unitOfWork = {
    transaction: vi.fn(async (callback) =>
      callback({
        product: productRepository,
        order: orderRepository,
      }),
    ),
  }
 
  const orderService = createOrderService({ unitOfWork })
 
  beforeEach(() => {
    vi.clearAllMocks()
  })
 
  it('should create order when product is available', async () => {
    productRepository.findById.mockResolvedValue({
      id: '1',
      name: 'Test',
      priceCents: 10_000,
      stock: 10,
      category: 'test',
    })
 
    productRepository.decrementStockIfAvailable.mockResolvedValue({ count: 1 })
 
    orderRepository.create.mockResolvedValue({
      id: 'order-1',
      userId: 'user-1',
      productId: '1',
      quantity: 2,
      totalCents: 20_000,
      status: 'PENDING',
    })
 
    const result = await orderService.createOrder({
      userId: 'user-1',
      productId: '1',
      quantity: 2,
    })
 
    expect(result.success).toBe(true)
    expect(result).toEqual({ success: true, orderId: 'order-1' })
    expect(productRepository.decrementStockIfAvailable).toHaveBeenCalledWith(
      '1',
      2,
    )
    expect(unitOfWork.transaction).toHaveBeenCalledTimes(1)
  })
 
  it('should fail when product out of stock', async () => {
    productRepository.findById.mockResolvedValue({
      id: '1',
      name: 'Test',
      priceCents: 10_000,
      stock: 1,
      category: 'test',
    })
 
    const result = await orderService.createOrder({
      userId: 'user-1',
      productId: '1',
      quantity: 5,
    })
 
    expect(result.success).toBe(false)
    expect(result).toMatchObject({
      success: false,
      code: 'CONFLICT',
    })
    expect(orderRepository.create).not.toHaveBeenCalled()
  })
})

Struktura plików dla backendu Next.js

Code
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.
Next.js

Często zadawane pytania

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.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Jak poprawnie testować Server Actions w Next.js

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

Maciej Sala

Founder StriveLab

Drizzle ORM oraz Prisma w projektach w Next.js

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

Maciej Sala

Founder StriveLab

Route Handlers czy Server Actions? Kiedy co wybrać

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

Maciej Sala

Founder StriveLab