Data Modeling Using Mongoose: A Comprehensive Guide

Data Modeling Using Mongoose: A Comprehensive Guide

Data modeling is an essential part of backend development. It defines how data is organized, stored, and accessed from a database. It ensures consistency in applications. Mongoose, a popular Node.js library, simplifies data modeling with MongoDB, a NoSQL database known for its flexibility. In this blog post, we will dive deep into data modeling with Mongoose, exploring its features, techniques, and best practices.


What is Data Modeling in Backend Development?

Data modeling is the process of designing a structure for your application's data. It involves defining “schemas” that defines the types of data to be stored, their relationships, and constraints. Proper data modeling helps in:

  • Ensuring data consistency and integrity.

  • Simplifying query logic.

  • Enhancing scalability and performance.

  • Makes debugging and maintenance easier.

In backend applications, well-structured data models act as a blueprint for how data interacts with the application and the database.


Why Mongoose for Data Modeling?

Mongoose is a MongoDB Object Data Modeling (ODM) library that provides a structured way to interact with MongoDB. This is a simple guide on HOW and WHY to use Mongoose. If you want to explore more details, you can always read the documentation here: https://mongoosejs.com/docs/. Unlike relational databases, MongoDB uses a flexible schema-less structure. However, Mongoose enforces schemas on application code, offering the best of both worlds:

  • Flexibility of MongoDB's schema-less design.

  • Data validation and consistency through schemas.

  • Powerful query-building capabilities.


Creating a Data Model with Mongoose

Note: Before implementing the database model, it's a good practice to refine your model using classic diagrams like ER diagrams. Keep refining them until you are completely sure, and then move on to the implementation, especially for complex applications.

Step 1: Install Mongoose

Install Mongoose in your Node.js application:

npm install mongoose

Step 2: Define a Schema

A schema in Mongoose defines the structure and properties of a document.

const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  age: { type: Number, min: 0 },
  isActive: { type: Boolean, default: true }
});

Here, userSchema defines the structure of documents in the users collection in MongoDB. Each key represents a field in the document, and the value is an object that specifies the field's type, constraints, and default values.

Field Breakdown:

  1. name:

    • type: String: This field must store a string.

    • required: true: This field is mandatory, and Mongoose will throw a validation error if it is missing.

  2. email:

    • type: String: This field must store a string.

    • required: true: This field is mandatory.

    • unique: true: This enforces that each document in the collection must have a unique value for the email field. However, note that Mongoose's unique is not a validation but an index creation for uniqueness enforcement.

  3. age:

    • type: Number: This field must store a number.

    • min: 0: This specifies that the value of age must be at least 0. If a lower value is provided, Mongoose will throw a validation error.

  4. isActive:

    • type: Boolean: This field must store a boolean value (true or false).

    • default: true: If no value is provided for this field when creating a document, Mongoose will automatically set it to true.

Step 3: Create a Model

A model is a compiled version of the schema that interacts with the database.

const User = mongoose.model('User', userSchema);

Step 4: Perform CRUD Operations

// Create a new user
const newUser = new User({ name: 'John Doe', email: 'john.doe@example.com', age: 25 });
await newUser.save();

// Find users
const users = await User.find();

// Update a user
await User.updateOne({ email: 'john.doe@example.com' }, { $set: { age: 26 } });

// Delete a user
await User.deleteOne({ email: 'john.doe@example.com' });

You can check more about CRUD features provided by Mongoose from the documentation later.


Timestamps in Mongoose

Mongoose provides a built-in option for automatic timestamping of documents. This adds createdAt and updatedAt fields to your schema.

const postSchema = new mongoose.Schema({
  title: String,
  content: String
}, 
{timestamps: true}
);

const Post = mongoose.model('Post', postSchema);

// Created documents will automatically include timestamps
const post = new Post({ title: 'First Post', content: 'This is my first post!' });
await post.save();

Creating Relationships Between Models

1. Referencing Documents

Referencing relationships in Mongoose, also known as document referencing, establish a connection between different collections in MongoDB. These relationships use MongoDB's ObjectId to store a reference to a document in another collection.

This approach is especially useful for one-to-many or many-to-many relationships, allowing data to remain normalized while maintaining flexibility for querying and retrieval.

For example, an author has a name, and a book has a title and an author. This means that for every book, the author should reference the author's data from the Author Collection.

const authorSchema = new mongoose.Schema({
  name: String
});

const bookSchema = new mongoose.Schema({
  title: String,
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'Author' }
});

const Author = mongoose.model('Author', authorSchema);
const Book = mongoose.model('Book', bookSchema);

// Populate referenced fields
const book = await Book.findOne({ title: 'Sample Book' }).populate('author');
console.log(book.author.name);

Explanation of the Code

  1. Schemas and Models

    • The authorSchema defines a schema for the Author collection, storing author details like name and bio.

    • The bookSchema defines a schema for the Book collection, where the author field stores the ObjectId of an Author document.

  2. Creating Documents

    • An author document is created and saved first.

    • A book document is then created, where the author field references the _id of the created author.

  3. Populating Data

    • Using populate, the author field in the book document is replaced with the corresponding Author document, allowing access to the full author details.

2. Embedding Documents

For one-to-few relationships, embed documents directly:

const blogSchema = new mongoose.Schema({
  title: String,
  comments: [{
    user: String,
    text: String
  }]
});

const Blog = mongoose.model('Blog', blogSchema);

// Example of an embedded document
const blog = new Blog({
  title: 'My Blog',
  comments: [{ user: 'Alice', text: 'Great post!' }]
});
await blog.save();

Mongoose vs. Fixed Schemas of Relational Databases

We have seen that Mongoose applies a schema to MongoDB. However, MongoDB itself has a schema-less architecture. So, how does this make Mongoose different from relational databases like MySQL, which strictly enforce schemas?

Key Differences:

  1. Flexibility

    • Mongoose schemas can evolve over time without requiring database migrations.

    • Relational databases enforce fixed schemas, necessitating schema migrations for changes.

  2. Data Validation

    • Mongoose provides validation in the application layer.

    • Relational databases enforce validation at database level through schema constraints.

  3. Schema Violation

    • MongoDB allows saving documents that do not strictly adhere to the defined Mongoose schema if validations are bypassed (e.g., using update operations without schema enforcement).

    • In relational databases, schema violations are not permitted at the database level.

Example of Schema Violation in Mongoose:

// Schema definition
const userSchema = new mongoose.Schema({ name: String, age: Number });
const User = mongoose.model('User', userSchema);

// Directly insert a document bypassing the schema
await mongoose.connection.db.collection('users').insertOne({ name: 'Schema Bypasser', age: 'not-a-number' });

// The above document violates the schema, but MongoDB will still accept it.

To ensure strict adherence to schemas, always use Mongoose methods for database operations.

You can also bypass the schema by the following method:

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const UserSchema = new Schema({
  name: String,
  email: { type: String, unique: true },
  age: Number
});

const User = mongoose.model('User', UserSchema);

// Create a new user
const newUser = new User({
  name: 'John Doe',
  email: 'john@example.com',
  age: 30
});

// validateBeforeSave:false does not validate the data before inserting.
newUser.save({ validateBeforeSave: false })
  .then(doc => console.log('Saved without validation:', doc))
  .catch(err => console.error('Error:', err));

If it's this easy to violate the schema, how can I make my MongoDB databases more secure? Here are some common solutions you can explore:

1. Use Strict Mode in Mongoose

  • By default, Mongoose has a strict mode for schemas, which ensures that only fields defined in the schema are saved.

  • If strict mode is disabled, additional fields not defined in the schema can still be stored.

Example:

const userSchema = new mongoose.Schema(
  {
    name: { type: String, required: true },
    age: { type: Number },
  },
  { strict: true } // This is enabled by default
);

With strict: true, only name and age fields are allowed. Any other field will be ignored during a save operation.

2. Use MongoDB Validation Rules

  • In addition to Mongoose validation, you can define validation rules at the database level using MongoDB's native validation features.

  • This ensures that even if someone bypasses Mongoose, invalid data will be rejected.

Example:

mongoose.connection.db.createCollection('users', {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "age"],
      properties: {
        name: { bsonType: "string", description: "must be a string and is required" },
        age: { bsonType: "int", minimum: 0, description: "must be an integer >= 0" }
      }
    }
  }
});

This schema-level validation is enforced by MongoDB itself.

3. Restrict Database Access

  • Use role-based access control (RBAC) in MongoDB to limit direct access to the database.

  • For example, create a role that only allows Mongoose to interact with the database, and disallow insert or update operations from unauthorized users or tools.

4. Avoid Direct Database Calls

  • Ensure that all database interactions go through Mongoose models. Avoid using raw MongoDB calls like insertOne or updateOne unless absolutely necessary.

5. Use Validation Middlewares

  • Use Mongoose middlewares like pre('save') or pre('updateOne') to add extra checks before data is saved to the database.

Example:

userSchema.pre('save', function (next) {
  if (typeof this.age !== 'number') {
    throw new Error('Age must be a number');
  }
  next();
});

6. Sanitize Inputs

  • Use libraries like validator.js or express-validator to sanitize and validate user input at the application level before passing it to Mongoose.

7. Monitor Database Activity

  • Use monitoring tools like MongoDB Atlas or other database auditing tools to track unexpected changes or direct access to the database.

Conclusion

Mongoose simplifies data modeling in MongoDB, offering the flexibility of NoSQL with the structure of schemas. By leveraging its features like timestamps, schema validation, and relationship handling, developers can build robust, scalable backend applications. While Mongoose is not as rigid as relational databases, it strikes a balance between flexibility and structure, making it an excellent choice for modern applications.

Experiment with Mongoose in your projects to fully appreciate its power and versatility in data modeling.

Did you find this article valuable?

Support Darsh's Blog by becoming a sponsor. Any amount is appreciated!