Проектирование GraphQL-схемы для веб-приложения
Хорошая GraphQL-схема — это контракт между фронтендом и бэкендом, который живёт годами. Плохо спроектированная схема обрастает костылями уже через три месяца: сломанные n+1, поля с непонятной семантикой, type UserOrError вместо нормальной обработки ошибок. Проектирование начинается с вопросов использования, а не с маппинга таблиц базы данных.
Принципы проектирования
Схема ориентирована на продукт, не на хранилище. REST-эндпоинты часто повторяют структуру БД. В GraphQL — наоборот: сначала определяем, что нужно UI, потом проектируем типы.
Nodes и Edges через Relay-спецификацию. Если проект средний и больше, стоит сразу закладывать Relay-совместимую структуру — она задаёт стандарт пагинации и глобальных ID.
Nullable по умолчанию. Поле nullable лучше, чем non-null, который сломает весь запрос при одном null-значении. Non-null (!) только там, где вы готовы гарантировать значение на уровне бизнес-логики.
Базовые типы
type Query {
node(id: ID!): Node
product(id: ID!): Product
products(filter: ProductFilter, page: PaginationInput): ProductConnection!
order(id: ID!): Order
viewer: User # текущий авторизованный пользователь
}
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
updateOrderStatus(input: UpdateOrderStatusInput!): UpdateOrderStatusPayload!
addToCart(input: AddToCartInput!): AddToCartPayload!
}
type Subscription {
orderStatusUpdated(orderId: ID!): OrderStatusEvent!
}
interface Node {
id: ID!
}
Паттерн Payload для мутаций
Никогда не возвращайте из мутации голый тип объекта. Используйте payload-обёртку:
type CreateOrderPayload {
order: Order # null при ошибке
userErrors: [UserError!]! # всегда массив, пустой при успехе
}
type UserError {
field: [String!] # путь к полю с ошибкой, например ["items", "0", "quantity"]
message: String!
code: OrderErrorCode
}
enum OrderErrorCode {
INSUFFICIENT_STOCK
INVALID_ADDRESS
PAYMENT_DECLINED
PRODUCT_UNAVAILABLE
}
Отличие userErrors от GraphQL-ошибок (errors): userErrors — предсказуемые бизнес-ошибки, которые клиент должен обработать. GraphQL errors — непредвиденные ситуации (исключения, network errors).
Пагинация: Cursor-based
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProductEdge {
node: Product!
cursor: String! # opaque base64 курсор
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
input PaginationInput {
first: Int
after: String
last: Int
before: String
}
Фильтрация и сортировка
input ProductFilter {
categoryIds: [ID!]
priceMin: Decimal
priceMax: Decimal
inStock: Boolean
search: String
tags: [String!]
}
enum ProductSortField {
PRICE
CREATED_AT
POPULARITY
NAME
}
enum SortDirection {
ASC
DESC
}
input ProductSort {
field: ProductSortField!
direction: SortDirection!
}
Версионирование и устаревание
GraphQL не версионируется через URL (/v2/graphql). Вместо этого — continuous evolution:
type Product {
id: ID!
name: String!
# Устаревшее поле — оставляем для совместимости
price: Decimal @deprecated(reason: "Use `pricing.basePrice` instead")
pricing: ProductPricing!
variants: [ProductVariant!]!
categories: [Category!]!
# Метаданные для отладки
_debug: ProductDebugInfo @deprecated(reason: "Debug only, remove before production")
}
type ProductPricing {
basePrice: Decimal!
salePrice: Decimal
currency: CurrencyCode!
isOnSale: Boolean!
discountPercent: Int
}
Директивы
Кастомные директивы для cross-cutting concerns:
directive @auth(requires: Role = USER) on FIELD_DEFINITION
directive @rateLimit(max: Int!, window: String!) on FIELD_DEFINITION
directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT
enum Role { ADMIN MANAGER USER GUEST }
enum CacheControlScope { PUBLIC PRIVATE }
type Order {
id: ID!
status: OrderStatus!
items: [OrderItem!]!
customer: User! @auth(requires: MANAGER)
internalNotes: String @auth(requires: ADMIN)
}
type Query {
products: ProductConnection! @cacheControl(maxAge: 300, scope: PUBLIC)
dashboard: DashboardStats! @auth(requires: MANAGER) @rateLimit(max: 60, window: "1m")
}
Пример полной схемы сущности
type Order implements Node {
id: ID!
number: String! # человекочитаемый номер, например "ORD-2024-001234"
status: OrderStatus!
createdAt: DateTime!
updatedAt: DateTime!
completedAt: DateTime
customer: User!
shippingAddress: Address!
billingAddress: Address
items: [OrderItem!]!
itemsCount: Int!
subtotal: Money!
shippingCost: Money!
discount: Money!
total: Money!
payment: Payment
shipment: Shipment
timeline: [OrderTimelineEvent!]! # история изменений статуса
}
scalar DateTime
scalar Decimal
type Money {
amount: Decimal!
currency: CurrencyCode!
formatted: String! # "1 234,56 ₽" — для прямого вывода
}
Разделение схемы по доменам
Для больших проектов схему разбивают на файлы по доменам и объединяют при загрузке:
schema/
base.graphql # Query, Mutation, Subscription, Node
products.graphql
orders.graphql
users.graphql
payments.graphql
scalars.graphql # DateTime, Decimal, Money, CurrencyCode
directives.graphql
Сроки
Проектирование схемы для приложения среднего масштаба (5–10 сущностей): 3–5 дней. С ревью, документацией полей и согласованием с командой фронтенда: 1 неделя.







