HomeBytesheetsRoadmapsUI TemplatesUI ToolsStack Discussion
5 Topics
Chapter•190 Minutes

GraphQL with MongoDB and Mongoose

Connect GraphQL to MongoDB through Mongoose, map schemas to domain types, implement queries and mutations, handle relationships, filtering, validation, and avoid common data-access mistakes.

Connecting GraphQL to MongoDB

GraphQL does not care which database stores your data. MongoDB is a natural fit for many Node.js applications because JavaScript objects can be mapped relatively naturally to MongoDB documents.

A useful architecture is:

GraphQL Resolver
      ↓
Application Service
      ↓
Repository
      ↓
Mongoose Model
      ↓
MongoDB

For example, a resolver should preferably call a service:

const resolvers = {
  Query: {
    user: (_, { id }, context) => {
      return context.userService.getById(id);
    }
  }
};

The service can call a repository:

const getById = (id) => userRepository.findById(id);

The repository can use Mongoose:

const findById = (id) => UserModel.findById(id).lean();

This separation prevents GraphQL resolver code from becoming tightly coupled to Mongoose.

It also makes it easier to replace the persistence layer or reuse the same business logic from a REST API, background worker, or command-line tool.

GraphQL to MongoDB Architecture
Key Takeaways
  • GraphQL should not be tightly coupled to MongoDB.
  • Services and repositories create useful separation between API and persistence.
  • Mongoose can be used as the MongoDB data-access layer.
  • The same business services can potentially support multiple API entry points.
Knowledge Check

What is a major benefit of placing a repository between a GraphQL service and Mongoose?

Mapping Mongoose Models to GraphQL Types

A Mongoose schema and a GraphQL type serve different purposes.

A Mongoose model describes persistence behavior:

const userSchema = new mongoose.Schema({
  name: String,
  email: String,
  createdAt: Date
});

A GraphQL type describes the public API contract:

type User {
  id: ID!
  name: String!
  email: String!
  createdAt: DateTime!
}

Do not assume that every Mongoose field should be exposed through GraphQL.

A database may contain internal fields such as:

passwordHash
internalFlags
resetToken
billingProviderId

These should not automatically become GraphQL fields.

Similarly, the GraphQL API may expose computed or aggregated fields that do not exist as individual MongoDB properties.

For example:

type User {
  id: ID!
  name: String!
  postCount: Int!
}

postCount could be computed using an aggregation query without storing it directly in the user document.

This illustrates why GraphQL should be treated as an API abstraction rather than a direct database reflection.

Persistence vs API Model
Mongoose schema: Persistence model
Describes stored document structure, indexes, defaults, validation, middleware, and other persistence concerns.
GraphQL type: Public API model
Describes what clients are allowed to query and receive.
Computed GraphQL field: API-level representation
A GraphQL field can be derived from one or more database operations.
Never expose database fields blindly

Database documents can contain secrets, internal identifiers, operational metadata, and implementation details. Explicitly decide which information belongs in the public GraphQL schema.

Key Takeaways
  • Mongoose schemas and GraphQL types serve different purposes.
  • Not every database field should be public.
  • GraphQL can expose computed fields.
  • API contracts should not be dictated blindly by persistence models.
Knowledge Check

Why should a Mongoose document not automatically become a GraphQL response type?

Using Mongoose Queries in GraphQL

A GraphQL resolver can use Mongoose through a service or repository to retrieve documents.

For example:

const findUserById = async (id) => {
  return UserModel.findById(id).lean();
};

The lean() method returns plain JavaScript objects rather than full Mongoose documents. This can be useful for read-heavy GraphQL operations when you do not need Mongoose document methods, getters, setters, or change tracking.

A resolver might then be:

const resolvers = {
  Query: {
    user: (_, { id }) => findUserById(id)
  }
};

For lists:

const users = await UserModel
  .find({ isActive: true })
  .sort({ createdAt: -1 })
  .limit(20)
  .lean();

GraphQL selection sets create an important performance consideration. A client may request only:

users {
  id
  name
}

but a naive resolver may still retrieve every large document field from MongoDB.

For performance-sensitive systems, the server can sometimes inspect the GraphQL selection and translate it into a database projection. This should be done carefully because nested selections, aliases, fragments, authorization, computed fields, and resolver behavior make automatic projection non-trivial.

Do not prematurely build complex selection-to-database projection logic. Measure first and introduce it where the performance benefit justifies the complexity.

Mongoose Repository Query
const userRepository = {
  async findActiveUsers(limit = 20) {
    return UserModel
      .find({ isActive: true })
      .sort({ createdAt: -1 })
      .limit(limit)
      .lean();
  }
};

const resolvers = {
  Query: {
    users: (_, { limit }, context) => {
      return context.userRepository.findActiveUsers(limit);
    }
  }
};
💡 The repository owns the Mongoose query while the resolver delegates to it. lean() can reduce Mongoose document overhead for read-only use cases. In production, query limits should be bounded and indexes should support the filter and sort.
Key Takeaways
  • lean() can be useful for read-only Mongoose queries.
  • Database queries should be encapsulated behind application boundaries.
  • GraphQL selection sets do not automatically optimize MongoDB queries.
  • Indexes and bounded limits are important for production queries.
Knowledge Check

What is one common reason to use Mongoose's `lean()` for a GraphQL read operation?

Filtering and Sorting Through GraphQL

Production GraphQL APIs commonly need filtering and sorting capabilities.

A simple schema might expose:

input UserFilterInput {
  search: String
  isActive: Boolean
}

enum UserSortField {
  CREATED_AT
  NAME
}

enum SortDirection {
  ASC
  DESC
}

input UserSortInput {
  field: UserSortField!
  direction: SortDirection!
}

The query could be:

users(
  filter: UserFilterInput
  sort: UserSortInput
): UserConnection!

The resolver translates the API-level filter into a database query.

For example:

const mongoFilter = {};

if (filter?.isActive !== undefined) {
  mongoFilter.isActive = filter.isActive;
}

Search requires additional consideration. A naive MongoDB regular expression across large collections can become expensive.

Production search may require:

  • appropriate MongoDB indexes
  • text indexes
  • Atlas Search
  • normalized search fields
  • external search systems

GraphQL should define a clean API contract while the repository chooses an efficient implementation.

Filtering Architecture
GraphQL input: Typed filter object
Provides a predictable API contract.
Service: Business-level filtering rules
Determines which filters are allowed and how they interact.
Repository: Database query
Translates approved filters into MongoDB operations.
Key Takeaways
  • Filtering should use explicit GraphQL input types.
  • The API layer should not expose arbitrary MongoDB operators.
  • Search performance depends on the underlying index strategy.
  • GraphQL provides the contract while the repository implements the query.
Knowledge Check

Why should a GraphQL API avoid accepting arbitrary MongoDB query operators directly from clients?

MongoDB Transactions and GraphQL Mutations

Some GraphQL mutations modify multiple MongoDB documents and require atomicity.

For example, placing an order might involve:

Create order
  ↓
Reduce inventory
  ↓
Create payment record

If the first two operations succeed but the third fails, the application may be left in an inconsistent state.

MongoDB supports transactions for appropriate deployment configurations. A service can use a session to group operations atomically.

Conceptually:

const session = await mongoose.startSession();

try {
  await session.withTransaction(async () => {
    await OrderModel.create([{ ...order }], { session });
    await InventoryModel.updateOne(
      { productId },
      { $inc: { quantity: -quantity } },
      { session }
    );
  });
} finally {
  await session.endSession();
}

The GraphQL resolver should not generally contain the transaction workflow. The application service should own the business transaction.

Also remember that a database transaction does not automatically make external side effects transactional. Sending an email or calling a payment provider cannot simply be rolled back because a MongoDB transaction aborts.

Distributed side effects require patterns such as outbox processing, idempotency, compensating actions, or carefully designed workflow orchestration.

Database transactions do not cover external services

A MongoDB transaction can protect MongoDB operations, but it cannot automatically roll back an email, payment provider call, message broker publication, or other external side effect.

GraphQL Mutation with MongoDB Transaction
Key Takeaways
  • Transactions can protect related MongoDB operations.
  • Transaction workflows belong in the application service layer.
  • External side effects are not automatically part of database transactions.
  • Distributed workflows may require outbox, idempotency, or compensation patterns.
Knowledge Check

Why is a MongoDB transaction not enough to make an order workflow fully atomic when a payment provider is involved?