GraphQL Part 3 – Persistence with MongoDB

In our post, we mastered Mutations. We can now query, add, update, and delete films from our Hammer collection. However, every time we restart our Apollo server, our changes vanish into the ether. Our “Watched” list resets, and that film we deleted? It’s back from the dead—and not in a cool, technicolor, cinematic way.

To fix this, we need Data Persistence. In this post, we’ll swap our humble, local JavaScript array for a MongoDB database.

If you haven’t done so already, clone the lab Github repository using

git clone https://github.com/jmwollny/lab.git

Install MongoDB

I’m installing on a Mac, if you what to install MongoDB on other systems go to the MongoDB download page here.

brew tap mongodb/brew
brew install mongodb-community

Now start the MongoDB server. This command ensures that the MongoDB server will restart at logon.

brew services start mongodb/brew/mongodb-community

Now check we have a running instance. Type mongosh. If the shell appears you are golden and are ready to proceed to the next section. Type exit to leave the shell.

Setting Up the MongoDB connection

First, we need to install the MongoDB driver. In your terminal, run:

cd lab/graphql-tutorial-3
npm install mongoose

Mongoose is an Object Data Modeling (ODM) library that makes talking to MongoDB from Node.js much easier. If you open index.js you will see that the films array has been replaced with a MongoDB connection to a database called hammer_films.

const mongoose = require('mongoose');

// Connect to your local or Atlas MongoDB instance
mongoose.connect('mongodb://localhost:27017/hammer_films', {
  useNewUrlParser: true,
  useUnifiedTopology: true
});

const db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', () => console.log('Connected to MongoDB!'));

Defining the Data Model

In GraphQL, we have a Schema. In MongoDB (via Mongoose), we have a Model. These two need to mirror each other so our data flows correctly. A new file called Film.js contains the MongoDB model which has been exported so it can be shared by seed.js(more about this later!).

const mongoose = require('mongoose');

const filmSchema = new mongoose.Schema({
  title: { type: String, required: true },
  year: { type: Number, required: true },
  watched: { type: Boolean, default: false }
});

// Export the model so both index.js and seed.js can use it
module.exports = mongoose.model('Film', filmSchema);

Updating the Resolvers

This is where the magic happens. Instead of using .find() or .splice() on a local array, we will use Mongoose methods which return Promises. GraphQL handles these asynchronous calls automatically.

The New Queries and mutations

const resolvers = {
  Query: {
    films: async (parent, args) => {
      // 1. Build a dynamic query object
      let query = {};

      // Watch filter
      if (args.watched !== undefined) {
        query.watched = args.watched;
      }

      // Year filter (Exact match)
      if (args.year) {
        query.year = args.year;
      }

      // Date range filter (using MongoDB operators $gte and $lte)
      if (args.where) {
        query.year = query.year || {}; // Initialize year object if it doesn't exist
        if (args.where.year_gte) {
          query.year.$gte = args.where.year_gte;
        }
        if (args.where.year_lte) {
          query.year.$lte = args.where.year_lte;
        }
      }

      // Search filter (using Regex for case-insensitive partial match)
      if (args.searchTerm) {
        query.title = { $regex: args.searchTerm, $options: 'i' };
      }

      // Execute the query against the database
      return await FilmModel.find(query);
    },

    // Find by ID - Mongoose maps GraphQL 'id' to MongoDB '_id' automatically
    film: async (parent, args) => await FilmModel.findById(args.id),
  },

  Mutation: {
    addFilm: async (parent, { input }) => {
      // Create a new instance and save it
      const newFilm = new FilmModel(input);
      return await newFilm.save();
    },

    updateWatched: async (parent, { id, watched }) => {
      const updatedFilm = await FilmModel.findByIdAndUpdate(
        id,
        { watched },
        { new: true }, // This flag returns the record *after* it was updated
      );

      if (!updatedFilm) {
        throw new Error('Film not found');
      }

      return updatedFilm;
    },

    deleteFilm: async (parent, { id }) => {
      const deleted = await FilmModel.findByIdAndDelete(id);
      if (!deleted) {
        throw new Error('Film not found');
      }

      return await FilmModel.find();
    },
  },
};

Testing Persistence

Restart your server with node index.js. Now, head back to your GraphQL sandbox at http://localhost:4000/. We can test that after adding a film and restarting our Apollo server, the film still exists!

In the sandbox run a mutation to add a new film.

mutation CreateFilm($input: CreateFilmInput!) {
  addFilm(input: $input) {
    id
    title
    year
    watched
  }
}

Remember to add the variables JSON.

{
  "input": {
    "title": "The Brides of Dracula",
    "year": 1960,
    "watched": false
  }
}

Run the query then shut down your server (Ctrl + C in the terminal). Start the server again usig node index.js

Run a query to retrieve all films.

query GetAllFilms {
  films {
    id
    title
    watched
    year
  }
}

Query result

{
  "data": {
    "films": [
      {
        "id": "69dfb8e8067cfb4bcaadeb6d",
        "title": "The Brides of Dracula",
        "watched": false,
        "year": 1960
      }
    ]
  }
}

If all has gone well, your data is still there! Unlike our local array, MongoDB has written this data to the disk.

Why use Mongoose with GraphQL?

You might notice that our FilmModel and our GraphQL type Film look very similar. This redundancy is actually a strength. The GraphQL Schema acts as a contract for your frontend (telling it what data it can ask for), while the Mongoose Model acts as a gatekeeper for your database (telling it how the data must be stored).

The “ID” Gotcha

MongoDB uses a field called _id by default. GraphQL usually expects id. Mongoose is smart enough to provide a virtual id field that maps to _id, so the existing queries like film(id: "...") continue to work without a hitch.

Importing the full list of films

Let’s finish by importing our film list into MongoDB, then we can get down to the fun job of watching every one and marking them as watched as we go.

To do this I have provided a handy script. Running the script will clear the database and import all films. All you need to do is open a terminal and run node seed.js.

node seed.js
Connected to MongoDB for seeding...
Old records removed.
157 Hammer films successfully added to the database!

Let’s have a look at the script.

const mongoose = require('mongoose');
const fs = require('fs');
// Import your Mongoose model
const Film = require('./models/Film'); 

const seedDatabase = async () => {
  try {
    // Connect to MongoDB
    await mongoose.connect('mongodb://127.0.0.1:27017/hammer_films');
    console.log("Connected to MongoDB for seeding...");

    // Read the JSON file
    const data = JSON.parse(fs.readFileSync('./films.json', 'utf-8'));

    // Clear existing films
    await Film.deleteMany({});
    console.log("Old records removed.");

    // Bulk insert the data
    await Film.insertMany(data);
    console.log(`${data.length} Hammer films successfully added to the database!`);

    // Close the connection
    process.exit();
  } catch (error) {
    console.error("Error seeding database:", error);
    process.exit(1);
  }
};

seedDatabase();

This script does the following:

  • Connects to MongoDB
  • Parses the list of films(note: we do not need the id anymore in the JSON)
  • Deletes all records in the database
  • Inserts all records defined in the JSON file

Conclusion

We’ve successfully moved our Hammer database from a “temporary” state to a “permanent” one. By integrating MongoDB, we’ve laid the groundwork for a real-world application. We are no longer just playing with variables in memory. we are managing a persistent data store. As an exercise try creating different queries or if you are feeling brave add more fields to the schema. Have fun coding and if you feel inclined watching one of the suggested films 🙂

GraphQL Part 2 – Mastering Mutations

In our last post, we built a robust way to search through 157 Hammer classics. But what happens when you finally sit down to watch The Brides of Dracula? You need a way to update that record.

In GraphQL, any operation that changes data is called a Mutation.

If you have not followed part 1 of this tutorial go there now to pull the code from my Github repository.

1. Updating the Schema

First, we need to tell our server what these changes look like. We’ll add a Mutation type to our typeDefs. We have one to update a film entry(updateWatched) and one to delete a film(deleteFilm).

type Mutation {
  # Toggle the watched status of a film
  updateWatched(id: ID!, watched: Boolean!): Film
  
  # Delete a film from our collection
  deleteFilm(id: ID!): [Film]
}

2. Writing the Resolvers

Now, we implement the logic to update and delete a film record. Since we’re working with a local array of films data we’ll use standard JavaScript array methods to find and modify our data. Here we are using splice and find.

const resolvers = {
  // ... query resolvers
  
  Mutation: {
    updateWatched: (parent, { id, watched }) => {
      const film = films.find(f => f.id == id);
      if (!film) {
        throw new Error("Film not found");
      }
      
      film.watched = watched;
      return film;
    },
    
    deleteFilm: (parent, { id }) => {
      const index = films.findIndex(f => f.id == id);
      if (index == -1) {
        throw new Error("Film not found");
      }
      
      // Remove the film and return the updated list
      films.splice(index, 1);
      return films;
    }
  }
};

3. Testing in the Playground

Once you restart your server, you can test these live.

node index.js
🚀 Server ready at http://localhost:4000/

Navigating to http://localhost:4000/ will redirect to the GraphQL sandbox. Click the ‘Query your server’ button and you will be presented with something like this.

First of all we need to find all unwatched Dracula films returning the id. Quick quiz! Do you remember how to craft the query? Here it is:

query GetNotWatched {
  films(watched: false, searchTerm: "dracula") {
    id
    title
  }
}

This will return the following.

{
  "data": {
    "films": [
      {
        "id": "70",
        "title": "The Brides of Dracula"
      },
      {
        "id": "102",
        "title": "Dracula: Prince of Darkness"
      },
      {
        "id": "115",
        "title": "Dracula Has Risen from the Grave"
      },
      {
        "id": "122",
        "title": "Scars of Dracula"
      },
      {
        "id": "125",
        "title": "Countess Dracula"
      }
    ]
  }
}

Pick a film and remember the ID. This will be used in the next step. In my case I will pick the first film which has an id of 70.

Mark “Brides of Dracula” (ID: 70) as watched:

Paste this query into the sandbox(remember to substitute your own id if it is different).

mutation {
  updateWatched(id: "70", watched: true) {
    title
    watched
  }
}

After running the update query run the GetNotWatched query again to check that the film is not in the list.

Removing a film

Let’s remove “Brides of Dracula”.

mutation {
  deleteFilm(id: "70") {
    title
  }
}

If we run a query to return the film with id 70 GraphQL will now return null.

query GetFilm {
  film(id: 70) {
    id
    watched
    year
  }
}

Results from the query

{
  "data": {
    "film": null
  }
}

Adding a film

Let’s add the film back!

When adding a record, passing four separate arguments (ID, Title, Year, Watched) can get messy. Instead, we define an input type in our schema to group them together.

Update the Schema

We create a new input which specifies which fields are mandatory when creating a new Film. In our case all fields must be specified(indicated by the “!”). This input spec is then specified in our addFilm mutation.

input CreateFilmInput {
  id: ID!
  title: String!
  year: Int!
  watched: Boolean!
}

type Mutation {
  # ... previous mutations
  addFilm(input: CreateFilmInput!): Film
}

Update the Resolver

const resolvers = {
  Mutation: {
    // ... updateWatched and deleteFilm
    
    addFilm: (parent, { input }) => {
      // Check if ID already exists to prevent duplicates
      const exists = films.find(f => f.id === input.id);
      if (exists) {
        throw new Error("A film with this ID already exists.");
      }

      const newFilm = { ...input };
      films.push(newFilm);
      return newFilm;
    }
  }
};

Testing the “Add” Mutation

Enter the query into the sandbox

mutation CreateNewHammerFilm($input: CreateFilmInput!) {
  addFilm(input: $input) {
    id
    title
    year
  }
}

Under the query box, enter the variables JSON.

{
  "input": {
    "id": "70",
    "title": "The Brides of Dracula",
    "year": 1960,
    "watched": false
  }
}

After running the mutation you can run the GetFilm query, which will show the resurrected film in all its glory!

query GetFilm {
  film(id: 70) {
    id
    watched
    year
  }
}

Why use input types?

Using an input object instead of flat arguments makes your API much more maintainable. If you decide to add a director or studio field later, you only have to update the input type, rather than changing the signature of the mutation across your entire codebase.

Why “Mutation” instead of “Query”?

While you could technically change data inside a Query resolver, it’s a major “no-go” in the GraphQL world. Using the Mutation keyword tells the server (and other developers) that this operation has side effects. It also ensures that if you send multiple mutations in one request, they run serially (one after another) to prevent data race conditions.

Conclusion

We’ve come a long way from a simple JavaScript array of my favourite films. By implementing Mutations, we’ve transformed our Hammer dataset into a functional API. We can now:

  • Create new entries to keep our database growing.
  • Update existing records to track our viewing progress.
  • Delete entries to keep our data clean and accurate.

This “CRUD” (Create, Read, Update, Delete) cycle is the backbone of almost every application you use daily. While we are currently managing this data in local memory via a simple array, the patterns we’ve used here—Input Types, Non-Nullable arguments, and Serial Mutation execution—are the exact same patterns you would use when connecting to a production-grade database like MongoDB or PostgreSQL.

What’s Next?

Now that the backend logic is solid, the next logical step is to explore Data Persistence. In the next post, we’ll look at how to hook this GraphQL server up to a database so that our “Watched” status doesn’t disappear every time we restart the server!

Until then, happy coding!

GraphQL Part 1 – A Modern Approach to APIs

For years, REST (Representational State Transfer) has been the standard for web services. However, as applications grow in complexity, developers often find themselves juggling dozens of endpoints and dealing with over-fetching data.

GraphQL is a query language for your API and a server-side runtime for executing those queries using a type system you define for your data. Instead of multiple “dumb” endpoints, GraphQL provides a single “smart” endpoint that can return exactly what the client asks for.


Why GraphQL?

  • No More Over-fetching: You get exactly the data you request—nothing more, nothing less.
  • Single Request, Multiple Resources: You can fetch data from different sources in one trip to the server.
  • Strongly Typed: GraphQL uses a schema to define what is possible, which acts as a contract between the frontend and backend.
  • Self-Documenting: Because of the schema, tools like GraphiQL allow you to browse the API structure effortlessly.

For this tutorial we will be working with a list of classic Hammer Studios films. Each film will have id, title, year and watched fields.

The code below shows example Schema and the Query definitions. The schema defines a Film as having four fields. Where the field type is suffixed with “!” it indicates that the field must not be null and will always return a value.

const typeDefs = gql`
  type Film {
    id: ID
    title: String!
    year: Int
    watched: Boolean
  }
  input FilmFilter {
    year_gte: Int
    year_lte: Int
  }
  type Query {
    # Return a list of films, optionally filtered by watched status, year, or search term in the title
    films(watched: Boolean, year: Int, searchTerm: String, where: FilmFilter): [Film]
    film(id: ID!): Film
  }
`;

After the Schema we have queries defined. If you define a query without any parameters e.g. films: [Film] and try to use a parameter in your query GraphQL will complain…loudly with a GRAPHQL_VALIDATION_FAILED error.

Here we have defined two queries.

  1. films(watched: Boolean, year: Int, searchTerm: String, where: FilmFilter): [Film] – return a list of Film objects. We can optionally have zero or all of the following query parameters – watched, year, searchTerm, where(this is used to support range queries on the year field)
  2. film(id: ID!) – return a single Film. The id parameter MUST be specified

Getting Started: A Simple Implementation

I have created a Github repo for this tutorial. It is straightforward to follow. Once you have cloned the repository, open readme.md for instructions. Alternatively read on!

git clone https://github.com/jmwollny/lab.git
cd lab/graphql-tutorial
npm install

Once the dependencies have been installed you can run the Apollo server.

node index.js

You may be thinking, okay I’ve defined the Schema and the Queries, where do I get the data from and how do I map the queries to the underlying datasource?

The list of films is a hard-coded array defined in index.js. In practice we would be calling out to one or more data sources to get this information.

To map and filter the queries, this is where resolvers come in.

Open a terminal

cd lab/qraphql-tutorial

open index.js. This file contains the Schema, Queries and Resolvers and starts the Apollo server. In a production environment these would be split out into different files. We are using a single file to keep things simple.

At the bottom this file you will see the resolvers definition. Inside the films arrow function we can create filters for each of our defined query parameters.

To filter the dataset we check for the presence of the query parameter and perform the filter using the built-in Javascript filter function. We make sure to use the filtered list in any filters that follow.

When we are done we just return the list to the server.

const resolvers = {
  Query: {
    films: (parent, args) => {
      let filteredFilms = films;
      // Watch filter
      if (args.watched !== undefined) {
        filteredFilms = filteredFilms.filter(f => f.watched ===    args.watched);
      }   
      // Year filter
      if (args.year) {
        filteredFilms = filteredFilms.filter(f => f.year === args.year);
      }
      // Date range filter
      if (args.where) {
        if (args.where.year_gte) {
          filteredFilms = filteredFilms.filter(f => f.year >= args.where.year_gte);
        }
        if (args.where.year_lte) {
          filteredFilms = filteredFilms.filter(f => f.year <= args.where.year_lte);
        }
      }

      // Search filter
      if (args.searchTerm) {
        filteredFilms = filteredFilms.filter(f => 
          f.title.toLowerCase().includes(args.searchTerm.toLowerCase())
        );
      }
      
      return filteredFilms;
    },
    film: (parent, args) => films.find(f => f.id === args.id),
  },
};

GraphQL queries

For those used to SQL these may look a little odd at first but they are quite straightforward once you get the hang of of the syntax.

A simple query

Let’s retrieve the full list of films. Open your browser at http://localhost:4000/ then click the Query your Server button. If all is well the sandbox will open. Paste the following query.

query GetAllFilms {
  films {
    title
    watched
    year
   }
}

GetAllFilms is the name we give to our query. It can be anything that succinctly describes our query! Next we indicate that we want to execute the films query and return a list with title, watched and year fields. Note: you need to supply at least one field to be returned in the output.

Well done, you have succesfully run your first GraphQL query 🙂 This is what is returned.

Using a filter in our query

Say we wanted all films containing the word “dracula” that were made in the 1970s and that we haven’t watched.

In order to specify a range we need to define some extra variables to support the query. In our case we need year_gte and year_lte to define our bounds.

input FilmFilter {
  year_gte: Int
  year_lte: Int
}

We then define a where query parameter that uses the FilmFilter

films(watched: Boolean, year: Int, searchTerm: String, where: FilmFilter): [Film]

The last piece of the puzzle is to update our resolver to use our new variables.

// Date range filter
if (args.where) {
  if (args.where.year_gte) {
    filteredFilms = filteredFilms.filter(f => f.year>=args.where.year_gte);
  }
  if (args.where.year_lte) {
    filteredFilms = filteredFilms.filter(f => f.year <= args.where.year_lte);
  }
}

Finally we craft our query using the new where parameter.

query GetSeventiesDracula {
  films(
    where: { year_gte: 1970, year_lte: 1979 }, 
    searchTerm: "dracula", 
    watched: true) {
      id
      title
      year
      watched
  }
}

A best practice when it comes to GraphQL is to separate the query data from the query itself. This is accomplished using variables. In the sandbox the variables JSON can be entered in the area underneath the query text box. Our new query will look like this.

query GetSeventiesDraculaWithVars($where: FilmFilter, $searchTerm: String, $watched: Boolean) {
  films(where: $where, searchTerm: $searchTerm, watched: $watched) {
    id
    title
    year
    watched
  }
}

After the query name we pass in the list of variables that we will provide values for, along with their type. $where: FilmFilter, $searchTerm: String, $watched: Boolean). In the film query instead of declaring the values we provide placeholders prefixed by ‘$’.

All that remains is to provide the query with solid values. The JSON will look like this.

{
  "where": {
    "year_gte": 1970,
    "year_lte": 1979
  },
  "searchTerm": "dracula",
  "watched": true
}

Alternatively you can open a terminal session and use the curl command as shown below.

curl -X POST http://localhost:4000/ \
-H "Content-Type: application/json" \
-d '{
  "query": "query GetFilteredFilms($where: FilmFilter, $searchTerm: String, $watched: Boolean) { films(where: $where, searchTerm: $searchTerm, watched: $watched) { id title year watched } }",
  "variables": {
    "where": {
      "year_gte": 1970,
      "year_lte": 1979
    },
    "searchTerm": "dracula",
    "watched": true
  }
}'

Query results

{"data":{"films":[{"id":"133","title":"Dracula A.D. 1972","year":1972,"watched":true},{"id":"142","title":"The Satanic Rites of Dracula","year":1973,"watched":true}]}}

When to Use GraphQL

While GraphQL is powerful, it isn’t always the “REST-killer.”

Use GraphQL When…Use REST When…
You have complex, nested data requirements.Your app is simple with few resources.
You support multiple clients (Web, iOS, Android) with different data needs.You need standard HTTP caching mechanisms.
You want to aggregate data from multiple microservices.You are building a very small, lightweight microservice.

Final Thoughts

GraphQL shifts the power from the server to the client. By allowing the frontend to dictate the data structure, it speeds up development cycles and reduces the payload sent over the wire. Once you get to grips with the extra boilerplate and query syntax, it is surprisingly easy to use.

You may now be asking, “well, this is all well and good, but how do I update or delete records from the database?” Well, dear reader that is the topic for my next article.