Build a Concurrency-Safe Ticket API

Build a MongoDB ticket API that prevents overselling during concurrent requests.

Introduction

30 Second Summary

During a flash sale, two buyers can reach the last few seats at almost the same moment. A system that handles each request separately can promise more tickets than the venue has.

In this project, you will build a flash-sale REST API with Express and MongoDB Atlas. You will prove its safety by exposing a race condition under competing requests before replacing the unsafe write with an atomic update.

What You'll Build

Picture your terminal proving that the same two competing reservations oversell the unsafe endpoint while the corrected endpoint preserves one remaining seat.

By the end of this project, you'll have:

  • A working ticket API that creates limited-capacity events with availability you can inspect.
  • A repeatable before-and-after concurrency demo that shows negative availability on the unsafe route before proving the safe route leaves one seat.
  • A hardened reservation contract that returns predictable JSON errors for invalid input, missing events, or insufficient seats.
  • Secret Mission: Add duplicate-request protection so retrying the same reservation key does not consume seats twice.

Are there any prerequisites?

Your existing Node.js, npm, and Visual Studio Code setup means no local installation is required.

You need a working MongoDB Atlas connection string with network access to the existing deployment.

Before We Start

Before We Start...

Before the hands-on work begins, commit to building a ticket reservation API that protects limited seat inventory when competing requests arrive at the same time.

Set Up the Ticket API

A concurrency demo is only useful when every learner runs the same runtime and dependency versions. This step removes environment differences before you build the API.

You will verify Node.js 24.11.1, create a local workspace, and install Express 5.2.1 with Mongoose 9.9.5. You will also prepare a private .env file for your existing MongoDB Atlas connection.

In this step, get ready to:
  • Verify that Node.js 24.11.1 is available.
  • Create the ticket-race-api workspace and install its dependencies.
  • Prepare local environment files without exposing your database credential.
Verify Node.js 24

This project uses APIs built into Node.js 24.11.1, including fetch and loadEnvFile(). Checking the runtime now prevents confusing failures later.

  • Open your terminal and check the installed runtime by running:
node --version

✔️ I see version 24.11.1

Your runtime matches the project. You can continue without changing your Node.js installation.

ⓧ I see an older version

The older runtime can miss APIs used by this project. Upgrade before you create the workspace.

  • Open the official Node.js 24.11.1 download page.
  • Download the macOS installer package or the Windows installer for your computer.
  • Run the installer and accept its default options.
  • Close your terminal after the installation finishes.
  • Open a new terminal and run the version command again.

ⓧ Command not found

Node.js is not available on your command path yet. Install the pinned release before continuing.

  • Open the official Node.js 24.11.1 download page.
  • Download the macOS installer package or the Windows installer for your computer.
  • Run the installer and accept its default options.
  • Open a new terminal after the installation finishes.
  • Run the version command again and confirm that it prints v24.11.1.
Create the project workspace

Visual Studio Code keeps the project files and terminal in one workspace. The dependency manifest pins every learner to the same library releases.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open system search.
  • Type Visual Studio Code and press Enter.
  • Click File in the menu bar.
  • Click Open Folder....
  • Select your Desktop in the folder picker.
  • Click New Folder.
  • Enter ticket-race-api as the folder name.
  • Open the new ticket-race-api folder.

You should see ticket-race-api at the top of the Explorer sidebar.

  • Create package.json inside the ticket-race-api folder with the New File... control.
  • Add the dependency pins by pasting this configuration:
{
  "name": "ticket-race-api",
  "version": "1.0.0",
  "private": true,
  "description": "A concurrency-safe ticket reservation API",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "demo:single": "node scripts/race-demo.js single",
    "race:naive": "node scripts/race-demo.js naive",
    "race:safe": "node scripts/race-demo.js safe",
    "test:contract": "node scripts/race-demo.js contract"
  },
  "dependencies": {
    "express": "5.2.1",
    "mongoose": "9.9.5"
  }
}

How Does package.json Control the Project?

The package.json file is the project manifest. npm reads it to install exact dependencies and expose repeatable commands.

  • The name and version fields identify this local application.
  • The private flag blocks accidental publication to the npm registry.
  • The main field names index.js as the startup file.
  • The scripts object gives each server, race, and contract command a memorable npm name.
  • The dependencies object pins Express and Mongoose. Exact versions keep the project behavior consistent across machines.
  • Save package.json.
  • Open the integrated terminal from View, then Terminal.
  • Install the pinned dependencies by running:
npm install

The first installation can take a minute while npm downloads the packages. You should see node_modules and package-lock.json appear in the Explorer.

Dependency Installation Failing?

Confirm that the terminal prompt ends in ticket-race-api. Check that package.json is saved and contains matching braces.

Help me diagnose this npm installation failure.

Protect the local configuration

Your Atlas connection string is a credential. The project documents the required setting in a shareable template while keeping the real value in a local file.

  • Create .gitignore inside ticket-race-api with the Explorer's New File... control.
  • Add the local-only paths by pasting:
node_modules/
.env

What Does .gitignore Protect?

The node_modules/ entry keeps downloaded packages out of version control. Anyone can rebuild them from package.json and package-lock.json.

The .env entry prevents your live MongoDB connection string from entering a commit. The shareable .env.example file documents the required setting without exposing the credential.

  • Save .gitignore.
  • Confirm that .gitignore appears beside package.json in the Explorer.
  • Create .env.example inside ticket-race-api with the New File... control.
  • Add the shareable environment template by pasting:
MONGODB_URI=your-mongodb-atlas-connection-string-with-ticket_race_demo-database
PORT=3000

Why Keep Two Environment Files?

The .env.example file shows which settings the app requires without containing a live credential. The ignored .env file holds the real connection string on your machine.

  • Save .env.example.
  • Confirm that .env.example appears in the Explorer.
  • Create .env beside .env.example with the New File... control.
  • Copy the contents of .env.example into .env.
  • Replace the connection-string placeholder with your existing Atlas connection string.
  • Set the connection string's database target to ticket_race_demo.
  • Save .env without sharing or capturing its contents.

Keep the URI Private

Keep .env out of screenshots and shared code. Use .env.example whenever you need to show another developer which settings the project requires.

Your workspace now has the pinned runtime, dependencies, and protected local configuration. Next, you will start the API and prove that it can reach MongoDB Atlas.

Start and Verify the Ticket API

Your workspace now has the exact runtime, packages, and protected environment file the project needs. The next goal is to turn that setup into a running API.

A small health endpoint tests the entire startup path without mixing in reservation logic. It proves that Node.js can load Express and connect through Mongoose to MongoDB Atlas.

In this step, get ready to:
  • Create the database connection module.
  • Build the Express health endpoint.
  • Start the server and verify the JSON response.

How Does a Request Reach the API?

Three files divide startup from request handling. This separation makes each failure easier to locate.

  • index.js loads .env, connects to MongoDB Atlas, and opens port 3000.
  • src/app.js receives HTTP requests and matches them to Express routes.
  • src/config/database.js owns the shared Mongoose connection.
  • A request to /health reaches src/app.js only after the database connection succeeds.
Connect the database

The connection module owns the database startup check. Keeping it separate lets every later feature use the same verified connection.

  • Create src/config/database.js by entering the full path in the Explorer's New File... control.
  • Add the connection function by pasting this code:
const mongoose = require('mongoose');

async function connectToDatabase() {
  // Read the private Atlas URI loaded from .env.
  const uri = process.env.MONGODB_URI;

  // Stop startup before Mongoose receives a missing URI.
  if (!uri) {
    throw new Error('MONGODB_URI is required');
  }

  // Open the shared database connection before the API listens.
  await mongoose.connect(uri);
  console.log('MongoDB connected');
}

module.exports = { connectToDatabase };

How Does the Database Module Work?

  • The require('mongoose') import gives this module access to the shared Mongoose connection API.
  • The process.env.MONGODB_URI expression reads the private URI that loadEnvFile() loads during startup.
  • The missing-value check stops immediately with a clear error. Mongoose never receives an undefined connection string.
  • The await mongoose.connect(uri) line pauses startup until Atlas accepts the connection.
  • The named export lets index.js call this function without exposing database setup to the Express application.
  • Save src/config/database.js.
  • Confirm that the Explorer shows database.js inside src/config.

Database Module in the Wrong Place?

Check that database.js sits inside src/config and that MONGODB_URI uses the same capitalization as .env.

Help me check the database connection module.

✔️ Awesome, I've got everything!

Your saved connection module is ready for the startup file.

ⓧ I'd like to double check the full code

const mongoose = require('mongoose');

async function connectToDatabase() {
  // Read the private Atlas URI loaded from .env.
  const uri = process.env.MONGODB_URI;

  // Stop startup before Mongoose receives a missing URI.
  if (!uri) {
    throw new Error('MONGODB_URI is required');
  }

  // Open the shared database connection before the API listens.
  await mongoose.connect(uri);
  console.log('MongoDB connected');
}

module.exports = { connectToDatabase };
Build the health endpoint

The application module defines request handling without opening a network port. Its health route returns a small JSON object that is easy to inspect.

  • Create src/app.js by entering the full path in the Explorer's New File... control.
  • Add the starter application by pasting this code:
const express = require('express');

// Create one application that later feature routes can extend.
const app = express();

// Parse JSON request bodies before they reach route handlers.
app.use(express.json());

app.get('/health', (req, res) => {
  // Return a small response that proves the API is reachable.
  res.json({ status: 'ok' });
});

module.exports = app;

How Does the Express Application Work?

  • The express() call creates one application that receives every HTTP request.
  • The express.json() middleware runs before the routes. It converts a JSON request body into req.body for later reservation handlers.
  • The app.get('/health', ...) call registers a handler for GET requests to /health.
  • The handler sends {"status":"ok"} through res.json(). Express also sets the response content type to JSON.
  • Exporting app keeps route configuration separate from the code that opens the network port.
  • Save src/app.js.
  • Confirm that app.js appears directly inside src.

Health Route Not Loading?

Make sure the file is named app.js and that module.exports = app appears after the route.

Help me check the starter application.

✔️ Awesome, I've got everything!

Your application now has a JSON health route.

ⓧ I'd like to double check the full code

const express = require('express');

// Create one application that later feature routes can extend.
const app = express();

// Parse JSON request bodies before they reach route handlers.
app.use(express.json());

app.get('/health', (req, res) => {
  // Return a small response that proves the API is reachable.
  res.json({ status: 'ok' });
});

module.exports = app;
Start and verify the API

The startup file loads .env, waits for MongoDB, and then opens port 3000. The running command keeps control of its terminal until you press Ctrl+C.

  • Create index.js inside ticket-race-api with the Explorer's New File... control.
  • Add the startup sequence by pasting this code:
const { loadEnvFile } = require('node:process');
const app = require('./src/app');
const { connectToDatabase } = require('./src/config/database');

// Load local settings before reading the port or database URI.
loadEnvFile();
const port = Number(process.env.PORT) || 3000;

async function startServer() {
  // Refuse traffic until the database connection succeeds.
  await connectToDatabase();

  app.listen(port, () => {
    console.log(`API listening on port ${port}`);
  });
}

// Surface startup failures and return a failing process status.
startServer().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

How Does Startup Work?

  • The built-in loadEnvFile() call loads the local settings without another package.
  • The awaited database connection keeps the server closed when Atlas is unavailable.
  • The startup failure handler records a failing process status instead of hiding the error.
  • Save index.js.
  • Start the API in the integrated terminal by running:
node index.js

The first Atlas connection can take several seconds. You should see MongoDB connected followed by API listening on port 3000.

API Not Starting?

Check that .env contains MONGODB_URI and that your Atlas network access still allows your current address. Keep the URI out of anything you share.

Help me diagnose this startup failure safely.

Before you open the endpoint, predict whether the browser will receive JSON or an HTML page.

  • Open a new browser tab.
  • Enter http://localhost:3000/health in the address bar.

You should see {"status":"ok"}. That response proves the runtime, Express application, local configuration, and Atlas connection are working together.

✔️ Awesome, I've got everything!

Your API is connected and returning JSON. Keep its terminal running for the next step.

ⓧ I'd like to double check the full code

const { loadEnvFile } = require('node:process');
const app = require('./src/app');
const { connectToDatabase } = require('./src/config/database');

// Load local settings before reading the port or database URI.
loadEnvFile();
const port = Number(process.env.PORT) || 3000;

async function startServer() {
  // Refuse traffic until the database connection succeeds.
  await connectToDatabase();

  app.listen(port, () => {
    console.log(`API listening on port ${port}`);
  });
}

// Surface startup failures and return a failing process status.
startServer().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Your first API response is live and backed by MongoDB Atlas. Next, you will turn that health check into a working reservation flow.

Build the First Reservation Flow

Your existing Node.js server already proves that the API can reach MongoDB Atlas. The next goal is to turn that connection into a working ticket reservation.

This first flow uses separate read, check, and update operations across an Express feature. A single request works correctly. The separation leaves a race condition for competing requests to expose in the next step.

In this step, get ready to:
  • Create the event model, repository, and application error class.
  • Build the unsafe reservation service and controller.
  • Run one four-seat reservation against a five-seat event.

How Does One Reservation Move Through the API?

Each layer answers one question about the same reservation. Following that path keeps database details out of HTTP handlers.

  • The route decides which URL and HTTP method enter the feature.
  • The controller reads the request and formats the response.
  • The service validates the input and applies the reservation rules.
  • The repository runs Mongoose queries against the event model.
  • The model defines the event document stored in MongoDB Atlas.
Create the event data layer

A Mongoose model defines how each event is stored. The repository keeps database operations separate from reservation decisions.

  • Switch back to the integrated terminal that is running the API.
  • Stop the current server process by pressing Ctrl+C.

The terminal returns to its command prompt. Your project files remain connected to the same Atlas database when you restart the server.

  • Create src/features/events/event.model.js by entering the full path in the Explorer sidebar's file creation control.
  • Define the event document by pasting this code into event.model.js:
const mongoose = require('mongoose');

const eventSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: true,
      trim: true
    },
    capacity: {
      type: Number,
      required: true,
      min: 1
    },
    availableSeats: {
      type: Number,
      required: true,
      min: 0
    }
  },
  { timestamps: true }
);

module.exports = mongoose.model('Event', eventSchema);

What does the event model do?

  • The name field stores a trimmed event name.
  • The capacity field records the original seat limit.
  • The availableSeats field tracks the seats that reservations can still claim.
  • The timestamps option records when each event document is created or updated.
  • Save event.model.js.
  • Confirm that event.model.js appears inside src/features/events in the Explorer sidebar.

Can't find the event model?

Check that the file path is src/features/events/event.model.js. The repository imports the model from that exact folder.

Make sure the file name ends with .js. Help me check my event model file location.

  • Create src/features/events/event.repository.js by entering the full path in the Explorer sidebar's file creation control.
  • Add the event database operations by pasting this code into event.repository.js:
const Event = require('./event.model');

function createEvent(eventData) {
  return Event.create(eventData);
}

function findEventById(eventId) {
  return Event.findById(eventId);
}

async function decrementSeatsWithoutGuard(eventId, seats) {
  await Event.updateOne(
    { _id: eventId },
    { $inc: { availableSeats: -seats } }
  );

  return Event.findById(eventId);
}

module.exports = {
  createEvent,
  findEventById,
  decrementSeatsWithoutGuard
};

What does the repository do?

  • The createEvent() function saves a new event document.
  • The findEventById() function retrieves one event by its document ID.
  • The decrementSeatsWithoutGuard() function reduces availability with $inc.
  • The decrement filter checks only _id. This missing availability condition creates the concurrency gap that you will test later.
  • Save event.repository.js.
  • Confirm that the repository exports createEvent, findEventById, and decrementSeatsWithoutGuard.

Seeing a repository problem?

Check that the model import begins with ./. Both files live in the same events folder.

Confirm that the update uses availableSeats with the same capitalization as the model. Help me compare my event repository with my event model.

Service errors also need a stable status code and machine-readable code. A small custom error class carries that contract between the service layer and later error middleware.

  • Create src/errors/AppError.js by entering the full path in the Explorer sidebar's file creation control.
  • Add the custom error class by pasting this code into AppError.js:
class AppError extends Error {
  constructor(statusCode, code, message) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
  }
}

module.exports = { AppError };

What does AppError carry?

  • The base Error class stores the human-readable message.
  • The statusCode property represents the HTTP response status.
  • The code property gives API callers a stable identifier for the failure.
  • Save AppError.js.
  • Confirm that AppError.js appears inside src/errors in the Explorer sidebar.

AppError not exporting?

Check that the class name uses the exact capitalization AppError. The service imports that identifier by name.

Confirm that the export wraps AppError in braces. Help me fix my AppError export.

Build the unsafe reservation service

The service layer owns input rules and reservation decisions. Its first reservation path reads availability before writing the decrement.

  • Create src/features/events/event.service.js by entering the full path in the Explorer sidebar's file creation control.
  • Add the service imports and validation functions by pasting this first chunk:
const { setTimeout: delay } = require('node:timers/promises');
const eventRepository = require('./event.repository');
const { AppError } = require('../../errors/AppError');

function validateEventInput(name, capacity) {
  if (typeof name !== 'string' || name.trim() === '') {
    throw new AppError(400, 'INVALID_EVENT_NAME', 'Event name is required');
  }

  if (!Number.isInteger(capacity) || capacity < 1) {
    throw new AppError(400, 'INVALID_CAPACITY', 'Capacity must be a positive integer');
  }

  return {
    name: name.trim(),
    capacity
  };
}

function validateSeatCount(seats) {
  if (!Number.isInteger(seats) || seats < 1) {
    throw new AppError(400, 'INVALID_SEAT_COUNT', 'Seats must be a positive integer');
  }
}

How does service validation work?

  • The promise-based delay widens the unsafe reservation gap later in the file.
  • The validateEventInput() function requires a non-empty name and a positive integer capacity.
  • The validateSeatCount() function rejects zero, negative, or fractional seat quantities.
  • Add the event creation and retrieval functions below validateSeatCount() by pasting this chunk:
async function createEvent(name, capacity) {
  const validEvent = validateEventInput(name, capacity);

  return eventRepository.createEvent({
    ...validEvent,
    availableSeats: validEvent.capacity
  });
}

async function getEvent(eventId) {
  const event = await eventRepository.findEventById(eventId);

  if (!event) {
    throw new AppError(404, 'EVENT_NOT_FOUND', 'Event not found');
  }

  return event;
}

How are events created and found?

  • The createEvent() function starts availableSeats at the validated capacity.
  • The getEvent() function returns the matching document.
  • A missing document produces an EVENT_NOT_FOUND application error with status 404.
  • Add the unsafe reservation function and service exports below getEvent() by pasting this final chunk:
async function reserveSeatsNaively(eventId, seats) {
  validateSeatCount(seats);
  const event = await getEvent(eventId);

  if (event.availableSeats < seats) {
    throw new AppError(409, 'INSUFFICIENT_SEATS', 'Not enough seats are available');
  }

  await delay(250);
  return eventRepository.decrementSeatsWithoutGuard(eventId, seats);
}

module.exports = {
  createEvent,
  getEvent,
  reserveSeatsNaively
};

Why is this reservation path unsafe?

The function validates the request before reading the event. It rejects a reservation when the retrieved availability is too low.

The 250 millisecond delay widens the gap between the check and the decrement. The database update has no availability condition guarding that gap.

  • Save event.service.js.
  • Confirm that the final export includes reserveSeatsNaively.

Service imports not resolving?

Check that event.repository.js sits beside event.service.js. The repository import uses a same-folder path.

Confirm that the AppError import moves up through features before entering errors. Help me diagnose my event service imports.

Controllers translate HTTP request data into service calls. They also choose the success status and response body returned to the client.

  • Create src/features/events/event.controller.js by entering the full path in the Explorer sidebar's file creation control.
  • Add the service import and event creation handler by pasting this first chunk:
const eventService = require('./event.service');

async function createEvent(req, res) {
  const event = await eventService.createEvent(req.body.name, req.body.capacity);
  res.status(201).json({ data: event });
}

How does event creation reach the service?

The controller reads name and capacity from the JSON request body. A successful creation returns status 201 with the saved event under data.

  • Add the event retrieval handler below createEvent() by pasting this chunk:
async function getEvent(req, res) {
  const event = await eventService.getEvent(req.params.eventId);
  res.json({ data: event });
}

How does retrieval use the URL?

The router places the event identifier in req.params.eventId. The controller passes that identifier to getEvent() before returning the document.

  • Add the unsafe reservation handler and controller exports below getEvent() by pasting this final chunk:
async function reserveSeatsNaively(req, res) {
  const event = await eventService.reserveSeatsNaively(
    req.params.eventId,
    req.body.seats
  );

  res.status(201).json({
    message: 'Reservation accepted by the unsafe demo endpoint',
    data: event
  });
}

module.exports = {
  createEvent,
  getEvent,
  reserveSeatsNaively
};

How does the reservation handler respond?

The handler combines the URL's event ID with the request body's seat quantity. A successful reservation returns status 201 with the updated event.

The response message labels this as the unsafe demonstration endpoint. That label keeps the learning path separate from the safe endpoint you add later.

  • Save event.controller.js.
  • Confirm that the final export contains three controller handlers.

Controller function missing?

Check that reserveSeatsNaively has the same spelling in its declaration and export. The router uses that exact property name.

Confirm that the controller imports ./event.service. Help me compare my controller handlers and exports.

Route and run the first reservation

The router gives each controller an HTTP method and path. Mounting that router under /api/events turns the feature into a callable API.

  • Create src/features/events/event.routes.js by entering the full path in the Explorer sidebar's file creation control.
  • Connect the three event handlers by pasting this code into event.routes.js:
const express = require('express');
const eventController = require('./event.controller');

const router = express.Router();

router.post('/', eventController.createEvent);
router.get('/:eventId', eventController.getEvent);
router.post('/:eventId/reservations/naive', eventController.reserveSeatsNaively);

module.exports = router;

What paths does the router define?

  • The POST root route creates an event.
  • The GET route retrieves an event using :eventId.
  • The final POST route sends a reservation to the deliberately unsafe handler.
  • Save event.routes.js.
  • Confirm that event.routes.js appears beside the other event feature files.

Route handler undefined?

Check that the controller import uses ./event.controller. Confirm that every referenced handler appears in the controller export.

Match the path segment reservations/naive exactly. Help me trace an undefined event route handler.

The event router exists as a separate module. The application must mount it before requests can reach those paths.

  • Replace the contents of src/app.js with this updated application code:
const express = require('express');
const eventRoutes = require('./features/events/event.routes');

const app = express();

app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.use('/api/events', eventRoutes);

module.exports = app;

What changed in the application?

The application imports the event router while preserving JSON parsing and the health route. The app.use() call prefixes every event route with /api/events.

  • Save src/app.js.
  • Confirm that the existing /health route remains above the event router mount.

Event routes not mounting?

Check that the import path starts with ./features/events. The src prefix is unnecessary because app.js already sits inside that folder.

Confirm that the mount path begins with a forward slash. Help me verify the event router mount in app.js.

A repeatable demo removes manual request setup. The script creates a fresh five-seat event before sending one four-seat reservation.

  • Create scripts/race-demo.js by entering the full path in the Explorer sidebar's file creation control.
  • Add environment loading and the reusable request helper by pasting this first chunk:
const { loadEnvFile } = require('node:process');

loadEnvFile();

const port = Number(process.env.PORT) || 3000;
const baseUrl = `http://localhost:${port}`;
const requestedMode = process.argv[2];
const mode = ['single', 'naive'].includes(requestedMode) ? requestedMode : 'safe';
const reservationPath = mode === 'safe' ? 'reservations' : 'reservations/naive';

async function request(path, options = {}) {
  const response = await fetch(`${baseUrl}${path}`, options);
  const body = await response.json();
  return { status: response.status, body };
}

How does the request helper work?

  • The script loads the same .env file as the API server.
  • The mode value comes from the script argument.
  • The request() helper sends a request with built-in fetch before parsing its JSON response.
  • Add a complete event-creation helper below request() by pasting this code:
async function createDemoEvent() {
  // Give every run a fresh five-seat event.
  const created = await request('/api/events', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      name: `Midnight Launch ${Date.now()}`,
      capacity: 5
    })
  });

  // Stop before reserving when event creation fails.
  if (created.status !== 201) {
    throw new Error(`Could not create event: ${JSON.stringify(created.body)}`);
  }

  return created.body.data._id;
}

How does the demo create its event?

  • The helper creates a uniquely named event with capacity 5.
  • A non-201 response stops the demo before it sends a reservation.
  • A successful call returns the new event ID for the reservation flow.
  • Add the complete runRace() function below createDemoEvent() by pasting this code:
  const results = [await reserveFourSeats()];

  const finalEvent = await request(`/api/events/${eventId}`);
  const statuses = results.map((result) => result.status);
  const acceptedSeats = results.filter((result) => result.status === 201).length * 4;

  console.log(`Mode: ${mode}`);
  console.log('Reservation statuses:', statuses);
  console.log('Accepted seats:', acceptedSeats);
  console.log('Final availability:', finalEvent.body.data.availableSeats);
}

runRace().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

What does the demo measure?

  • The script sends one prepared reservation before retrieving the final event state.
  • The statuses array captures the reservation response status.
  • The acceptedSeats calculation counts accepted four-seat requests.
  • The final log shows whether the event has the expected one seat remaining.
  • Save scripts/race-demo.js.
  • Confirm that the script ends with the runRace().catch() block.

Demo file looks incomplete?

Check that the second chunk sits inside runRace() before its closing brace. The first chunk deliberately leaves that function open.

Confirm that runRace().catch() appears after the function closes. Help me check the structure of race-demo.js.

Your event feature is now connected from the HTTP route down to MongoDB. The final check proves that one reservation follows the entire path successfully.

  • Restart the API in the integrated terminal by running this command:
node index.js

What should the server report?

The terminal reports MongoDB connected before showing the local ticket API address. Keep this process running for the demo request.

  • Create another integrated terminal with the terminal panel's add control.

Before you run the demo, predict the final availability after one four-seat request reserves from a five-seat event.

  • Run the single-reservation demonstration in the new terminal with this command:
node scripts/race-demo.js single

What Should the Demo Prove?

You will see mode single with reservation statuses [201]. The accepted seat total is 4.

The final availability is 1. One reservation works correctly before concurrency enters the test.

Confirm that the original terminal still shows the running API process. The demo sends requests to that local server.

Check the API terminal for a file path or database error. Help me diagnose my single reservation demo.

✔️ Awesome, I've got everything!

Great work. Save every new file and keep the API running for the next concurrency test.

ⓧ I'd like to double check the full code

class AppError extends Error {
  constructor(statusCode, code, message) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
  }
}

module.exports = { AppError };
const mongoose = require('mongoose');

const eventSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: true,
      trim: true
    },
    capacity: {
      type: Number,
      required: true,
      min: 1
    },
    availableSeats: {
      type: Number,
      required: true,
      min: 0
    }
  },
  { timestamps: true }
);

module.exports = mongoose.model('Event', eventSchema);
const Event = require('./event.model');

function createEvent(eventData) {
  return Event.create(eventData);
}

function findEventById(eventId) {
  return Event.findById(eventId);
}

async function decrementSeatsWithoutGuard(eventId, seats) {
  await Event.updateOne(
    { _id: eventId },
    { $inc: { availableSeats: -seats } }
  );

  return Event.findById(eventId);
}

module.exports = {
  createEvent,
  findEventById,
  decrementSeatsWithoutGuard
};
const { setTimeout: delay } = require('node:timers/promises');
const eventRepository = require('./event.repository');
const { AppError } = require('../../errors/AppError');

function validateEventInput(name, capacity) {
  if (typeof name !== 'string' || name.trim() === '') {
    throw new AppError(400, 'INVALID_EVENT_NAME', 'Event name is required');
  }

  if (!Number.isInteger(capacity) || capacity < 1) {
    throw new AppError(400, 'INVALID_CAPACITY', 'Capacity must be a positive integer');
  }

  return {
    name: name.trim(),
    capacity
  };
}

function validateSeatCount(seats) {
  if (!Number.isInteger(seats) || seats < 1) {
    throw new AppError(400, 'INVALID_SEAT_COUNT', 'Seats must be a positive integer');
  }
}

async function createEvent(name, capacity) {
  const validEvent = validateEventInput(name, capacity);

  return eventRepository.createEvent({
    ...validEvent,
    availableSeats: validEvent.capacity
  });
}

async function getEvent(eventId) {
  const event = await eventRepository.findEventById(eventId);

  if (!event) {
    throw new AppError(404, 'EVENT_NOT_FOUND', 'Event not found');
  }

  return event;
}

async function reserveSeatsNaively(eventId, seats) {
  validateSeatCount(seats);
  const event = await getEvent(eventId);

  if (event.availableSeats < seats) {
    throw new AppError(409, 'INSUFFICIENT_SEATS', 'Not enough seats are available');
  }

  await delay(250);
  return eventRepository.decrementSeatsWithoutGuard(eventId, seats);
}

module.exports = {
  createEvent,
  getEvent,
  reserveSeatsNaively
};
const eventService = require('./event.service');

async function createEvent(req, res) {
  const event = await eventService.createEvent(req.body.name, req.body.capacity);
  res.status(201).json({ data: event });
}

async function getEvent(req, res) {
  const event = await eventService.getEvent(req.params.eventId);
  res.json({ data: event });
}

async function reserveSeatsNaively(req, res) {
  const event = await eventService.reserveSeatsNaively(
    req.params.eventId,
    req.body.seats
  );

  res.status(201).json({
    message: 'Reservation accepted by the unsafe demo endpoint',
    data: event
  });
}

module.exports = {
  createEvent,
  getEvent,
  reserveSeatsNaively
};
const express = require('express');
const eventController = require('./event.controller');

const router = express.Router();

router.post('/', eventController.createEvent);
router.get('/:eventId', eventController.getEvent);
router.post('/:eventId/reservations/naive', eventController.reserveSeatsNaively);

module.exports = router;
const express = require('express');
const eventRoutes = require('./features/events/event.routes');

const app = express();

app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.use('/api/events', eventRoutes);

module.exports = app;
const { loadEnvFile } = require('node:process');

loadEnvFile();

const port = Number(process.env.PORT) || 3000;
const baseUrl = `http://localhost:${port}`;
const requestedMode = process.argv[2];
const mode = ['single', 'naive'].includes(requestedMode) ? requestedMode : 'safe';
const reservationPath = mode === 'safe' ? 'reservations' : 'reservations/naive';

async function request(path, options = {}) {
  const response = await fetch(`${baseUrl}${path}`, options);
  const body = await response.json();
  return { status: response.status, body };
}

async function runRace() {
  const created = await request('/api/events', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      name: `Midnight Launch ${Date.now()}`,
      capacity: 5
    })
  });

  if (created.status !== 201) {
    throw new Error(`Could not create event: ${JSON.stringify(created.body)}`);
  }

  const eventId = created.body.data._id;
  const reserveFourSeats = () => request(
    `/api/events/${eventId}/${reservationPath}`,
    {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ seats: 4 })
    }
  );

  const results = [await reserveFourSeats()];

  const finalEvent = await request(`/api/events/${eventId}`);
  const statuses = results.map((result) => result.status);
  const acceptedSeats = results.filter((result) => result.status === 201).length * 4;

  console.log(`Mode: ${mode}`);
  console.log('Reservation statuses:', statuses);
  console.log('Accepted seats:', acceptedSeats);
  console.log('Final availability:', finalEvent.body.data.availableSeats);
}

runRace().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

That first reservation now travels through your route, controller, service, repository, and event model. Next, you will send two requests into the same unsafe gap and watch the seat invariant fail.

Make the API Oversell

Your single-request demo proved that the API can reserve four seats in MongoDB Atlas. Now you need to learn what happens when two buyers reach the same five-seat event together.

A race condition can appear when each unsafe handler checks the same availableSeats value before either database write finishes. This step makes that overlap visible under a repeatable workload.

In this step, get ready to:
  • Extend the demo with a second four-seat reservation.
  • Launch both requests together against one five-seat event.
  • Capture the response statuses, accepted seats, and final availability.
Confirm the shared reservation target

The existing reserveFourSeats helper builds one unsafe reservation request. Reusing that helper makes every call target the same event.

  • In the Visual Studio Code Explorer sidebar from earlier, select scripts/race-demo.js.
  • Find the reserveFourSeats helper below the event creation check.
  • Confirm that its request path uses eventId with reservationPath.

The helper closes over eventId. Every call therefore submits four seats against the same five-seat Event.

Launch both reservations together

Sequential requests would let the first write finish before the second check. Promise.all() starts both request promises before waiting for their results.

  • In scripts/race-demo.js, locate the assignment that begins with const results.
  • Replace the current single-request assignment by copying this code:
  const results = mode === 'single'
    ? [await reserveFourSeats()]
    : await Promise.all([reserveFourSeats(), reserveFourSeats()]);

How Does This Create Competition?

  • The single branch preserves the one-request demo from the previous step.
  • The other branch calls reserveFourSeats() twice for the same event.
  • Promise.all() lets both unsafe handlers reach their separate availability checks before their delayed writes finish.
  • Save scripts/race-demo.js.

✔️ Awesome, I've got everything!

Great. scripts/race-demo.js now creates a concurrent workload when you select naive mode.

ⓧ I'd like to double check the full code

const { loadEnvFile } = require('node:process');

loadEnvFile();

const port = Number(process.env.PORT) || 3000;
const baseUrl = `http://localhost:${port}`;
const requestedMode = process.argv[2];
const mode = ['single', 'naive'].includes(requestedMode) ? requestedMode : 'safe';
const reservationPath = mode === 'safe' ? 'reservations' : 'reservations/naive';

async function request(path, options = {}) {
  const response = await fetch(`${baseUrl}${path}`, options);
  const body = await response.json();
  return { status: response.status, body };
}

async function runRace() {
  const created = await request('/api/events', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      name: `Midnight Launch ${Date.now()}`,
      capacity: 5
    })
  });

  if (created.status !== 201) {
    throw new Error(`Could not create event: ${JSON.stringify(created.body)}`);
  }

  const eventId = created.body.data._id;
  const reserveFourSeats = () => request(
    `/api/events/${eventId}/${reservationPath}`,
    {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ seats: 4 })
    }
  );

  const results = mode === 'single'
    ? [await reserveFourSeats()]
    : await Promise.all([reserveFourSeats(), reserveFourSeats()]);

  const finalEvent = await request(`/api/events/${eventId}`);
  const statuses = results.map((result) => result.status);
  const acceptedSeats = results.filter((result) => result.status === 201).length * 4;

  console.log(`Mode: ${mode}`);
  console.log('Reservation statuses:', statuses);
  console.log('Accepted seats:', acceptedSeats);
  console.log('Final availability:', finalEvent.body.data.availableSeats);
}

runRace().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
Capture the oversell evidence

The existing output calculations turn both responses into three measurements. Together, they show whether the event's seat invariant survived the competing writes.

  • Confirm that the four console.log() lines remain below the acceptedSeats calculation.

Before you run the demo, predict whether both reservations will succeed.

  • Run the two-request unsafe workload in the terminal from earlier with this command:
node scripts/race-demo.js naive

What Does This Result Prove?

  • The reservation statuses are [201, 201]. Both four-seat requests were accepted.
  • The accepted seat count is 8. The event only had a capacity of 5.
  • Final availability is -3. The unguarded decrements broke the seat invariant after both checks passed.

Both handlers read the same starting value before either write completes. The delay makes this overlap repeatable.

  1. Request A reads availableSeats: 5 and accepts a four-seat reservation.
  2. Request B also reads availableSeats: 5 and accepts another four-seat reservation.
  3. Request A decrements the stored value from 5 to 1.
  4. Request B decrements the stored value from 1 to -3.

The rule that availability must stay at or above zero is the seat invariant. The unsafe flow checks that rule too early to protect the later write.

You have reproduced the oversell under a workload you can run again. The failure is now visible evidence instead of a theoretical warning.

Not Seeing the Oversell?

  • Confirm that the API terminal from earlier is still running on port 3000.
  • Check that scripts/race-demo.js contains the Promise.all() line from the double-check tab.
  • Help me diagnose this race demo.

The unsafe path now gives you a repeatable before-state. Next, you'll close that concurrency gap inside the database write.

Fix the Race with an Atomic Update

The previous race exposed the concurrency gap between checking availability and decrementing the seats. Both requests crossed that gap before either write finished.

This step moves both decisions into one atomic update on the event document in MongoDB Atlas. The same competing workload can then prove that availability never becomes negative.

In this step, get ready to:
  • Add a conditional repository update that protects the remaining seats.
  • Connect the safe service path to a new reservation endpoint.
  • Run the same race and prove that one seat remains.
Add the atomic repository update

The repository is where Mongoose performs the guarded write. Its filter matches the event only while enough seats remain.

Why One Atomic Update?

The unsafe flow reads availability, checks that snapshot, and writes later. A competing request can change the stored value between those operations.

  • The unsafe repository update matches only the event ID. Its earlier availability check cannot protect the later decrement.
  • The atomic update matches both the event ID and availableSeats: { $gte: seats } at write time.
  • The $inc operation runs only while the latest event document still satisfies that filter.
  • A competing request evaluates the same filter against the value left by the winning update.

A null result means no event satisfied both conditions. The event may be missing, or it may no longer have enough seats.

The service performs one lookup after a null result. It returns 404 for a missing event or 409 when the event exists without enough availability.

  • Return to src/features/events/event.repository.js in the Visual Studio Code Explorer sidebar.
  • Find the module.exports block below decrementSeatsWithoutGuard().
  • Replace that export block with the guarded function plus updated exports by pasting this code:
function reserveAvailableSeats(eventId, seats) {
  return Event.findOneAndUpdate(
    {
      _id: eventId,
      availableSeats: { $gte: seats }
    },
    {
      $inc: { availableSeats: -seats }
    },
    {
      returnDocument: 'after'
    }
  );
}

module.exports = {
  createEvent,
  findEventById,
  decrementSeatsWithoutGuard,
  reserveAvailableSeats
};

What does this repository update do?

  • The _id condition targets the requested event.
  • The availableSeats: { $gte: seats } condition allows the write only when the event can satisfy the reservation.
  • The $inc update subtracts the requested quantity inside the same document write.
  • The returnDocument: 'after' option returns the event after a successful update.
  • Save src/features/events/event.repository.js.
  • Stop the running API in the server terminal from earlier by pressing Ctrl+C.
  • Restart the API with the guarded repository loaded by running this command:
node index.js

What does this restart prove?

The restart loads the updated repository into the existing application. Reaching the connection log proves that Node.js can parse the new function plus its export.

You should see MongoDB connected followed by the API listening message. The guarded repository function now loads successfully.

API not restarting?

Confirm that reserveAvailableSeats appears above module.exports. Check that the export uses the same capitalization.

Look for a missing comma after decrementSeatsWithoutGuard. Help me diagnose this repository startup problem.

✔️ Awesome, I've got everything!

Your repository now has both the unsafe decrement and the guarded atomic update.

ⓧ I'd like to double check the full code

const Event = require('./event.model');

function createEvent(eventData) {
  return Event.create(eventData);
}

function findEventById(eventId) {
  return Event.findById(eventId);
}

async function decrementSeatsWithoutGuard(eventId, seats) {
  await Event.updateOne(
    { _id: eventId },
    { $inc: { availableSeats: -seats } }
  );

  return Event.findById(eventId);
}

function reserveAvailableSeats(eventId, seats) {
  return Event.findOneAndUpdate(
    {
      _id: eventId,
      availableSeats: { $gte: seats }
    },
    {
      $inc: { availableSeats: -seats }
    },
    {
      returnDocument: 'after'
    }
  );
}

module.exports = {
  createEvent,
  findEventById,
  decrementSeatsWithoutGuard,
  reserveAvailableSeats
};

What should this file contain?

This reference preserves the unsafe function for comparison. It also exports the new guarded function used by the safe service path.

Connect the safe endpoint

A conditional update can return no event for two reasons. The service checks whether the event exists so the API can distinguish a missing event from insufficient seats.

  • Return to src/features/events/event.service.js in the Explorer sidebar.
  • Find the module.exports block below reserveSeatsNaively().
  • Replace that export block with the safe service function plus updated exports by pasting this code:
async function reserveSeats(eventId, seats) {
  validateSeatCount(seats);

  const updatedEvent = await eventRepository.reserveAvailableSeats(eventId, seats);
  if (updatedEvent) {
    return updatedEvent;
  }

  const existingEvent = await eventRepository.findEventById(eventId);
  if (!existingEvent) {
    throw new AppError(404, 'EVENT_NOT_FOUND', 'Event not found');
  }

  throw new AppError(409, 'INSUFFICIENT_SEATS', 'Not enough seats are available');
}

module.exports = {
  createEvent,
  getEvent,
  reserveSeatsNaively,
  reserveSeats
};

How does the service decide?

  • The existing validateSeatCount() check rejects invalid reservation quantities before the database call.
  • A returned event proves that the guarded update reserved the seats.
  • A lookup after a failed update identifies a missing event with 404.
  • An existing event after a failed update means the available seats were insufficient. The service reports that conflict with 409.
  • Save src/features/events/event.service.js.
  • Stop the running API in the server terminal by pressing Ctrl+C.
  • Restart the API with the safe service loaded by running this command:
node index.js

What does this service check prove?

The restart loads the repository through the updated service. The listening message confirms that the new function references a valid repository export.

You should see the database connection log followed by the listening message. The service layer now loads without an import error.

Service failing to load?

Confirm that the service calls eventRepository.reserveAvailableSeats. Check that the repository exports that exact function name.

Make sure the new function appears before the service's module.exports block. Help me trace this safe service error.

✔️ Awesome, I've got the service!

The safe service now separates successful reservations from missing events and seat conflicts.

ⓧ I'd like to double check the full code

const { setTimeout: delay } = require('node:timers/promises');
const eventRepository = require('./event.repository');
const { AppError } = require('../../errors/AppError');

function validateEventInput(name, capacity) {
  if (typeof name !== 'string' || name.trim() === '') {
    throw new AppError(400, 'INVALID_EVENT_NAME', 'Event name is required');
  }

  if (!Number.isInteger(capacity) || capacity < 1) {
    throw new AppError(400, 'INVALID_CAPACITY', 'Capacity must be a positive integer');
  }

  return {
    name: name.trim(),
    capacity
  };
}

function validateSeatCount(seats) {
  if (!Number.isInteger(seats) || seats < 1) {
    throw new AppError(400, 'INVALID_SEAT_COUNT', 'Seats must be a positive integer');
  }
}

async function createEvent(name, capacity) {
  const validEvent = validateEventInput(name, capacity);

  return eventRepository.createEvent({
    ...validEvent,
    availableSeats: validEvent.capacity
  });
}

async function getEvent(eventId) {
  const event = await eventRepository.findEventById(eventId);

  if (!event) {
    throw new AppError(404, 'EVENT_NOT_FOUND', 'Event not found');
  }

  return event;
}

async function reserveSeatsNaively(eventId, seats) {
  validateSeatCount(seats);
  const event = await getEvent(eventId);

  if (event.availableSeats < seats) {
    throw new AppError(409, 'INSUFFICIENT_SEATS', 'Not enough seats are available');
  }

  await delay(250);
  return eventRepository.decrementSeatsWithoutGuard(eventId, seats);
}

async function reserveSeats(eventId, seats) {
  validateSeatCount(seats);

  const updatedEvent = await eventRepository.reserveAvailableSeats(eventId, seats);
  if (updatedEvent) {
    return updatedEvent;
  }

  const existingEvent = await eventRepository.findEventById(eventId);
  if (!existingEvent) {
    throw new AppError(404, 'EVENT_NOT_FOUND', 'Event not found');
  }

  throw new AppError(409, 'INSUFFICIENT_SEATS', 'Not enough seats are available');
}

module.exports = {
  createEvent,
  getEvent,
  reserveSeatsNaively,
  reserveSeats
};

What should this file contain?

This reference keeps the unsafe service intact. The new safe service uses the conditional update before choosing the appropriate application error.

The controller translates a successful service result into an HTTP response. The new handler returns the updated event with a created status.

  • Return to src/features/events/event.controller.js in the Explorer sidebar.
  • Find the module.exports block below reserveSeatsNaively().
  • Replace that export block with the safe controller function plus updated exports by pasting this code:
async function reserveSeats(req, res) {
  const event = await eventService.reserveSeats(
    req.params.eventId,
    req.body.seats
  );

  res.status(201).json({
    message: 'Reservation accepted',
    data: event
  });
}

module.exports = {
  createEvent,
  getEvent,
  reserveSeatsNaively,
  reserveSeats
};

What does the controller add?

  • The handler passes the event ID plus requested seat count to the safe service.
  • A successful reservation returns 201 with the updated event document.
  • A rejected service promise enters the existing Express error flow.
  • Save src/features/events/event.controller.js.
  • Stop the running API in the server terminal by pressing Ctrl+C.
  • Restart the API with the safe controller loaded by running this command:
node index.js

What does this controller check prove?

The restart loads the controller through the existing router. Reaching the listening message confirms that the controller can find the new service function.

You should see the database connection log plus the listening message again. The safe controller now loads through the route stack.

The safe endpoint rejects one competing reservation by design. Its client expects JSON, so the application needs a small error handler before the final race can report both statuses.

  • Create src/middleware/error.middleware.js by entering the full path in the Explorer's New File... control.
  • Add the base JSON error handler by pasting this code:
function errorHandler(error, req, res, next) {
  // Delegate when another handler has already started the response.
  if (res.headersSent) {
    return next(error);
  }

  // Preserve known application errors and hide unexpected details.
  const statusCode = error.statusCode || 500;
  const code = error.code || 'INTERNAL_ERROR';
  const message = statusCode === 500
    ? 'Unexpected server error'
    : error.message;

  return res.status(statusCode).json({
    error: { code, message }
  });
}

module.exports = { errorHandler };

Why Add the Handler Now?

  • The four-argument function is recognized as Express error middleware.
  • Known AppError values keep their HTTP status, code, and message.
  • Unexpected failures return a generic 500 message instead of exposing internal details.
  • Save src/middleware/error.middleware.js.
  • Replace src/app.js with this application pipeline:
const express = require('express');
const eventRoutes = require('./features/events/event.routes');
const { errorHandler } = require('./middleware/error.middleware');

// Create the shared JSON application pipeline.
const app = express();
app.use(express.json());

app.get('/health', (req, res) => {
  // Keep the startup check available as features are added.
  res.json({ status: 'ok' });
});

// Mount feature routes before the final error handler.
app.use('/api/events', eventRoutes);
app.use(errorHandler);

module.exports = app;

How Does the Pipeline Change?

Feature routes run before the error handler. A rejected async reservation reaches errorHandler and returns JSON that the race client can parse.

  • Save src/app.js.
  • Stop the API process with Ctrl+C.
  • Restart the API with the JSON error handler by running:
node index.js

You should see the database connection log followed by the listening message. The application can now return JSON for the conflict that the final safe race creates.

Safe Request Pipeline Not Loading?

  • Check that error.middleware.js sits inside src/middleware.
  • Confirm that app.use(errorHandler) appears after the event routes.
  • Confirm that the controller calls eventService.reserveSeats and exports reserveSeats with the same capitalization.

Help me trace this loading error.

✔️ Awesome, I've got the controller!

The controller can now return a successful response from the guarded service path.

ⓧ I'd like to double check the full code

const eventService = require('./event.service');

async function createEvent(req, res) {
  const event = await eventService.createEvent(req.body.name, req.body.capacity);
  res.status(201).json({ data: event });
}

async function getEvent(req, res) {
  const event = await eventService.getEvent(req.params.eventId);
  res.json({ data: event });
}

async function reserveSeatsNaively(req, res) {
  const event = await eventService.reserveSeatsNaively(
    req.params.eventId,
    req.body.seats
  );

  res.status(201).json({
    message: 'Reservation accepted by the unsafe demo endpoint',
    data: event
  });
}

async function reserveSeats(req, res) {
  const event = await eventService.reserveSeats(
    req.params.eventId,
    req.body.seats
  );

  res.status(201).json({
    message: 'Reservation accepted',
    data: event
  });
}

module.exports = {
  createEvent,
  getEvent,
  reserveSeatsNaively,
  reserveSeats
};

What should this file contain?

This reference exposes both reservation handlers. The unsafe handler remains available for the before-and-after comparison.

The router gives the safe handler its own REST endpoint. The existing naive route stays available as evidence of the original race.

  • Return to src/features/events/event.routes.js in the Explorer sidebar.
  • Replace the file contents with the router that exposes both reservation paths by pasting this code:
const express = require('express');
const eventController = require('./event.controller');

const router = express.Router();

router.post('/', eventController.createEvent);
router.get('/:eventId', eventController.getEvent);
router.post('/:eventId/reservations/naive', eventController.reserveSeatsNaively);
router.post('/:eventId/reservations', eventController.reserveSeats);

module.exports = router;

How are the reservation paths different?

  • The /:eventId/reservations/naive route preserves the separate read-check-update flow.
  • The /:eventId/reservations route uses the guarded service path.
  • Save src/features/events/event.routes.js.
  • Stop the running API in the server terminal by pressing Ctrl+C.
  • Restart the API with both reservation endpoints mounted by running this command:
node index.js

What does this router check prove?

The restart loads the new route with its controller handler. The listening message confirms that the safe endpoint is mounted without an import error.

You should see the connection log plus the listening message. Leave this API process running for the safe race.

Router failing to load?

Confirm that the safe route references eventController.reserveSeats. Make sure the file still ends with module.exports = router.

Check that the safe route has no /naive suffix. Help me diagnose this router problem.

✔️ Awesome, I've got everything!

Both reservation endpoints are now mounted under /api/events.

ⓧ I'd like to double check the full code

const express = require('express');
const eventController = require('./event.controller');

const router = express.Router();

router.post('/', eventController.createEvent);
router.get('/:eventId', eventController.getEvent);
router.post('/:eventId/reservations/naive', eventController.reserveSeatsNaively);
router.post('/:eventId/reservations', eventController.reserveSeats);

module.exports = router;

What should this file contain?

This reference keeps event creation plus event lookup unchanged. It mounts the unsafe and safe reservation handlers on separate paths.

Run the safe race

The demonstration must keep the competing workload unchanged. Safe mode only changes the endpoint that receives the two simultaneous requests.

  • Return to scripts/race-demo.js in the Explorer sidebar.
  • Find const requestedMode = process.argv[2]; near the top of the file.
  • Update the mode selection beneath that line to match this code:
const requestedMode = process.argv[2];
const mode = ['single', 'naive'].includes(requestedMode) ? requestedMode : 'safe';
const reservationPath = mode === 'safe' ? 'reservations' : 'reservations/naive';

What does the mode selection do?

  • The single mode continues to use the unsafe endpoint for the original one-request demonstration.
  • The naive mode keeps the oversell demonstration available.
  • The safe mode selects the guarded reservation endpoint.
  • The existing Promise.all() call continues to launch the two requests together.
  • Save scripts/race-demo.js.

Before you run the safe race, predict how many of the two requests can reserve four of the five seats.

  • Switch back to the demo terminal from earlier.
  • Run the competing requests against the safe endpoint with this command:
node scripts/race-demo.js safe

What does this run test?

The script creates a fresh event with five seats. It then sends two four-seat reservation requests to the guarded endpoint at the same time.

You should see one 201 status plus one 409 status. The order can vary because the requests compete.

You should also see accepted seats 4 plus final availability 1. The conditional update has preserved the seat invariant.

Safe race not leaving one seat?

Confirm that safe mode selects reservations without the /naive suffix. Check that the API was restarted after you mounted the safe route.

Verify that the repository filter contains availableSeats: { $gte: seats } inside findOneAndUpdate(). Help me debug the safe race result.

✔️ Awesome, I've got everything!

That is the race closed. The same workload now accepts four seats without overselling the event.

ⓧ I'd like to double check the full code

const { loadEnvFile } = require('node:process');

loadEnvFile();

const port = Number(process.env.PORT) || 3000;
const baseUrl = `http://localhost:${port}`;
const requestedMode = process.argv[2];
const mode = ['single', 'naive'].includes(requestedMode) ? requestedMode : 'safe';
const reservationPath = mode === 'safe' ? 'reservations' : 'reservations/naive';

async function request(path, options = {}) {
  const response = await fetch(`${baseUrl}${path}`, options);
  const body = await response.json();
  return { status: response.status, body };
}

async function runRace() {
  const created = await request('/api/events', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      name: `Midnight Launch ${Date.now()}`,
      capacity: 5
    })
  });

  if (created.status !== 201) {
    throw new Error(`Could not create event: ${JSON.stringify(created.body)}`);
  }

  const eventId = created.body.data._id;
  const reserveFourSeats = () => request(
    `/api/events/${eventId}/${reservationPath}`,
    {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ seats: 4 })
    }
  );

  const results = mode === 'single'
    ? [await reserveFourSeats()]
    : await Promise.all([reserveFourSeats(), reserveFourSeats()]);

  const finalEvent = await request(`/api/events/${eventId}`);
  const statuses = results.map((result) => result.status);
  const acceptedSeats = results.filter((result) => result.status === 201).length * 4;

  console.log(`Mode: ${mode}`);
  console.log('Reservation statuses:', statuses);
  console.log('Accepted seats:', acceptedSeats);
  console.log('Final availability:', finalEvent.body.data.availableSeats);
}

runRace().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

What should this file contain?

This reference preserves the single and naive demonstrations. Safe mode sends the same competing requests to the guarded endpoint.

Your safe endpoint now survives competing reservations without making availability negative. Next, you will give each failure a stable JSON contract that callers can use.

Harden and Prove the API Contract

Your atomic MongoDB update now preserves the seat count when competing reservations arrive.

Correct storage behavior becomes dependable when callers also receive a stable HTTP error contract. This step adds strict validation and structured responses to your Express API.

In this step, get ready to:
  • Enforce strict service input rules.
  • Centralize structured JSON error responses.
  • Capture both concurrency outcomes as verification evidence.

How Should Callers Read the Error Contract?

Every error response keeps the same outer shape. The status and application code tell callers what kind of recovery makes sense.

  • 400 reports invalid input, schema validation failures, or an invalid event ID.
  • 404 reports an event or route that does not exist.
  • 409 reports a valid reservation request that conflicts with the remaining seats.
  • 500 reports an unexpected server failure without changing the JSON envelope.

This contract lets a client branch on a stable code instead of parsing a human-readable message.

Validate event and seat input

Service-layer validation protects every controller that calls the event service. Blank names plus invalid quantities now stop before they reach the database.

  • In event.service.js, replace the existing validateEventInput() function with this version:
function validateEventInput(name, capacity) {
  if (typeof name !== 'string' || name.trim() === '') {
    throw new AppError(400, 'INVALID_EVENT_NAME', 'Event name is required');
  }

  if (!Number.isInteger(capacity) || capacity < 1) {
    throw new AppError(400, 'INVALID_CAPACITY', 'Capacity must be a positive integer');
  }

  return {
    name: name.trim(),
    capacity
  };
}

What does this validation do?

  • The name check rejects missing values plus strings that become empty after trimming.
  • The capacity check requires a positive integer.
  • The returned object removes surrounding whitespace from a valid event name.
  • In event.service.js, replace the existing validateSeatCount() function with this version:
function validateSeatCount(seats) {
  if (!Number.isInteger(seats) || seats < 1) {
    throw new AppError(400, 'INVALID_SEAT_COUNT', 'Seats must be a positive integer');
  }
}

What does this validation do?

  • The integer check rejects decimals plus nonnumeric values.
  • The lower bound rejects zero plus negative seat quantities.
  • The AppError carries the status plus application code needed by the error middleware.
  • Save event.service.js.
  • Switch back to the API terminal from earlier.
  • Stop the running process with Ctrl+C.
  • Restart the API with the server command from Step 1.
  • Switch back to the demo terminal from earlier.
  • Confirm the validation changes preserve the safe reservation flow by running:
node scripts/race-demo.js safe

What does this check prove?

The demo sends two competing reservations through the existing atomic endpoint. A successful run confirms that the stricter helpers load without disrupting valid requests.

You should still see one 201 status plus one 409 status. Accepted seats remain 4 with final availability at 1.

Did the API stop after the edit?

Check the braces around both validation helpers. Confirm that each error message uses matching quotes.

Make sure AppError is still imported near the top of event.service.js. Help me troubleshoot my service validation changes.

✔️ Awesome, I've got everything!

Your validation helpers are saved. Valid reservations still reach the atomic update.

ⓧ I'd like to double check the full code

const { setTimeout: delay } = require('node:timers/promises');
const eventRepository = require('./event.repository');
const { AppError } = require('../../errors/AppError');

function validateEventInput(name, capacity) {
  if (typeof name !== 'string' || name.trim() === '') {
    throw new AppError(400, 'INVALID_EVENT_NAME', 'Event name is required');
  }

  if (!Number.isInteger(capacity) || capacity < 1) {
    throw new AppError(400, 'INVALID_CAPACITY', 'Capacity must be a positive integer');
  }

  return {
    name: name.trim(),
    capacity
  };
}

function validateSeatCount(seats) {
  if (!Number.isInteger(seats) || seats < 1) {
    throw new AppError(400, 'INVALID_SEAT_COUNT', 'Seats must be a positive integer');
  }
}

async function createEvent(name, capacity) {
  const validEvent = validateEventInput(name, capacity);

  return eventRepository.createEvent({
    ...validEvent,
    availableSeats: validEvent.capacity
  });
}

async function getEvent(eventId) {
  const event = await eventRepository.findEventById(eventId);

  if (!event) {
    throw new AppError(404, 'EVENT_NOT_FOUND', 'Event not found');
  }

  return event;
}

async function reserveSeatsNaively(eventId, seats) {
  validateSeatCount(seats);
  const event = await getEvent(eventId);

  if (event.availableSeats < seats) {
    throw new AppError(409, 'INSUFFICIENT_SEATS', 'Not enough seats are available');
  }

  await delay(250);
  return eventRepository.decrementSeatsWithoutGuard(eventId, seats);
}

async function reserveSeats(eventId, seats) {
  validateSeatCount(seats);

  const updatedEvent = await eventRepository.reserveAvailableSeats(eventId, seats);
  if (updatedEvent) {
    return updatedEvent;
  }

  const existingEvent = await eventRepository.findEventById(eventId);
  if (!existingEvent) {
    throw new AppError(404, 'EVENT_NOT_FOUND', 'Event not found');
  }

  throw new AppError(409, 'INSUFFICIENT_SEATS', 'Not enough seats are available');
}

module.exports = {
  createEvent,
  getEvent,
  reserveSeatsNaively,
  reserveSeats
};

What should you compare?

Check both validation helpers against your file. The reservation functions should remain unchanged.

Centralize the error contract

The base handler from the safe reservation step already keeps conflict responses in JSON. You will now extend it to normalize Mongoose validation and invalid-ID failures without changing the response shape.

const express = require('express');
const eventRoutes = require('./features/events/event.routes');
const { errorHandler } = require('./middleware/error.middleware');

const app = express();

app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.use('/api/events', eventRoutes);

app.use((req, res) => {
  res.status(404).json({
    error: {
      code: 'ROUTE_NOT_FOUND',
      message: 'Route not found'
    }
  });
});

app.use(errorHandler);

module.exports = app;

How Do Errors Reach One JSON Shape?

Express forwards errors from rejected async route handlers to the final error middleware. Every failure then passes through one response boundary.

  • Service errors arrive as AppError values with an HTTP status, application code, and message.
  • Mongoose ValidationError and CastError values enter named branches that map them to structured 400 responses.
  • Unknown failures reach the fallback branch. It returns 500 with INTERNAL_ERROR without exposing internal details.

Every branch returns the same outer shape: {"error":{"code":"...","message":"..."}}. Clients can handle errors consistently without parsing prose.

  • Save src/app.js.
  • Switch back to the API terminal from earlier.
  • Stop the running process with Ctrl+C.
  • Restart the API with the server command from Step 1.
  • Switch back to the demo terminal from earlier.
  • Confirm the mounted handler preserves the safe conflict response by running:
node scripts/race-demo.js safe

What does this check prove?

The safe demo reaches the mounted error handler when the second reservation conflicts. The script can still parse the response as JSON.

The safe result remains one accepted reservation plus one conflict. Final availability stays at 1.

Does the server fail during startup?

Confirm that error.middleware.js sits inside src/middleware. Check that the import path in src/app.js starts with ./middleware.

Make sure app.use(errorHandler) appears after the routes. Help me troubleshoot the error middleware import.

  • In error.middleware.js, add this validation-error branch above the statusCode declaration:
  if (error.name === 'ValidationError') {
    return res.status(400).json({
      error: {
        code: 'VALIDATION_ERROR',
        message: error.message
      }
    });
  }

Why handle validation errors separately?

Mongoose uses ValidationError for schema validation failures. This branch maps that failure to a predictable 400 response.

  • In error.middleware.js, add this invalid-ID branch below the validation-error branch:
  if (error.name === 'CastError') {
    return res.status(400).json({
      error: {
        code: 'INVALID_ID',
        message: 'The event ID is invalid'
      }
    });
  }

Why handle invalid IDs separately?

Mongoose raises CastError when a route value cannot become a valid event ID. The caller receives a specific INVALID_ID code plus a 400 status.

  • Save error.middleware.js.
  • Switch back to the API terminal from earlier.
  • Stop the running process with Ctrl+C.
  • Restart the API with the server command from Step 1.
  • Enter http://localhost:3000/api/events/not-a-valid-id in your browser address bar.

You should see {"error":{"code":"INVALID_ID","message":"The event ID is invalid"}}. This proves the invalid identifier reached your structured error contract.

Seeing a generic server response?

Confirm that both named error branches appear before the statusCode declaration. Check that the handler still has four parameters.

Restart the API after saving the middleware file. Help me diagnose my error response.

✔️ Awesome, I've got everything!

Your middleware now handles application errors plus Mongoose failures through one response shape.

ⓧ I'd like to double check the full code

function errorHandler(error, req, res, next) {
  if (res.headersSent) {
    return next(error);
  }

  if (error.name === 'ValidationError') {
    return res.status(400).json({
      error: {
        code: 'VALIDATION_ERROR',
        message: error.message
      }
    });
  }

  if (error.name === 'CastError') {
    return res.status(400).json({
      error: {
        code: 'INVALID_ID',
        message: 'The event ID is invalid'
      }
    });
  }

  const statusCode = error.statusCode || 500;
  const code = error.code || 'INTERNAL_ERROR';
  const message = statusCode === 500 ? 'Unexpected server error' : error.message;

  if (statusCode === 500) {
    console.error(error);
  }

  return res.status(statusCode).json({
    error: { code, message }
  });
}

module.exports = { errorHandler };

What should you compare?

Check the order of the two Mongoose branches. The generic application-error response should remain at the bottom.

✔️ Awesome, I've got everything!

Your application mounts the route fallback before the final error handler.

ⓧ I'd like to double check the full code

const express = require('express');
const eventRoutes = require('./features/events/event.routes');
const { errorHandler } = require('./middleware/error.middleware');

const app = express();

app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.use('/api/events', eventRoutes);

app.use((req, res) => {
  res.status(404).json({
    error: {
      code: 'ROUTE_NOT_FOUND',
      message: 'Route not found'
    }
  });
});

app.use(errorHandler);

module.exports = app;

What should you compare?

Check the import plus the order of the route fallback. The error handler must remain the final middleware registration.

Prove the final contract

The final demo pairs the concurrency result with an invalid reservation. One run now proves both the seat invariant plus the caller-facing validation contract.

  • In scripts/race-demo.js, add this safe-mode validation request below the final availability log:
  if (mode === 'safe') {
    const invalid = await request(`/api/events/${eventId}/reservations`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ seats: 0 })
    });

    console.log('Validation status:', invalid.status);
  }

What does this addition prove?

  • The request targets the same safe reservation endpoint used by the race.
  • The body deliberately sends 0 seats so validateSeatCount() rejects it.
  • The final log records the returned status as contract evidence.
  • Save scripts/race-demo.js.

✔️ Awesome, I've got everything!

Your safe demo now checks concurrency plus invalid input in one run.

ⓧ I'd like to double check the full code

const { loadEnvFile } = require('node:process');

loadEnvFile();

const port = Number(process.env.PORT) || 3000;
const baseUrl = `http://localhost:${port}`;
const requestedMode = process.argv[2];
const mode = ['single', 'naive'].includes(requestedMode) ? requestedMode : 'safe';
const reservationPath = mode === 'safe' ? 'reservations' : 'reservations/naive';

async function request(path, options = {}) {
  const response = await fetch(`${baseUrl}${path}`, options);
  const body = await response.json();
  return { status: response.status, body };
}

async function runRace() {
  const created = await request('/api/events', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      name: `Midnight Launch ${Date.now()}`,
      capacity: 5
    })
  });

  if (created.status !== 201) {
    throw new Error(`Could not create event: ${JSON.stringify(created.body)}`);
  }

  const eventId = created.body.data._id;
  const reserveFourSeats = () => request(
    `/api/events/${eventId}/${reservationPath}`,
    {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ seats: 4 })
    }
  );

  const results = mode === 'single'
    ? [await reserveFourSeats()]
    : await Promise.all([reserveFourSeats(), reserveFourSeats()]);

  const finalEvent = await request(`/api/events/${eventId}`);
  const statuses = results.map((result) => result.status);
  const acceptedSeats = results.filter((result) => result.status === 201).length * 4;

  console.log(`Mode: ${mode}`);
  console.log('Reservation statuses:', statuses);
  console.log('Accepted seats:', acceptedSeats);
  console.log('Final availability:', finalEvent.body.data.availableSeats);

  if (mode === 'safe') {
    const invalid = await request(`/api/events/${eventId}/reservations`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ seats: 0 })
    });

    console.log('Validation status:', invalid.status);
  }
}

runRace().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

What should you compare?

Check the final safe-mode block below the four race logs. The rest of the demo should remain unchanged.

Before you run the final demo, which request do you expect the API to reject after the race finishes?

  • Run the completed safe demonstration in the demo terminal:
node scripts/race-demo.js safe

What does the final command test?

The command runs the competing safe reservations first. It then sends the zero-seat reservation through the same endpoint.

You should see one 201 plus one 409. You should also see Validation status: 400 after final availability remains 1.

That is the full proof: your API preserves capacity under competition. It also rejects invalid input through a predictable contract.

  • Capture a screenshot of the completed safe output.
  • Return to the unsafe output from Step 3 in your terminal history.
  • Capture a screenshot of the unsafe output showing two accepted requests plus negative availability.

Missing the validation status?

Confirm that the safe-mode block sits inside runRace() below the availability log. Make sure the API was restarted after the middleware changes.

Check that the request body sends seats: 0 to the safe reservations path. Help me troubleshoot the final contract demo.

You now have a repeatable before-and-after demonstration of the race condition. Your final API also gives callers stable validation plus conflict responses.

Secret mission

Make Reservation Retries Idempotent

Add a reservation key to every safe request. You will prove that retrying one successful reservation returns the original result without consuming another seat.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the local API available, pause its process, or remove the project data. This project adds no paid resource, and an Atlas Free cluster has no ongoing project cost.

Resources you used:

  • The running local Node.js API process for ticket-race-api.
  • A local ticket-race-api folder containing the project files and installed dependencies.
  • An events collection inside the ticket_race_demo database.
  • The demonstration documents created by the single, unsafe, safe, validation, and retry runs.

Keep everything running

No action is needed while you are actively demonstrating the API. The local process and Atlas documents remain available for another concurrency run.

  • Keep the local API process running for live demonstrations.
  • Keep the ticket-race-api folder on your Desktop.
  • Retain the events collection as evidence of the unsafe, safe, and retry results.

Pause - I'll come back to this later

Stopping the local process frees its runtime resources. The project folder and Atlas documents remain ready for your next session.

  • Switch to the terminal running the API.
  • Press Ctrl+C to stop the process.

Your terminal prompt returns. The health endpoint stops responding until you start the API again.

Delete - I don't want to use this again

Deleting the project removes its local files and demo data. These actions leave your existing Atlas deployment and other databases untouched.

  • Switch to the terminal running the API.
  • Press Ctrl+C to stop the process.

Remove the local project folder using the instructions for your operating system.

macOS

  • Open Finder from the Dock.
  • Select Desktop in the sidebar.
  • Select the ticket-race-api folder.
  • Move the folder to the Trash.
  • Confirm that ticket-race-api no longer appears on the Desktop.

Windows

  • Open File Explorer from the taskbar.
  • Select Desktop in the sidebar.
  • Select the ticket-race-api folder.
  • Press Delete to remove the folder.
  • Confirm that ticket-race-api no longer appears on the Desktop.

Remove the project collection from MongoDB Atlas.

  • Open your Atlas project.
  • Click Data Explorer under Database in the left sidebar.
  • Expand the ticket_race_demo database in the Connections sidebar.
  • Open the options menu beside the events collection.
  • Click Drop Collection.
  • Enter events in the confirmation field.
  • Click Drop Collection to delete the collection, its documents, and its indexes.
  • Refresh Data Explorer and confirm that the events collection is gone.

Nice Work!

Nice Work!

You did it! Your Express ticket API now proves that a MongoDB atomic update prevents overbooking under competing requests.

You've learned how to:

  • Run a repeatable concurrency demo that shows two competing reservations overselling an event through the unsafe endpoint.
  • Protect the seat invariant with a conditional atomic update. Observe one 201 response. Observe one 409 response. Confirm that one seat remains.
  • Organize the safe reservation path across route, controller, service, and repository layers. Return structured JSON errors for invalid input, missing events, and insufficient seats.
  • Secret Mission: Make reservation retries idempotent by storing each requestKey with its reservation. A replay now returns 200 without consuming seats again.

Ready to quiz yourself?