No history yet

Backend Architecture and MongoDB

Beyond Basic CRUD

You've likely built simple apps that can create, read, update, and delete data. But as applications grow, putting all your logic in one place becomes messy and hard to maintain. Professional backend development is about structure, scalability, and performance.

This is where an ODM, or Object Document Mapper like Mongoose, becomes essential. It acts as a bridge between your Node.js application and your MongoDB database, allowing you to model your application data with a clear schema and interact with it in a more structured, predictable way. Let's move beyond simple database calls and start building robust, professional-grade backends.

Architecting Data with Mongoose

MongoDB is schema-less, which offers great flexibility. However, for most applications, you need a predictable structure. Mongoose schemas define the shape of your documents, including data types, default values, and validation rules. Think of it as a blueprint for your data.

import mongoose from 'mongoose';
const { Schema } = mongoose;

const userSchema = new Schema({
  username: {
    type: String,
    required: true,
    unique: true,
    trim: true
  },
  email: {
    type: String,
    required: true,
    unique: true,
    match: [/.+\\@.+\\.+/, 'Please fill a valid email address']
  },
  password: {
    type: String,
    required: true
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

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

With a schema in place, we can tackle a core database design question: how should we model relationships between different types of data? For example, in a blogging application, how do you link a user to the posts they've written? You have two main options: referencing and embedding.

Choosing between referencing and embedding is a trade-off between query performance and data consistency.

FeatureReferencing (Normalisation)Embedding (Denormalisation)
How it WorksStore the _id of a related document.Nest a full document inside another.
Data ConsistencyHigh. Update data in one place.Lower. Data is duplicated.
Query PerformanceSlower. Requires a second query ($lookup or .populate()) to fetch related data.Faster. All data is retrieved in a single query.
Use CaseLarge, unbounded arrays of related data (e.g., comments on a popular post). When related data is frequently updated.Small, contained arrays of related data (e.g., addresses for a user). When data is read together frequently.

Here’s how you'd use referencing to link posts to a user:

// In your Post schema
const postSchema = new Schema({
  title: String,
  content: String,
  author: {
    type: Schema.Types.ObjectId,
    ref: 'User' // This creates the reference
  }
});

And here’s how you'd embed comments within a post:

// A simple schema for comments
const commentSchema = new Schema({
  text: String,
  author: String,
  createdAt: { type: Date, default: Date.now }
});

// In your Post schema
const postSchema = new Schema({
  title: String,
  content: String,
  comments: [commentSchema] // Embed an array of comment documents
});

Mongoose also allows you to run code before or after certain events using middleware, also known as hooks. A common use case is hashing a user's password before saving it to the database.

import bcrypt from 'bcrypt';

userSchema.pre('save', async function(next) {
  // Only hash the password if it has been modified (or is new)
  if (!this.isModified('password')) return next();

  try {
    const salt = await bcrypt.genSalt(10);
    this.password = await bcrypt.hash(this.password, salt);
    next();
  } catch (error) {
    next(error);
  }
});

This pre('save') hook ensures that plaintext passwords never touch your database, automatically encrypting them whenever a user document is created or updated.

Structuring Application Logic

As your application's complexity grows, you need an architecture that separates concerns. This makes your code easier to read, test, and maintain. A popular and effective choice for Node.js applications is the Controller-Service-Repository pattern., an evolution of layered architecture.

This pattern creates a clear separation of duties:

  • Controllers are the entry point. They handle incoming requests, extract relevant information from them (like parameters or body data), and pass it along to the service layer. They are responsible for sending back the final HTTP response.
  • Services contain the heart of your application: the business logic. They orchestrate operations, validate data, and decide how to respond to a request. A service might call multiple repositories to gather the data it needs.
  • Repositories are responsible for one thing only: data access. They contain all the logic for interacting with the database, such as finding, creating, or updating documents. This isolates your database queries from the rest of your application.

Optimising for Performance

As your database grows, finding data can become slow. MongoDB indexes are special data structures that store a small portion of the collection's data set in an easy-to-traverse form. An index stores the value of a specific field or set of fields, ordered by the field value. This allows MongoDB to execute queries much more efficiently.

For queries that filter on multiple fields, you can create a compound index.. For example, if you frequently search for users by their city and also sort them by age, you could create a compound index on both fields.

// Create a compound index on 'city' (ascending) and 'age' (descending)
userSchema.index({ city: 1, age: -1 });

The order of fields in a compound index matters. A good rule of thumb is to order them by: Equality, Sort, then Range.

For even more complex data processing, you'll need MongoDB's Aggregation Pipeline. It's a framework for data aggregation modelled on the concept of a data processing pipeline. Documents enter a multi-stage pipeline that transforms them into aggregated results.

Think of an aggregation pipeline as an assembly line for your data. Each stage performs a specific operation, like filtering, grouping, or calculating values.

For example, imagine you have a collection of orders and you want to calculate the total sales for each customer. You could use an aggregation pipeline to group the orders by customer ID and sum up their purchase amounts.

import Order from './models/Order';

async function calculateTotalSales() {
  const salesByCustomer = await Order.aggregate([
    { // Stage 1: Match only completed orders
      $match: { status: 'completed' }
    },
    { // Stage 2: Group by customerId and sum the 'amount' field
      $group: {
        _id: '$customerId',
        totalSales: { $sum: '$amount' }
      }
    },
    { // Stage 3: Sort by totalSales in descending order
      $sort: { totalSales: -1 }
    }
  ]);

  return salesByCustomer;
}

The aggregation framework is incredibly powerful, allowing you to perform server-side data analysis that would be complex and inefficient to do in your application code.

Ready to test your knowledge?

Quiz Questions 1/6

What is the primary purpose of a Mongoose schema when working with a schema-less database like MongoDB?

Quiz Questions 2/6

In the Controller-Service-Repository architectural pattern, which layer is exclusively responsible for interacting with the database?

By applying these patterns and tools, you can build backends that are not only functional but also scalable, maintainable, and performant.