Axolotl is a type-safe, schema-first GraphQL framework that generates TypeScript types from your GraphQL schema and provides full type safety for resolvers. This guide provides exact instructions for LLMs to work with Axolotl projects.
- Write GraphQL schema in
.graphqlfiles - Axolotl CLI generates TypeScript types automatically
- Resolvers are fully typed based on the schema
project/
├── axolotl.json # Configuration file
├── schema.graphql # GraphQL schema
├── src/
│ ├── axolotl.ts # Framework initialization
│ ├── models.ts # Auto-generated types (DO NOT EDIT)
│ ├── resolvers.ts # Resolver implementations
│ └── index.ts # Server entry point
ALWAYS follow these rules when working with Axolotl:
- NEVER edit models.ts manually - always regenerate with
axolotl build - ALWAYS use .js extensions in imports (ESM requirement)
- ALWAYS run axolotl build after schema changes
- CRITICAL: Resolver signature is
(input, args)whereinput = [source, args, context] - CRITICAL: Access context as
input[2]or([, , context]) - CRITICAL: Access parent/source as
input[0]or([source]) - CRITICAL: Context type must extend
YogaInitialContextand spread...initial - Import from axolotl.ts - never from @aexol/axolotl-core directly in resolver files
- Use createResolvers() for ALL resolver definitions
- Use mergeAxolotls() to combine multiple resolver sets
- Return empty object
{}for nested resolver enablement - Context typing requires
graphqlYogaWithContextAdapter<T>(contextFunction)
The axolotl.json configuration file defines:
{
"schema": "schema.graphql", // Path to main schema
"models": "src/models.ts", // Where to generate types
"federation": [
// Optional: for micro-federation
{
"schema": "src/todos/schema.graphql",
"models": "src/todos/models.ts"
}
],
"zeus": [
// Optional: GraphQL client generation
{
"generationPath": "src/"
}
]
}Instructions:
- Read
axolotl.jsonfirst to understand project structure - NEVER edit
axolotl.jsonunless explicitly asked - Use paths from config to locate schema and models
Example:
scalar Secret
type User {
_id: String!
username: String!
}
type Query {
user: AuthorizedUserQuery @resolver
hello: String!
}
type Mutation {
login(username: String!, password: String!): String! @resolver
}
directive @resolver on FIELD_DEFINITION
schema {
query: Query
mutation: Mutation
}Key Points:
- This is the source of truth for your API
- The
@resolverdirective marks fields that need resolver implementations - After modifying schema, ALWAYS run:
npx @aexol/axolotl build
Command:
npx @aexol/axolotl build
# Or with custom directory:
npx @aexol/axolotl build --cwd path/to/projectWhat it does:
- Reads
schema.graphql - Generates TypeScript types in
src/models.ts - Creates type definitions for Query, Mutation, Subscription, and all types
Generated models.ts structure:
// AUTO-GENERATED - DO NOT EDIT
export type Scalars = {
['Secret']: unknown;
};
export type Models<S extends { [P in keyof Scalars]: any }> = {
['User']: {
_id: { args: Record<string, never> };
username: { args: Record<string, never> };
};
['Query']: {
hello: { args: Record<string, never> };
user: { args: Record<string, never> };
};
['Mutation']: {
login: {
args: {
username: string;
password: string;
};
};
};
};Command:
npx @aexol/axolotl resolversWhat it does:
- Reads your schema and finds all fields marked with
@resolverdirective - Generates organized resolver file structure automatically
- Creates placeholder implementations for each resolver field
- Sets up proper import structure and type safety
Generated structure example:
Given a schema with @resolver directives:
type Query {
user: AuthorizedUserQuery @resolver
hello: String!
}
type Mutation {
login(username: String!, password: String!): String! @resolver
}The command generates:
src/
├── resolvers/
│ ├── Query/
│ │ ├── user.ts # Individual field resolver
│ │ └── resolvers.ts # Query type aggregator
│ ├── Mutation/
│ │ ├── login.ts # Individual field resolver
│ │ └── resolvers.ts # Mutation type aggregator
│ └── resolvers.ts # Root aggregator (export this)
Generated file example (Query/user.ts):
import { createResolvers } from '../../axolotl.js';
export default createResolvers({
Query: {
user: async ([parent, details, ctx], args) => {
// TODO: implement resolver for Query.user
throw new Error('Not implemented: Query.user');
},
},
});Generated aggregator (Query/resolvers.ts):
import { createResolvers } from '../../axolotl.js';
import user from './user.js';
export default createResolvers({
Query: {
...user.Query,
},
});Root aggregator (resolvers/resolvers.ts):
import { createResolvers } from '../axolotl.js';
import Query from './Query/resolvers.js';
import Mutation from './Mutation/resolvers.js';
export default createResolvers({
...Query,
...Mutation,
});Key Benefits:
- Automatic scaffolding - No manual file/folder creation needed
- Organized structure - Each resolver in its own file
- Type safety - All generated files use
createResolvers()correctly - Non-destructive - Only creates files that don't exist (won't overwrite your implementations)
- Aggregator files always updated - Type-level and root aggregators are regenerated to stay in sync
When to use:
- ✅ Starting a new project with many resolvers
- ✅ Adding new resolver fields to existing schema
- ✅ Want organized, maintainable resolver structure
- ✅ Working with federated schemas (generates for each module)
Workflow:
- Add
@resolverdirectives to schema fields - Run
npx @aexol/axolotl buildto update types - Run
npx @aexol/axolotl resolversto scaffold structure - Implement TODO sections in generated resolver files
- Import and use
resolvers/resolvers.tsin your server
Note for Federated Projects:
The command automatically detects federation in axolotl.json and generates resolver structures for each federated schema in the appropriate directories.
Purpose: Initialize Axolotl framework with adapter and type definitions.
File: src/axolotl.ts
import { Models, Scalars } from '@/src/models.js';
import { Axolotl } from '@aexol/axolotl-core';
import { graphqlYogaAdapter } from '@aexol/axolotl-graphql-yoga';
export const { applyMiddleware, createResolvers, createDirectives, adapter } = Axolotl(graphqlYogaAdapter)<
Models<{ Secret: number }>, // Models with scalar mappings
Scalars // Scalar type definitions
>();import { Models, Scalars } from '@/src/models.js';
import { Axolotl } from '@aexol/axolotl-core';
import { graphqlYogaWithContextAdapter } from '@aexol/axolotl-graphql-yoga';
import { YogaInitialContext } from 'graphql-yoga';
// Define your context type - MUST extend YogaInitialContext
type AppContext = YogaInitialContext & {
userId: string | null;
isAuthenticated: boolean;
isAdmin: boolean;
requestId: string;
};
// Context builder function
async function buildContext(initial: YogaInitialContext): Promise<AppContext> {
const token = initial.request.headers.get('authorization')?.replace('Bearer ', '');
const user = token ? await verifyToken(token) : null;
return {
...initial, // ✅ MUST spread initial context
userId: user?._id || null,
isAuthenticated: !!user,
isAdmin: user?.role === 'admin' || false,
requestId: crypto.randomUUID(),
};
}
export const { createResolvers, adapter } = Axolotl(graphqlYogaWithContextAdapter<AppContext>(buildContext))<
Models<{ Secret: number }>,
Scalars
>();Key Components:
- Import Models & Scalars from generated
models.ts - Import Axolotl from
@aexol/axolotl-core - Import adapter (GraphQL Yoga in this case)
- Initialize with generics:
- First generic:
Models<ScalarMap>- your type definitions - Second generic:
Scalars- custom scalar types
- First generic:
Exported functions:
createResolvers()- Create type-safe resolverscreateDirectives()- Create custom directivesapplyMiddleware()- Apply middleware to resolversadapter()- Configure and start server
Context Type Safety:
graphqlYogaWithContextAdapter<T>()takes a FUNCTION (not an object)- Your context type MUST extend
YogaInitialContext - The function MUST return an object that includes
...initial - Context is automatically typed in ALL resolvers
The resolver signature is:
(input, args) => ReturnType;Where:
inputis a tuple:[source, args, context]input[0]= source (parent value)input[1]= args (field arguments)input[2]= context (request context)
argsis also provided as second parameter for convenience
import { createResolvers } from '@/src/axolotl.js';
export default createResolvers({
Query: {
hello: async ([source, args, context]) => {
// ↑ ↑ ↑
// input[0] [1] [2]
return 'Hello, World!';
},
},
Mutation: {
login: async ([source, args, context], { username, password }) => {
// ↑ Destructure tuple ↑ Convenience args parameter
const token = await authenticateUser(username, password);
return token;
},
},
});// Pattern 1: Access context only
createResolvers({
Query: {
me: async ([, , context]) => {
return getUserById(context.userId);
},
},
});
// Pattern 2: Access source and context
createResolvers({
AuthorizedUserQuery: {
todos: async ([source, , context]) => {
const src = source as { _id: string };
return getTodosByUserId(src._id);
},
},
});
// Pattern 3: Use convenience args parameter
createResolvers({
Mutation: {
createTodo: async ([, , context], { content }) => {
return createTodo(content, context.userId);
},
},
});
// Pattern 4: Ignore unused with underscores
createResolvers({
Query: {
me: async ([_, __, context]) => {
return getUserById(context.userId);
},
},
});In nested resolvers, the parent (also called source) is the value returned by the parent resolver.
// Schema
type Query {
user: AuthorizedUserQuery @resolver
}
type AuthorizedUserQuery {
me: User! @resolver
todos: [Todo!] @resolver
}
// Resolvers
createResolvers({
Query: {
user: async ([, , context]) => {
const token = context.request.headers.get('authorization');
const user = await verifyToken(token);
// This object becomes the SOURCE for AuthorizedUserQuery resolvers
return {
_id: user._id,
username: user.username,
};
},
},
AuthorizedUserQuery: {
me: ([source]) => {
// source is what Query.user returned
const src = source as { _id: string; username: string };
return src;
},
todos: async ([source]) => {
// Access parent data
const src = source as { _id: string };
return getTodosByUserId(src._id);
},
},
});Method 1: Type Assertion (Simple)
type UserSource = {
_id: string;
username: string;
token?: string;
};
export default createResolvers({
AuthorizedUserQuery: {
me: ([source]) => {
const src = source as UserSource;
return {
_id: src._id,
username: src.username,
};
},
},
});Method 2: Using setSourceTypeFromResolver (Advanced)
import { createResolvers, setSourceTypeFromResolver } from '@aexol/axolotl-core';
const getUserResolver = async ([, , context]) => {
const user = await authenticateUser(context);
return {
_id: user._id,
username: user.username,
email: user.email,
};
};
const getUser = setSourceTypeFromResolver(getUserResolver);
export default createResolvers({
Query: {
user: getUserResolver,
},
AuthorizedUserQuery: {
me: ([source]) => {
const src = getUser(source); // src is now fully typed
return src;
},
},
});// src/resolvers/Query/resolvers.ts
import { createResolvers } from '../axolotl.js';
import user from './user.js';
export default createResolvers({
Query: {
...user.Query,
},
});
// src/resolvers/Query/user.ts
import { createResolvers } from '../axolotl.js';
export default createResolvers({
Query: {
user: async ([, , context]) => {
// Return object to enable nested resolvers
return {};
},
},
});
// Main resolvers.ts
import { mergeAxolotls } from '@aexol/axolotl-core';
import QueryResolvers from '@/src/resolvers/Query/resolvers.js';
import MutationResolvers from '@/src/resolvers/Mutation/resolvers.js';
export default mergeAxolotls(QueryResolvers, MutationResolvers);Key Points:
- Arguments are automatically typed from schema
- Return types must match schema definitions
- For nested resolvers, return an empty object
{}in parent resolver - Always use async functions (best practice)
Purpose: Enable real-time updates via GraphQL Subscriptions.
Add a Subscription type to your schema:
type Subscription {
countdown(from: Int): Int @resolver
messageAdded: Message @resolver
}
schema {
query: Query
mutation: Mutation
subscription: Subscription
}CRITICAL: All subscription resolvers MUST use createSubscriptionHandler from @aexol/axolotl-core.
import { createResolvers, createSubscriptionHandler } from '@aexol/axolotl-core';
import { setTimeout as setTimeout$ } from 'node:timers/promises';
export default createResolvers({
Subscription: {
// Simple countdown subscription
countdown: createSubscriptionHandler(async function* (input, { from }) {
// input is [source, args, context] - same as regular resolvers
const [, , context] = input;
for (let i = from || 10; i >= 0; i--) {
await setTimeout$(1000);
yield i;
}
}),
// Event-based subscription with PubSub
messageAdded: createSubscriptionHandler(async function* (input) {
const [, , context] = input;
const channel = context.pubsub.subscribe('MESSAGE_ADDED');
for await (const message of channel) {
yield message;
}
}),
},
});- Always use
createSubscriptionHandler- It wraps your async generator function - Use async generators - Functions with
async function*that yield values - Return values directly - The framework handles wrapping in the subscription field
- Access context - Same
[source, args, context]signature as regular resolvers - Works with GraphQL Yoga - Supports both SSE and WebSocket transports
Schema:
type Subscription {
countdown(from: Int = 10): Int @resolver
}Resolver:
import { createResolvers, createSubscriptionHandler } from '@aexol/axolotl-core';
import { setTimeout as setTimeout$ } from 'node:timers/promises';
export default createResolvers({
Subscription: {
countdown: createSubscriptionHandler(async function* (input, { from }) {
console.log(`Starting countdown from ${from}`);
for (let i = from || 10; i >= 0; i--) {
await setTimeout$(1000);
yield i;
}
console.log('Countdown complete!');
}),
},
});GraphQL Query:
subscription {
countdown(from: 5)
}import { createResolvers, createSubscriptionHandler } from '@aexol/axolotl-core';
export default createResolvers({
Mutation: {
sendMessage: async ([, , ctx], { text }) => {
const message = {
id: crypto.randomUUID(),
text,
timestamp: new Date().toISOString(),
};
// Publish event
await ctx.pubsub.publish('MESSAGE_ADDED', message);
return message;
},
},
Subscription: {
messageAdded: createSubscriptionHandler(async function* (input) {
const [, , ctx] = input;
const channel = ctx.pubsub.subscribe('MESSAGE_ADDED');
try {
for await (const message of channel) {
yield message;
}
} finally {
// Cleanup on disconnect
await channel.unsubscribe();
}
}),
},
});In federated setups, each subscription field should only be defined in one module:
// ✅ CORRECT: Define in one module only
// users/schema.graphql
type Subscription {
userStatusChanged(userId: String!): UserStatus @resolver
}
// ❌ WRONG: Multiple modules defining the same subscription
// users/schema.graphql
type Subscription {
statusChanged: Status @resolver
}
// todos/schema.graphql
type Subscription {
statusChanged: Status @resolver # Conflict!
}If multiple modules try to define the same subscription field, only the first one encountered will be used.
File: src/index.ts
import { adapter } from '@/src/axolotl.js';
import resolvers from '@/src/resolvers.js';
const { server, yoga } = adapter(
{ resolvers },
{
yoga: {
graphiql: true, // Enable GraphiQL UI
},
},
);
server.listen(4000, () => {
console.log('Server running on http://localhost:4000');
});import { GraphQLScalarType, Kind } from 'graphql';
import { createScalars } from '@/src/axolotl.js';
const scalars = createScalars({
Secret: new GraphQLScalarType({
name: 'Secret',
serialize: (value) => String(value),
parseValue: (value) => Number(value),
parseLiteral: (ast) => {
if (ast.kind !== Kind.INT) return null;
return Number(ast.value);
},
}),
});
adapter({ resolvers, scalars });Directives add cross-cutting concerns like authentication, authorization, and logging to your schema fields.
import { createDirectives } from '@/src/axolotl.js';
import { MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLError } from 'graphql';
const directives = createDirectives({
// Directive function signature: (schema, getDirective) => SchemaMapperConfig
auth: (schema, getDirective) => {
// Return mapper config object (NOT a schema!)
return {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
// Check if field has @auth directive
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (!authDirective) {
return fieldConfig; // No directive, return unchanged
}
// Get original resolver
const { resolve = defaultFieldResolver } = fieldConfig;
// Return field with wrapped resolver for runtime behavior
return {
...fieldConfig,
resolve: async (source, args, context, info) => {
// This runs on EVERY request to this field
if (!context.userId) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHORIZED' },
});
}
// Call original resolver
return resolve(source, args, context, info);
},
};
},
};
},
});
adapter({ resolvers, directives });Schema:
directive @auth on FIELD_DEFINITION
type Query {
publicData: String!
protectedData: String! @auth # Only authenticated users
}Key Points:
- Directive function receives
(schema, getDirective)parameters - Must return mapper config object
{ [MapperKind.X]: ... } - Use
getDirective()to check if field has the directive - Wrap
resolvefunction to add runtime behavior per request - The adapter calls
mapSchema()internally - don't call it in your directive
Purpose: Merge multiple GraphQL schemas and resolvers into one API.
Configuration in axolotl.json:
{
"schema": "schema.graphql",
"models": "src/models.ts",
"federation": [
{
"schema": "src/todos/schema.graphql",
"models": "src/todos/models.ts"
},
{
"schema": "src/users/schema.graphql",
"models": "src/users/models.ts"
}
]
}Each module has its own:
schema.graphqlmodels.ts(generated)axolotl.ts(module-specific initialization)- Resolvers
Module axolotl.ts:
// src/todos/axolotl.ts
import { Models } from '@/src/todos/models.js';
import { Axolotl } from '@aexol/axolotl-core';
import { graphqlYogaAdapter } from '@aexol/axolotl-graphql-yoga';
export const { createResolvers } = Axolotl(graphqlYogaAdapter)<Models>();Main resolvers (merge):
// src/resolvers.ts
import { mergeAxolotls } from '@aexol/axolotl-core';
import todosResolvers from '@/src/todos/resolvers/resolvers.js';
import usersResolvers from '@/src/users/resolvers/resolvers.js';
export default mergeAxolotls(todosResolvers, usersResolvers);Key Points:
- Run
axolotl buildto generate ALL models (main + federated) - Each module has its own axolotl.ts using its own models
- Merge all resolvers using
mergeAxolotls() - Schema files are merged automatically by CLI
# Create new Axolotl project with Yoga
npx @aexol/axolotl create-yoga my-project
# Generate models from schema
npx @aexol/axolotl build
# Generate models with custom directory
npx @aexol/axolotl build --cwd path/to/project
# Generate resolver boilerplate from @resolver directives
npx @aexol/axolotl resolvers
# Inspect resolvers (find unimplemented @resolver fields)
npx @aexol/axolotl inspect -s schema.graphql -r lib/resolvers.jsThe inspect command identifies which resolvers marked with @resolver directive are not yet implemented:
npx @aexol/axolotl inspect -s ./schema.graphql -r ./lib/resolvers.jsWhat it does:
- Finds all fields marked with
@resolverdirective in your schema - Checks if resolvers are missing or still contain stub implementations
- Reports only unimplemented resolvers (not all schema fields)
Example output:
Resolvers that need implementation:
⚠️ Query.users - throws "Not implemented"
❌ Mutation.login - not found
❌ Mutation.register - not found
Total: 3 resolver(s) to implement
Status indicators:
- ✅ All implemented - Command exits with code 0
⚠️ Stub - Resolver exists but throws "Not implemented" error- ❌ Missing - No resolver function exists for this field
Tip: Use npx @aexol/axolotl resolvers to generate stubs, then use inspect to track implementation progress.
---
## LLM Workflow Checklist
When working with an Axolotl project:
1. ✅ **Read axolotl.json** to understand structure
2. ✅ **Check schema.graphql** for current schema
3. ✅ **Verify models.ts is up-to-date** (regenerate if needed)
4. ✅ **Locate axolotl.ts** to understand initialization
5. ✅ **Find resolver files** and understand structure
6. ✅ **Make schema changes** if requested
7. ✅ **Run `axolotl build`** after schema changes
8. ✅ **Optionally run `axolotl resolvers`** to scaffold new resolver files
9. ✅ **Update resolvers** to match new types
10. ✅ **Test** that server starts without type errors
---
## Common Patterns Cheat Sheet
### Context Type Safety
```typescript
// ✅ CORRECT
type AppContext = YogaInitialContext & { userId: string };
graphqlYogaWithContextAdapter<AppContext>(async (initial) => ({
...initial,
userId: '123',
}));
// ❌ WRONG - Not extending YogaInitialContext
type AppContext = { userId: string };
// ❌ WRONG - Not spreading initial
graphqlYogaWithContextAdapter<AppContext>(async (initial) => ({
userId: '123', // Missing ...initial
}));
// ❌ WRONG - Passing object instead of function
graphqlYogaWithContextAdapter<AppContext>({ userId: '123' });
// Type-safe arguments (auto-typed from schema)
createResolvers({
Query: {
user: async ([, , context], { id, includeEmail }) => {
// id: string, includeEmail: boolean | undefined
return getUserById(id, includeEmail);
},
},
});
// Nested resolvers
createResolvers({
Query: {
user: async ([, , context]) => {
return {}; // Enable nested resolvers
},
},
UserQuery: {
me: async ([, , context]) => {
return getUserById(context.userId);
},
},
});Solution: Run npx @aexol/axolotl build to regenerate models
Solution: Map scalars in axolotl.ts:
Axolotl(adapter)<Models<{ MyScalar: string }>, Scalars>();Solution: Use graphqlYogaWithContextAdapter<YourContextType>(contextFunction)
Solution: Make sure you spread ...initial when building context
| Task | Command/Code |
|---|---|
| Initialize project | npx @aexol/axolotl create-yoga <name> |
| Generate types | npx @aexol/axolotl build |
| Scaffold resolvers | npx @aexol/axolotl resolvers |
| Create resolvers | createResolvers({ Query: {...} }) |
| Access context | ([, , context]) - third in tuple |
| Access parent | ([source]) - first in tuple |
| Merge resolvers | mergeAxolotls(resolvers1, resolvers2) |
| Start server | adapter({ resolvers }).server.listen(4000) |
| Add custom context | graphqlYogaWithContextAdapter<Ctx>(contextFn) |
| Context must extend | YogaInitialContext & { custom } |
| Context must include | { ...initial, ...custom } |
| Define scalars | createScalars({ ScalarName: GraphQLScalarType }) |
| Define directives | createDirectives({ directiveName: mapper }) |
| Inspect resolvers | npx @aexol/axolotl inspect -s schema.graphql -r resolvers |
This guide provides everything an LLM needs to work effectively with Axolotl projects, from understanding the structure to implementing resolvers with full type safety.