Проектування 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! # непрозорий 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 тиждень.







