GraphQL schema design principles
A well-designed GraphQL schema is the contract between your API and consumers. Unlike REST, where the backend controls response structure, in GraphQL the client requests exactly what it needs. This requires modeling data from frontend needs.
Start with use cases: Before writing types, list the queries clients will need. 'I want to show a profile with posts and followers' → you need type User with relationships. This prevents schemas with fields nobody uses.
Strong typing is key: Use ID! for required identifiers, String! for required fields, [Type!]! for non-null lists of non-null items. The ! indicates obligation. This catches errors in development, not production.
Bidirectional relationships: If Post has author: User!, consider if User needs posts: [Post!]!. But watch out: bidirectional relationships can generate over-fetching if you don't use DataLoader to batch queries and avoid N+1.
Avoiding common modeling errors
Don't mirror your database: Your schema isn't your exposed DB. If you have a users_posts_likes table with foreign IDs, don't create a UsersPostsLikes type. Expose Like with clean relationships. The schema should model the domain, not internal implementation.
Vague or technical names: type Data { info: String } says nothing. Be specific: type UserProfile { bio: String }. Avoid cryptic abbreviations. The schema is living documentation.
Queries with too many parameters: users(filterByName: String, filterByEmail: String, filterByAge: Int, sortBy: String, order: String, limit: Int, offset: Int) is unmanageable. Use input types: users(filter: UserFilter, pagination: PaginationInput).
Mutations without input types: createUser(name: String! email: String! age: Int bio: String avatar: String) vs createUser(input: CreateUserInput!). Input types scale better and allow complex validations.
Pagination, filters, and sorting
Cursor-based pagination: For infinite feeds, use connections: posts(first: Int after: String): PostConnection! with type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! }. This scales better than offset-based for large datasets.
Offset-based pagination: For simple cases: users(limit: Int offset: Int): [User!]!. Works well up to ~10k records. After that, cursor-based is more efficient.
Complex filters: Use nested input types. input ProductFilter { category: String priceRange: PriceRangeInput inStock: Boolean } with input PriceRangeInput { min: Float max: Float }. This allows filter composition without exploding the query signature.
Sorting: Enum for options: enum PostSort { RECENT POPULAR TRENDING }. Combine with order: SortOrder where enum SortOrder { ASC DESC } if you need direction control.
Subscriptions and real-time
Subscriptions enable server-to-client push. Use WebSockets or Server-Sent Events depending on your infrastructure. Typical structure: type Subscription { messageAdded(conversationId: ID!): Message! }.
When to use subscriptions: Real-time chat, push notifications, status updates (order processing, stock changing), live collaboration (users editing same doc). Not for polling that can be query every X seconds.
Performance: Each open subscription consumes server resources. Implement authentication in subscriptions: context must validate user can subscribe to resource. Limit subscriptions per connection to prevent abuse.
Alternatives: If your use case tolerates 5-10 seconds latency, polling with queries is simpler. If you need live updates but not sub-second, long-polling or webhooks may suffice. Subscriptions add complexity; validate they're justified.