Стандарты
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)
Связанные документы
- Standard: Backend API — конвенции REST API
- Standard: Error Handling — обработка ошибок
- Standard: Prisma — конвенции ORM
- Standard: Forms & Validation — валидация на клиенте
- ADR-012: Nuxt Server Backend — архитектура бэкенда