15 Topics
Chapter•65 Minutes

Implement Complete CRUD Operations

Build create, read, update, partial update, and delete endpoints using Express, Mongoose, controllers, and service modules.

Design the User CRUD Endpoints

Before writing the implementation, define the API contract.

We will create the following endpoints:

MethodEndpointPurpose
POST/api/v1/usersCreate a user
GET/api/v1/usersRetrieve users
GET/api/v1/users/:idRetrieve a specific user
PUT/api/v1/users/:idReplace user information
PATCH/api/v1/users/:idPartially update a user
DELETE/api/v1/users/:idDelete a user

Each endpoint will return a consistent JSON response.

We will implement the service layer first, followed by controllers and routes.

User CRUD Architecture
Key Takeaways
  • Define the API contract before implementation.

  • Use HTTP methods according to their intended semantics.

  • Separate database operations from request handling.

Implement the Create User Service

Create src/services/user.service.js.

The service layer contains business logic and database operations.

The createUser function will accept validated user information and create a MongoDB document.

We will initially implement the basic creation logic. Password hashing will be integrated when authentication is implemented.

src/services/user.service.js
import { User } from '../models/user.model.js';

export async function createUser(userData) {
  const user = await User.create(userData);

  return user;
}

Creates a user document using the Mongoose model and returns the created document.

Key Takeaways
  • Services contain business logic.

  • User.create() creates and saves a MongoDB document.

  • Database operations should not be scattered throughout route definitions.

Implement the Create User Controller

Create src/controllers/user.controller.js.

The controller receives the HTTP request, calls the appropriate service, and sends the HTTP response.

For user creation, return status code 201 because a new resource has been created.

src/controllers/user.controller.js
import * as userService from '../services/user.service.js';

export async function createUser(req, res, next) {
  try {
    const user = await userService.createUser(req.body);

    res.status(201).json({
      success: true,
      message: 'User created successfully',
      data: user
    });
  } catch (error) {
    next(error);
  }
}

Calls the user service and returns a 201 response. Errors are passed to the centralized Express error handler.

Key Takeaways
  • Controllers handle HTTP input and output.

  • Return 201 after successful resource creation.

  • Pass unexpected errors to centralized error middleware.

Create the User Route

Create src/routes/user.routes.js.

Use Express Router to define the user endpoints.

For now, register the create endpoint. We will add the remaining CRUD operations in the following sections.

src/routes/user.routes.js
import { Router } from 'express';
import { createUser } from '../controllers/user.controller.js';

const router = Router();

router.post('/', createUser);

export default router;

Registers the POST /users endpoint and connects it to the createUser controller.

Key Takeaways
  • Express Router defines resource-specific endpoints.

  • POST /users creates a user.

  • Route handlers should delegate processing to controllers.

Test the Create User Endpoint

Start the server and use Postman or Bruno to send a POST request.

Endpoint:

POST http://localhost:3000/api/v1/users

Headers:

Content-Type: application/json

Request body:

{
  "name": "John Doe",
  "email": "john@example.com",
  "password": "temporary-password",
  "age": 25
}

At this stage, password hashing has not yet been implemented. Use only a disposable local database for this intermediate test, and do not expose this endpoint publicly. We will secure registration before completing authentication.

Temporary Development Step

Do not deploy the initial registration endpoint. Passwords must be hashed before storage, and input validation must be applied before accepting registrations.

Create User Request
POST /api/v1/users HTTP/1.1
Host: localhost:3000
Content-Type: application/json

{
  "name": "John Doe",
  "email": "john@example.com",
  "password": "temporary-password",
  "age": 25
}

Sends a JSON request to create a user. The password handling in this initial stage is intentionally incomplete and will be replaced with password hashing.

Key Takeaways
  • Test endpoints using an API client.

  • Set the Content-Type header to application/json.

  • Verify the created document in MongoDB.

Implement Retrieve All Users

Now implement an endpoint that retrieves users from MongoDB.

Add the following service function to src/services/user.service.js.

Mongoose provides find() to retrieve documents matching a query.

For this initial implementation, retrieve all users. Pagination and filtering will be implemented later.

Get All Users Service
export async function getAllUsers() {
  return User.find()
    .select('-password')
    .sort({ createdAt: -1 });
}

Retrieves users, excludes password fields, and sorts the newest users first.

Key Takeaways
  • find() retrieves matching MongoDB documents.

  • select('-password') excludes the password field.

  • sort() controls the order of returned documents.

Implement the Get All Users Controller

Add a controller function that calls the getAllUsers service.

The response should contain a success flag and a data array.

Return HTTP 200 when the request succeeds.

Get All Users Controller
export async function getAllUsers(req, res, next) {
  try {
    const users = await userService.getAllUsers();

    res.status(200).json({
      success: true,
      count: users.length,
      data: users
    });
  } catch (error) {
    next(error);
  }
}

Retrieves the user collection through the service and returns the result in a JSON response.

Key Takeaways
  • GET endpoints retrieve resources.

  • Return an array when listing multiple resources.

  • Never expose password fields in API responses.

Implement Retrieve User by ID

Sometimes the frontend needs a specific user instead of the entire collection.

Mongoose provides findById() for retrieving a document using its MongoDB identifier.

However, malformed MongoDB ObjectIds can cause casting errors. We should validate the ID before querying the database.

Get User by ID Service
export async function getUserById(userId) {
  return User.findById(userId)
    .select('-password');
}

Finds one user document using its MongoDB ObjectId and excludes the password field.

Key Takeaways
  • findById() retrieves a document using its identifier.

  • Validate ObjectIds before querying.

  • Return 404 when a valid identifier does not match a document.

Validate MongoDB ObjectIds

MongoDB uses ObjectId values as document identifiers by default.

A typical ObjectId is represented as a 24-character hexadecimal string.

Before querying MongoDB, validate the identifier using Mongoose.

Mongoose provides mongoose.isValidObjectId() to check whether a value can be cast to an ObjectId.

For APIs that require a strict 24-character hexadecimal representation, use an explicit regular expression as well.

Validate User ID
import mongoose from 'mongoose';

export function isValidUserId(userId) {
  return (
    typeof userId === 'string' &&
    /^[a-fA-F0-9]{24}$/.test(userId) &&
    mongoose.isValidObjectId(userId)
  );
}

Checks that the supplied ID is a string, contains exactly 24 hexadecimal characters, and is accepted by Mongoose.

Key Takeaways
  • MongoDB ObjectIds have a standard 24-character hexadecimal representation.

  • Validate IDs before database queries.

  • Return a client error for malformed identifiers.

Implement Get User by ID Controller

Add the getUserById controller function.

The controller should:

  1. Read the ID from req.params.
  2. Validate the ID.
  3. Call the service.
  4. Return 404 if the user does not exist.
  5. Return the user when found.
Get User Controller
import { isValidUserId } from '../utils/user-id.js';

export async function getUserById(req, res, next) {
  try {
    const { id } = req.params;

    if (!isValidUserId(id)) {
      return res.status(400).json({
        success: false,
        message: 'Invalid user ID'
      });
    }

    const user = await userService.getUserById(id);

    if (!user) {
      return res.status(404).json({
        success: false,
        message: 'User not found'
      });
    }

    res.status(200).json({
      success: true,
      data: user
    });
  } catch (error) {
    next(error);
  }
}

Validates the route parameter, retrieves the user, and handles invalid IDs and missing records.

Key Takeaways
  • Route parameters are available through req.params.

  • Return 400 for malformed identifiers.

  • Return 404 when the requested resource does not exist.

Implement Update User Service

We will support both PUT and PATCH operations.

For PUT, the request should provide the complete set of editable user fields.

For PATCH, only the supplied fields should be updated.

Mongoose provides findByIdAndUpdate() for updating a document by its ID.

Use the new option to return the updated document and runValidators to apply schema validators during the update.

Never Pass the Entire Request Body Blindly

Only allow explicitly editable fields. Never allow clients to update protected fields such as role, password hashes, or internal account status through a general-purpose update endpoint.

Update User Service
export async function updateUser(userId, updateData) {
  return User.findByIdAndUpdate(
    userId,
    { $set: updateData },
    {
      new: true,
      runValidators: true
    }
  ).select('-password');
}

Updates the supplied fields and returns the updated document. runValidators enables schema validation for supported update operations.

Key Takeaways
  • findByIdAndUpdate() updates documents by identifier.

  • runValidators enables update validation.

  • Use an explicit allowlist of editable properties.

Implement PUT and PATCH Routes

Add the PUT and PATCH routes to the user router.

Both operations can reuse the update controller, but their validation schemas should differ.

PUT represents replacement of the editable user representation.

PATCH represents partial modification.

The controller should receive the appropriate validated update payload.

User Update Routes
router.put('/:id', updateUser);
router.patch('/:id', updateUser);

Registers the PUT and PATCH endpoints. Different validation middleware will be added to enforce full replacement versus partial updates.

Key Takeaways
  • PUT and PATCH have different API semantics.

  • Both routes may reuse service functionality.

  • Use different validation rules for replacement and partial updates.

Implement Delete User Endpoint

Implement user deletion using Mongoose findByIdAndDelete().

The operation should return 404 if the user does not exist.

For successful deletion, HTTP 204 No Content is appropriate when no response body is returned.

Alternatively, an API may return 200 with a confirmation message. Choose one response convention and use it consistently.

Delete User Service
export async function deleteUser(userId) {
  return User.findByIdAndDelete(userId);
}

Deletes a user document by its MongoDB identifier and returns the deleted document or null.

Key Takeaways
  • findByIdAndDelete() removes a document by ID.

  • Return 404 if the document does not exist.

  • Use 204 when successful deletion returns no response body.

Implement Delete User Controller

Add the deleteUser controller.

The controller validates the ID, calls the service, and handles missing users.

After successful deletion, send an empty 204 response.

Delete User Controller
export async function deleteUser(req, res, next) {
  try {
    const { id } = req.params;

    if (!isValidUserId(id)) {
      return res.status(400).json({
        success: false,
        message: 'Invalid user ID'
      });
    }

    const deletedUser = await userService.deleteUser(id);

    if (!deletedUser) {
      return res.status(404).json({
        success: false,
        message: 'User not found'
      });
    }

    return res.status(204).send();
  } catch (error) {
    next(error);
  }
}

Validates the user identifier, deletes the matching record, and returns 204 after successful deletion.

Key Takeaways
  • Validate identifiers before deleting.

  • Handle missing records explicitly.

  • A 204 response should not contain a response body.

Register All User CRUD Routes

Update src/routes/user.routes.js to register all CRUD endpoints.

The completed router should expose all six operations.

Complete src/routes/user.routes.js
import { Router } from 'express';
import {
  createUser,
  getAllUsers,
  getUserById,
  updateUser,
  deleteUser
} from '../controllers/user.controller.js';

const router = Router();

router.post('/', createUser);
router.get('/', getAllUsers);
router.get('/:id', getUserById);
router.put('/:id', updateUser);
router.patch('/:id', updateUser);
router.delete('/:id', deleteUser);

export default router;

Registers all user CRUD endpoints and connects each endpoint to its controller.

Key Takeaways
  • All CRUD endpoints are registered in the user router.

  • Routes delegate to controllers.

  • The API now supports the complete basic CRUD workflow.