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 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.
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 modelPublic API modelAPI-level representationDatabase documents can contain secrets, internal identifiers, operational metadata, and implementation details. Explicitly decide which information belongs in the public GraphQL schema.
- 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.
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.
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);
}
}
};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.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.
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.
Typed filter objectBusiness-level filtering rulesDatabase query- 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.
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.
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.
- 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.
Why is a MongoDB transaction not enough to make an order workflow fully atomic when a payment provider is involved?