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
Micro-federation is one of Axolotl's most powerful features, allowing you to compose multiple GraphQL modules into a unified API while maintaining type safety and code organization.
Micro-federation in Axolotl is a modular architecture pattern where:
- Each domain (e.g., users, todos, products) has its own GraphQL schema and resolvers
- Schemas are automatically merged into a single supergraph at build time
- Each module maintains its own type safety with generated models
- Resolvers are intelligently merged at runtime to handle overlapping types
Key Difference from Apollo Federation: Axolotl's micro-federation is designed for monorepo or single-project architectures where all modules are built and deployed together, not for distributed microservices.
Good use cases:
- Large monorepo applications with distinct domain modules
- Teams working on separate features within the same codebase
- Projects where you want to organize GraphQL code by business domain
- Applications that need to scale code organization without microservices complexity
Not recommended for:
- Distributed services that deploy independently (use Apollo Federation instead)
- Simple applications with only a few types and resolvers
- Projects where all types are tightly coupled
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"
}
]
}Recommended structure for a federated project:
project/
├── axolotl.json # Main config with federation array
├── schema.graphql # Generated supergraph (don't edit manually)
├── src/
│ ├── models.ts # Generated supergraph models
│ ├── axolotl.ts # Main Axolotl instance
│ ├── resolvers.ts # Merged resolvers (calls mergeAxolotls)
│ ├── index.ts # Server entry point
│ │
│ ├── users/ # Users domain module
│ │ ├── schema.graphql # Users schema
│ │ ├── models.ts # Generated from users schema
│ │ ├── axolotl.ts # Users Axolotl instance
│ │ ├── db.ts # Users data layer
│ │ └── resolvers/
│ │ ├── resolvers.ts # Main users resolvers export
│ │ ├── Mutation/
│ │ │ ├── resolvers.ts
│ │ │ ├── login.ts
│ │ │ └── register.ts
│ │ └── Query/
│ │ ├── resolvers.ts
│ │ └── user.ts
│ │
│ └── todos/ # Todos domain module
│ ├── schema.graphql
│ ├── models.ts
│ ├── axolotl.ts
│ ├── db.ts
│ └── resolvers/
│ ├── resolvers.ts
│ ├── AuthorizedUserMutation/
│ ├── AuthorizedUserQuery/
│ └── TodoOps/
Each module defines its own schema:
src/users/schema.graphql:
type User {
_id: String!
username: String!
}
type Mutation {
login(username: String!, password: String!): String! @resolver
register(username: String!, password: String!): String! @resolver
}
type Query {
user: AuthorizedUserQuery! @resolver
}
type AuthorizedUserQuery {
me: User! @resolver
}
directive @resolver on FIELD_DEFINITION
schema {
query: Query
mutation: Mutation
}src/todos/schema.graphql:
type Todo {
_id: String!
content: String!
done: Boolean
}
type AuthorizedUserMutation {
createTodo(content: String!): String! @resolver
todoOps(_id: String!): TodoOps! @resolver
}
type AuthorizedUserQuery {
todos: [Todo!] @resolver
todo(_id: String!): Todo! @resolver
}
type TodoOps {
markDone: Boolean @resolver
}
directive @resolver on FIELD_DEFINITION
type Query {
user: AuthorizedUserQuery @resolver
}
type Mutation {
user: AuthorizedUserMutation @resolver
}
schema {
query: Query
mutation: Mutation
}Each module needs its own axolotl.ts file to create type-safe resolver helpers:
src/users/axolotl.ts:
import { Models } from '@/src/users/models.js';
import { Axolotl } from '@aexol/axolotl-core';
import { graphqlYogaAdapter } from '@aexol/axolotl-graphql-yoga';
export const { createResolvers, createDirectives, applyMiddleware } = Axolotl(graphqlYogaAdapter)<Models, unknown>();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, createDirectives, applyMiddleware } = Axolotl(graphqlYogaAdapter)<Models, unknown>();When you run axolotl build, schemas are merged using these rules:
1. Types are merged by name:
- If
Usertype exists in multiple schemas, all fields are combined - Fields with the same name must have identical type signatures
- If there's a conflict, the build fails with a detailed error
Example - Types get merged:
# users/schema.graphql
type User {
_id: String!
username: String!
}
# todos/schema.graphql
type User {
_id: String!
}
# Merged result in schema.graphql
type User {
_id: String!
username: String! # Field from users module
}2. Root types (Query, Mutation, Subscription) are automatically merged:
# users/schema.graphql
type Query {
user: AuthorizedUserQuery! @resolver
}
# todos/schema.graphql
type Query {
user: AuthorizedUserQuery @resolver
}
# Merged result - fields combined
type Query {
user: AuthorizedUserQuery @resolver
}The mergeAxolotls function intelligently merges resolvers:
1. Non-overlapping resolvers are combined:
// users: { Mutation: { login: fn1 } }
// todos: { Mutation: { createTodo: fn2 } }
// Result: { Mutation: { login: fn1, createTodo: fn2 } }2. Overlapping resolvers are executed in parallel and results are deep-merged:
// users: { Query: { user: () => ({ username: "john" }) } }
// todos: { Query: { user: () => ({ todos: [...] }) } }
// Result: { Query: { user: () => ({ username: "john", todos: [...] }) } }This allows multiple modules to contribute different fields to the same resolver!
3. Subscriptions cannot be merged - only the first one is used:
// If multiple modules define the same subscription, only the first is used
// This is because subscriptions have a single event streamModule resolvers example:
// src/users/resolvers/resolvers.ts
import { createResolvers } from '../axolotl.js';
import Mutation from './Mutation/resolvers.js';
import Query from './Query/resolvers.js';
import AuthorizedUserQuery from './AuthorizedUserQuery/resolvers.js';
export default createResolvers({
...Mutation,
...Query,
...AuthorizedUserQuery,
});// src/users/resolvers/Mutation/login.ts
import { createResolvers } from '../../axolotl.js';
import { db } from '../../db.js';
export default createResolvers({
Mutation: {
login: async (_, { password, username }) => {
const user = db.users.find((u) => u.username === username && u.password === password);
return user?.token;
},
},
});Main resolvers (merge all modules):
// 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);1. Read axolotl.json
↓
2. For each federation entry:
- Parse schema file
- Generate models.ts with TypeScript types
↓
3. Merge all schemas using graphql-js-tree
↓
4. Write merged schema to root schema file
↓
5. Generate root models.ts from supergraph
↓
6. Each module uses its own models for type safety
When modules need to reference the same types, define them in each schema:
# src/users/schema.graphql
type User {
_id: String!
username: String!
}# src/todos/schema.graphql
type User {
_id: String! # Shared fields must match exactly
}
type Todo {
_id: String!
content: String!
owner: User! # Reference the shared type
}The schemas will be merged, and the User type will contain all fields from both definitions.
Modules can extend each other's types by defining resolvers for shared types:
// src/todos/resolvers/Query/user.ts
import { createResolvers } from '@/src/axolotl.js';
import { db as usersDb } from '@/src/users/db.js';
// Todos module contributes to the Query.user resolver
export default createResolvers({
Query: {
user: async (input) => {
const token = input[2].request.headers.get('token');
const user = usersDb.users.find((u) => u.token === token);
if (!user) throw new Error('Not authorized');
return user;
},
},
});When multiple modules implement the same resolver, their results are deep-merged automatically.
Define scalars in each module that uses them:
# src/todos/schema.graphql
scalar Secret
type AuthorizedUserMutation {
createTodo(content: String!, secret: Secret): String! @resolver
}Scalar resolvers should be defined once in the main axolotl.ts:
// src/axolotl.ts
import { Axolotl } from '@aexol/axolotl-core';
import { Models } from '@/src/models.js';
export const { adapter, createResolvers } = Axolotl(graphqlYogaAdapter)<
Models<{ Secret: number }>, // Map custom scalar to TypeScript type
unknown
>();Subscriptions work in federated setups, but each subscription field should only be defined in one module:
// src/users/resolvers/Subscription/countdown.ts
import { createResolvers, createSubscriptionHandler } from '@aexol/axolotl-core';
export default createResolvers({
Subscription: {
countdown: createSubscriptionHandler(async function* (input, { from }) {
for (let i = from ?? 3; i >= 0; i--) {
yield i;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}),
},
});If multiple modules try to define the same subscription field, only the first one encountered will be used.
Directives must be defined in each schema that uses them:
directive @resolver on FIELD_DEFINITION
directive @auth(role: String!) on FIELD_DEFINITIONDirective implementations should be registered in each module's Axolotl instance that needs them.
Initial Setup:
# Install dependencies
npm install
# Generate all models and merged schema
npx @aexol/axolotl build
# Generate resolver scaffolding (optional)
npx @aexol/axolotl resolversWhen to Regenerate:
Run axolotl build when you:
- Add or modify any schema file
- Add new types or fields
- Add or remove federation modules
- Change
axolotl.jsonconfiguration
The CLI will regenerate:
- Each submodule's
models.ts - The merged
schema.graphql - The root
models.ts
Module Organization:
- Organize by business domain (users, products, orders)
- Keep related types in the same module
- Use consistent directory structure across modules
- Make modules as independent as possible
Avoid:
- Creating modules for every single type
- Mixing unrelated concerns in one module
- Creating circular dependencies between modules
Naming Conventions for Shared Types:
# Good: Both modules use "User"
type User {
_id: String!
}
# Bad: Different names for same concept
type UserAccount {
_id: String!
}
type UserProfile {
_id: String!
}Module-specific types - prefix with domain:
type TodoItem { ... }
type TodoFilter { ... }Performance Considerations:
- Parallel resolver execution: When multiple modules implement the same resolver, they execute in parallel using
Promise.all(). Be aware of database connection limits and rate limiting. - Deep merge overhead: Results are deep-merged using object spreading. Keep resolver return values focused and avoid deeply nested objects when possible.
- Use DataLoader for batching database queries across modules.
Error: Federation conflict on Node.field pattern: User.email
Cause: The same field on the same type has different definitions across modules.
Solution: Ensure field types match exactly:
# users/schema.graphql
type User {
email: String! # Required
}
# profile/schema.graphql
type User {
email: String # Optional - CONFLICT!
}Fix by making them identical in both files.
Error: Cannot find module '@/src/users/models.js'
Solution:
# Regenerate all models
npx @aexol/axolotl build
# Check your tsconfig.json has correct path mappings
{
"compilerOptions": {
"paths": {
"@/*": ["./*"]
}
}
}Causes:
- Resolver not exported from module
- Not included in
mergeAxolotlscall - Schema and resolver names don't match
Solution:
// Ensure resolver is exported
export default createResolvers({
Query: {
user: async () => { ... } // Must match schema field name exactly
}
});
// Ensure it's merged
import usersResolvers from './users/resolvers/resolvers.js';
export default mergeAxolotls(usersResolvers, ...otherResolvers);Issue: Deep merge combines objects in unexpected ways.
Solution: Ensure resolvers return compatible object shapes. If modules return conflicting primitives, the last one wins. Use different field names to avoid conflicts.
| Feature | Axolotl Micro-Federation | Apollo Federation |
|---|---|---|
| Deployment | Single service | Multiple services |
| Schema Merging | Build-time | Runtime with gateway |
| Type Splitting | Automatic deep merge | Explicit @key directives |
| Resolver Execution | Parallel within process | Cross-service HTTP calls |
| Performance | Fast (in-process) | Network overhead |
| Complexity | Simple config | Gateway + federation service |
| Use Case | Monorepo/single app | Distributed microservices |
| Independence | Shared codebase | Fully independent services |
The repository includes a complete federated example:
# Navigate to the example
cd examples/yoga-federated
# Install dependencies (if not already done at root)
npm install
# Generate models
npm run models
# Run in development mode
npm run devVisit http://localhost:4002/graphql and try these operations:
# Register a user
mutation Register {
register(username: "user", password: "password")
}
# Login (returns token)
mutation Login {
login(username: "user", password: "password")
}
# Set the token in headers: { "token": "your-token-here" }
# Create a todo
mutation CreateTodo {
user {
createTodo(content: "Learn Axolotl Federation")
}
}
# Query merged data (comes from both users and todos modules!)
query MyData {
user {
me {
_id
username
}
todos {
_id
content
done
}
}
}# 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.