DEV Community

Thesius Code
Thesius Code

Posted on • Originally published at datanest-stores.pages.dev

GraphQL Starter Kit

GraphQL Starter Kit

Move beyond REST's over-fetching and under-fetching problems with a production-ready GraphQL server that's actually designed for scale. This kit gives you a fully structured schema with type-safe resolvers, N+1 query prevention via DataLoader, JWT authentication with field-level authorization, real-time subscriptions over WebSocket, and pagination patterns that work with real databases. Not a toy todo-app — a foundation for APIs that serve millions of queries.

Key Features

  • Schema-First Design — Complete SDL schema with custom scalars (DateTime, Email, URL), input validation directives, and interface/union types for polymorphic data
  • DataLoader Integration — Batch and cache database lookups to eliminate N+1 queries, with per-request scoped loaders that prevent stale data across requests
  • JWT Authentication Middleware — Token validation, role-based access control, and field-level @auth(requires: ADMIN) directives
  • Subscription Support — WebSocket-based real-time updates with filtered subscriptions (subscribe to changes for specific entities)
  • Cursor-Based Pagination — Relay-style connections with first/after/last/before args, total counts, and pageInfo with hasNextPage
  • Error Handling Framework — Structured GraphQL errors with error codes, user-friendly messages, and debug extensions in development mode
  • Query Complexity Analysis — Prevent abuse by calculating query cost before execution and rejecting queries that exceed a configurable threshold

Quick Start

  1. Review the schema definition:
# src/schema/schema.graphql
type Query {
    user(id: ID!): User
    users(first: Int = 10, after: String): UserConnection!
    me: User @auth(requires: USER)
}

type Mutation {
    createUser(input: CreateUserInput!): UserPayload!
    deleteUser(id: ID!): DeletePayload! @auth(requires: ADMIN)
}

type Subscription {
    userCreated: User!
    userUpdated(id: ID): User!
}

type User {
    id: ID!
    email: String!
    name: String!
    role: Role!
    posts(first: Int = 10, after: String): PostConnection!
    createdAt: DateTime!
}

enum Role { USER ADMIN MODERATOR }
Enter fullscreen mode Exit fullscreen mode
  1. Start the server:
from graphql_starter_kit.server import create_app
from graphql_starter_kit.config import load_config
app = create_app(load_config("config.example.yaml"))

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=4000)
Enter fullscreen mode Exit fullscreen mode
  1. Query your API:
query GetUserWithPosts {
    user(id: "42") {
        name
        email
        posts(first: 5) {
            edges {
                node {
                    title
                    createdAt
                }
            }
            pageInfo {
                hasNextPage
                endCursor
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Architecture

graphql-starter-kit/
├── src/graphql_starter_kit/
│   ├── core.py              # App factory, middleware chain
│   ├── server.py            # ASGI server with WebSocket support
│   ├── schema/              # SDL schema, custom scalars, directives
│   ├── resolvers/           # Query, mutation, subscription, type resolvers
│   ├── dataloaders/         # Batched user/post loaders
│   ├── auth/                # JWT middleware, role-based permissions
│   ├── pagination.py        # Cursor encode/decode, connection builder
│   └── utils.py             # Error formatting, complexity calculator
├── examples/                # Sample queries and mutations
└── config.example.yaml
Enter fullscreen mode Exit fullscreen mode

Request flow: HTTP/WebSocket -> Auth Middleware -> Complexity Check -> Resolver -> DataLoader (batched DB call) -> Response Formatting.

Usage Examples

DataLoader to Prevent N+1

# src/graphql_starter_kit/dataloaders/user_loader.py
class UserLoader:
    """Batches individual user lookups into a single query."""
    def __init__(self, db):
        self._db = db
        self._cache: dict[str, dict] = {}
        self._queue: list[str] = []

    async def load(self, user_id: str) -> dict | None:
        if user_id in self._cache:
            return self._cache[user_id]
        self._queue.append(user_id)
        # Single batched query instead of N individual queries
        ids = list(set(self._queue))
        self._queue.clear()
        rows = await self._db.fetch_many("SELECT * FROM users WHERE id = ANY($1)", ids)
        for row in rows:
            self._cache[str(row["id"])] = dict(row)
        return self._cache.get(user_id)
Enter fullscreen mode Exit fullscreen mode

Query Complexity Limiting

# Maximum query cost before rejection
# Each field has a cost of 1, list fields multiply by the `first` argument
# Example: user { posts(first: 10) { title } } = 1 + (10 * 1) = 11
MAX_QUERY_COST = 500
Enter fullscreen mode Exit fullscreen mode

Configuration

Key Type Default Description
server.port int 4000 Server listen port
server.debug bool false Enable GraphiQL playground and debug errors
auth.jwt_secret string required YOUR_JWT_SECRET_HERE
auth.token_expiry_hours int 24 JWT token lifetime
query.max_complexity int 500 Maximum allowed query cost
query.max_depth int 10 Maximum query nesting depth
pagination.default_page_size int 10 Default items per page
pagination.max_page_size int 100 Maximum items per page
subscriptions.enabled bool true Enable WebSocket subscriptions
dataloader.cache_per_request bool true Scope DataLoader cache to request

Best Practices

  • Always use DataLoaders for nested relationships. Without them, users { posts { author } } generates O(N*M) database queries. DataLoaders collapse these into 2-3 batched queries.
  • Limit query depth AND complexity. Depth alone doesn't prevent users(first: 1000) { posts(first: 1000) }. Complexity analysis multiplies list sizes through the tree.
  • Return payloads from mutations, not bare types. UserPayload with user and errors fields lets clients handle partial failures gracefully.
  • Version via schema evolution, not URL paths. Add fields freely, deprecate old fields with @deprecated(reason: "..."), remove after zero usage confirmed.

Troubleshooting

N+1 queries still appearing in logs
Verify DataLoaders are instantiated per-request in your context factory, not globally. Check that resolvers call loader.load(id) instead of direct DB queries.

Subscription connection drops after 30 seconds
WebSocket connections need keepalive pings. Set --ws-ping-interval 20 for Uvicorn and proxy_read_timeout 300s in Nginx.

"Query complexity exceeds maximum" on simple queries
List fields without a first argument may default to a high assumed page size. Set explicit defaults in your schema (users(first: Int = 10)).


This is 1 of 7 resources in the API Developer Pro toolkit. Get the complete [GraphQL Starter Kit] with all files, templates, and documentation for $39.

Get the Full Kit →

Or grab the entire API Developer Pro bundle (7 products) for $79 — save 30%.

Get the Complete Bundle →


Related Articles

Top comments (0)