Стандарты

Service Layer

Стандарт абстракции бизнес-логики в серверных API

Service Layer

Принцип

API-хэндлеры (event handlers) — тонкие. Вся бизнес-логика живёт в сервисном слое.

Приоритет: P0 — обязательно для всех API-эндпоинтов.

Зачем

Прямые вызовы Prisma в хэндлерах создают проблемы:

  • Бизнес-логика размазана по файлам API-роутов
  • Невозможно переиспользовать логику между хэндлерами и Socket.io
  • Сложно тестировать — нужно мокать HTTP-запрос целиком
  • При смене ORM (Prisma → Drizzle) надо править каждый роут

Структура

server/
  api/
    users/
      index.get.ts        ← тонкий хэндлер
      index.post.ts
      [id].get.ts
      [id].put.ts
  services/
    user.service.ts        ← бизнес-логика
    auth.service.ts
    notification.service.ts
  repositories/
    user.repository.ts     ← доступ к данным (опционально)
  utils/
    prisma.ts              ← Prisma client singleton

Правила

1. Хэндлер — только транспортный слой

Хэндлер отвечает за:

  • Извлечение параметров из запроса
  • Валидацию входных данных (Zod)
  • Вызов сервиса
  • Формирование HTTP-ответа (статус, заголовки)
// ✅ Хорошо — хэндлер тонкий
// server/api/users/index.post.ts
import { createUserSchema } from '~/shared/schemas/user'
import { UserService } from '~/server/services/user.service'

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, createUserSchema.parse)
  const user = await UserService.create(body)
  setResponseStatus(event, 201)
  return user
})
// ❌ Плохо — бизнес-логика в хэндлере
export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  // Валидация вручную
  if (!body.email) throw createError({ statusCode: 400 })

  // Проверка уникальности — бизнес-логика
  const existing = await prisma.user.findUnique({ where: { email: body.email } })
  if (existing) throw createError({ statusCode: 409 })

  // Хэширование пароля — бизнес-логика
  const hashedPassword = await hash(body.password)

  // Создание + уведомление — бизнес-логика
  const user = await prisma.user.create({
    data: { ...body, password: hashedPassword }
  })
  await sendWelcomeEmail(user.email)

  return user
})

2. Сервис содержит бизнес-логику

Сервис — чистый TypeScript класс или объект с методами. Не знает ни о HTTP, ни о Socket.io.

// server/services/user.service.ts
import { prisma } from '~/server/utils/prisma'
import { hash } from '~/server/utils/crypto'
import { AppError } from '~/server/utils/errors'
import type { CreateUserInput, UpdateUserInput } from '~/shared/schemas/user'

export const UserService = {
  async create(input: CreateUserInput) {
    const existing = await prisma.user.findUnique({
      where: { email: input.email },
    })
    if (existing) {
      throw new AppError('USER_EXISTS', 'Пользователь с таким email уже существует', 409)
    }

    const user = await prisma.user.create({
      data: {
        ...input,
        password: await hash(input.password),
      },
    })

    // Side-effects
    await NotificationService.sendWelcome(user.email)

    return exclude(user, ['password'])
  },

  async getById(id: string) {
    const user = await prisma.user.findUnique({ where: { id } })
    if (!user) {
      throw new AppError('USER_NOT_FOUND', 'Пользователь не найден', 404)
    }
    return exclude(user, ['password'])
  },

  async update(id: string, input: UpdateUserInput) {
    await this.getById(id) // проверяет существование
    return prisma.user.update({
      where: { id },
      data: input,
    })
  },

  async delete(id: string) {
    await this.getById(id)
    return prisma.user.delete({ where: { id } })
  },
}

3. Сервис переиспользуется между HTTP и Socket.io

// server/api/tasks/[id].put.ts — HTTP
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')!
  const body = await readValidatedBody(event, updateTaskSchema.parse)
  return TaskService.update(id, body)
})

// server/plugins/socket.ts — Socket.io
io.on('connection', (socket) => {
  socket.on('task:update', async (data) => {
    const task = await TaskService.update(data.id, data)
    socket.to(`project:${task.projectId}`).emit('task:updated', task)
  })
})

4. Repository — опционально

Для простых CRUD используй Prisma напрямую в сервисе. Repository нужен, если:

  • Запросы сложные (джойны, агрегации, raw SQL)
  • Нужно кэширование на уровне запросов
  • Планируется смена хранилища
// server/repositories/user.repository.ts — для сложных случаев
export const UserRepository = {
  async findWithStats(id: string) {
    return prisma.user.findUnique({
      where: { id },
      include: {
        _count: { select: { posts: true, comments: true } },
        posts: { take: 5, orderBy: { createdAt: 'desc' } },
      },
    })
  },
}

Валидация на границе — Zod-схемы

Схемы валидации живут в shared/schemas/ и используются и на клиенте (формы), и на сервере (хэндлеры):

// shared/schemas/user.ts
import { z } from 'zod'

export const createUserSchema = z.object({
  email: z.string().email('Некорректный email'),
  name: z.string().min(2, 'Минимум 2 символа'),
  password: z.string().min(8, 'Минимум 8 символов'),
})

export const updateUserSchema = createUserSchema.partial().omit({ password: true })

export type CreateUserInput = z.infer<typeof createUserSchema>
export type UpdateUserInput = z.infer<typeof updateUserSchema>

Чек-лист

  • Хэндлер не содержит бизнес-логики — только валидация, вызов сервиса, формирование ответа
  • Бизнес-логика сосредоточена в сервисе
  • Сервис не зависит от HTTP-контекста (event, request, response)
  • Zod-схемы живут в shared/schemas/ и используются и на клиенте, и на сервере
  • Сервис выбрасывает AppError с кодом и статусом, а не createError
  • Один сервис на одну доменную сущность (UserService, TaskService)

Связанные документы