Micro Federation

Micro‑Federation

Micro-federation allows you to compose multiple Axolotl modules into a single GraphQL API. This architectural pattern is ideal for organizing large applications into logical domains while maintaining a unified API surface.

What is Micro-Federation?

Micro-federation in Axolotl is a modular approach 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

Unlike Apollo Federation, Axolotl's micro-federation is designed for monorepo or single-project architectures where all modules are built and deployed together.

When to Use Micro-Federation

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

Quick Start

1. Configure axolotl.json

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"
    }
  ]
}

2. Create Submodule Schemas

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
}

3. Run Code Generation

axolotl build

This command:

  1. Generates models for each submodule (src/todos/models.ts, src/users/models.ts)
  2. Merges all submodule schemas into the supergraph (schema.graphql)
  3. Generates models for the supergraph (src/models.ts)

4. Create Axolotl Instances per Module

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>();

5. Implement Resolvers in Each Module

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;
    },
  },
});
src/todos/resolvers/resolvers.ts
import { createResolvers } from '../axolotl.js';
import AuthorizedUserQuery from './AuthorizedUserQuery/resolvers.js';
import AuthorizedUserMutation from './AuthorizedUserMutation/resolvers.js';
import TodoOps from './TodoOps/resolvers.js';
 
export default createResolvers({
  ...AuthorizedUserQuery,
  ...AuthorizedUserMutation,
  ...TodoOps,
});

6. Merge Resolvers in Main File

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);

7. Use in Your Server

src/index.ts
import { adapter } from '@/src/axolotl.js';
import resolvers from '@/src/resolvers.js';
 
adapter({ resolvers }).server.listen(4000, () => {
  console.log('Server listening on port 4000');
});

How It Works

Schema Merging

When you run axolotl build, schemas are merged using the following rules:

Types are merged by name:

  • If User type 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
}

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
}

Resolver Merging

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 stream

Type Generation Flow

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

Directory Structure

Here's a 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/

Advanced Topics

Sharing Types Across Modules

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.

Cross-Module Dependencies

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.

Custom Scalars in Federation

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 in Federation

Subscriptions work in federated setups, but each subscription field should only be defined in one module:

src/users/resolvers/Subscription/countdown.ts
import { createResolvers } from '../../axolotl.js';
 
export default createResolvers({
  Subscription: {
    countdown: {
      subscribe: async function* (_, { from }) {
        for (let i = from ?? 3; i >= 0; i--) {
          yield { countdown: 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 Across Modules

Directives must be defined in each schema that uses them:

directive @resolver on FIELD_DEFINITION
directive @auth(role: String!) on FIELD_DEFINITION

Directive implementations should be registered in each module's Axolotl instance that needs them.

Development Workflow

Initial Setup

# Install dependencies
npm install
 
# Generate all models and merged schema
npm run models  # or: axolotl build
 
# Generate resolver scaffolding (optional)
npm run resolvers  # or: axolotl resolvers

Development Mode

# Watch for changes and rebuild
npm run watch
 
# In another terminal, run the dev server
npm run dev

When 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.json configuration

The CLI will regenerate:

  1. Each submodule's models.ts
  2. The merged schema.graphql
  3. The root models.ts

Production Build

# Build TypeScript
npm run build
 
# Start production server
npm run start

Best Practices

Module Organization

Do:

  • 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

Don't:

  • Create modules for every single type
  • Mix unrelated concerns in one module
  • Create circular dependencies between modules

Naming Conventions

Shared types: Use consistent names across modules

# 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

# Good
type TodoItem { ... }
type TodoFilter { ... }
 
# Also acceptable
type Todo { ... }
type TodosFilter { ... }

Resolver Organization

Group resolvers by type and field:

resolvers/
├── resolvers.ts              # Main export with createResolvers
├── Query/
│   ├── resolvers.ts         # Combines all Query resolvers
│   ├── user.ts
│   └── users.ts
├── Mutation/
│   ├── resolvers.ts
│   ├── createUser.ts
│   └── updateUser.ts
└── User/                     # Type resolvers
    ├── resolvers.ts
    └── posts.ts

Testing Strategy

Test modules independently:

import { createResolvers } from '@/src/users/axolotl.js';
import usersResolvers from '@/src/users/resolvers/resolvers.js';
 
describe('Users Module', () => {
  it('should login user', async () => {
    const result = await usersResolvers.Mutation.login({}, { username: 'test', password: 'pass' }, mockContext);
    expect(result).toBeDefined();
  });
});

Test the merged supergraph:

import mergedResolvers from '@/src/resolvers.js';
 
describe('Supergraph', () => {
  it('should merge user data from multiple modules', async () => {
    const result = await mergedResolvers.Query.user({}, {}, mockContext);
    expect(result.username).toBeDefined(); // from users module
    expect(result.todos).toBeDefined(); // from todos module
  });
});

Performance Considerations

Parallel resolver execution: When multiple modules implement the same resolver, they execute in parallel using Promise.all(). This is efficient but be aware of:

  • Database connection limits
  • Rate limiting for external APIs
  • Memory usage with many concurrent operations

Deep merge overhead: Results are deep-merged using object spreading. For very large response objects, this may add latency. Consider:

  • Keeping resolver return values focused
  • Avoiding deeply nested objects when possible
  • Using DataLoader for batching database queries

Troubleshooting

Schema Merge Conflicts

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:

# Both files
type User {
  email: String!
}

Type Generation Failures

Error: Cannot find module '@/src/users/models.js'

Cause: Models haven't been generated yet or paths are incorrect.

Solution:

# Regenerate all models
axolotl build
 
# Check your tsconfig.json has correct path mappings
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Resolver Not Found

Error: Resolver returns null or is not called

Cause:

  • Resolver not exported from module
  • Not included in mergeAxolotls call
  • 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);

Merged Resolver Returns Unexpected Data

Issue: Merged resolver combines data incorrectly

Cause: Deep merge combines objects in unexpected ways

Solution: Ensure resolvers return compatible object shapes:

// Module 1
{ Query: { user: () => ({ id: "1", name: "John" }) } }
 
// Module 2
{ Query: { user: () => ({ id: "1", posts: [...] }) } }
 
// Merged result - objects combined
{ id: "1", name: "John", posts: [...] }

If modules return conflicting primitives, the last one wins. Use different field names to avoid conflicts.

Subscription Not Working

Issue: Subscription not firing or using wrong implementation

Cause: Multiple modules define the same subscription

Solution: Define each subscription in only one module. If you need different subscription behavior, use different field names:

# users/schema.graphql
type Subscription {
  userUpdated: User!
}
 
# todos/schema.graphql
type Subscription {
  todoUpdated: Todo!
}

Running the Example

The repository includes a complete federated example you can run:

# 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 dev

Visit 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
    }
  }
}

Comparison with Apollo Federation

FeatureAxolotl Micro-FederationApollo Federation
DeploymentSingle serviceMultiple services
Schema MergingBuild-timeRuntime with gateway
Type SplittingAutomatic deep mergeExplicit @key directives
Resolver ExecutionParallel within processCross-service HTTP calls
PerformanceFast (in-process)Network overhead
ComplexitySimple configGateway + federation service
Use CaseMonorepo/single appDistributed microservices
IndependenceShared codebaseFully independent services

Next Steps