Node.jsBackendFull-Stack

Database Relationships & Joins in Node.js

TT
TopicTrick Team
Database Relationships & Joins in Node.js

Database Relationships & Joins in Node.js

Every non-trivial application has related data. Users write posts. Posts have comments. Products belong to categories. Orders contain line items. How you model and query those relationships determines whether your application stays fast and correct as data grows.

This module covers relationships in both MongoDB (Mongoose) and PostgreSQL (Prisma), because most Node.js developers work with both at some point. The patterns are different but the underlying concepts — one-to-one, one-to-many, many-to-many — are universal.

This is Module 16 of the Node.js Full‑Stack Developer course.


The Three Relationship Types

TypeExampleSQL mechanismMongoDB mechanism
One-to-oneUser ↔ ProfileForeign key + UNIQUEEmbedded doc or $ref
One-to-manyUser → PostsForeign keyArray of refs or embedded
Many-to-manyPosts ↔ TagsJunction tableArray of refs on both sides

Relationships in PostgreSQL with Prisma

One-to-One

A user has exactly one profile. The profile cannot exist without a user.

prisma
model User {
  id      String   @id @default(cuid())
  email   String   @unique
  profile Profile?
}

model Profile {
  id     String  @id @default(cuid())
  bio    String?
  avatar String?

  userId String @unique          // UNIQUE ensures one-to-one
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
}
js
// Create user with profile
const user = await prisma.user.create({
  data: {
    email: 'alice@example.com',
    profile: {
      create: { bio: 'Full-stack developer' },
    },
  },
  include: { profile: true },
});

// Fetch user with profile
const user = await prisma.user.findUnique({
  where: { id },
  include: { profile: true },
});

// Update profile through user
await prisma.user.update({
  where: { id },
  data: {
    profile: {
      upsert: {
        create: { bio: 'New bio' },
        update: { bio: 'New bio' },
      },
    },
  },
});

One-to-Many

A user writes many posts. A post belongs to exactly one user.

prisma
model User {
  id    String @id @default(cuid())
  email String @unique
  posts Post[]
}

model Post {
  id       String @id @default(cuid())
  title    String
  authorId String
  author   User   @relation(fields: [authorId], references: [id])

  @@index([authorId])
}
js
// Create a post for an existing user
const post = await prisma.post.create({
  data: {
    title: 'Hello World',
    body: '...',
    authorId: userId,
  },
});

// Fetch user with their posts (eager load)
const user = await prisma.user.findUnique({
  where: { id: userId },
  include: {
    posts: {
      where: { published: true },
      orderBy: { createdAt: 'desc' },
      take: 10,
    },
  },
});

// Fetch posts with their author
const posts = await prisma.post.findMany({
  where: { published: true },
  include: {
    author: { select: { id: true, name: true } },
  },
});

Many-to-Many (Implicit)

A post has many tags. A tag belongs to many posts. No extra data on the relationship.

prisma
model Post {
  id   String @id @default(cuid())
  tags Tag[]  @relation("PostTags")
}

model Tag {
  id    String @id @default(cuid())
  name  String @unique
  posts Post[] @relation("PostTags")
}

Prisma creates the hidden _PostTags junction table automatically.

js
// Create post with tags (connect or create)
const post = await prisma.post.create({
  data: {
    title: 'My Post',
    body: '...',
    authorId: userId,
    tags: {
      connectOrCreate: [
        { where: { name: 'nodejs' }, create: { name: 'nodejs' } },
        { where: { name: 'express' }, create: { name: 'express' } },
      ],
    },
  },
  include: { tags: true },
});

// Add a tag to an existing post
await prisma.post.update({
  where: { id: postId },
  data: { tags: { connect: { name: 'javascript' } } },
});

// Remove a tag from a post
await prisma.post.update({
  where: { id: postId },
  data: { tags: { disconnect: { name: 'javascript' } } },
});

// Find all posts with a given tag
const posts = await prisma.post.findMany({
  where: { tags: { some: { name: 'nodejs' } } },
  include: { tags: true },
});

Many-to-Many (Explicit — with extra data)

When the relationship itself carries data (e.g., a user is a member of a team with a specific role):

prisma
model User {
  id          String       @id @default(cuid())
  memberships Membership[]
}

model Team {
  id          String       @id @default(cuid())
  name        String
  memberships Membership[]
}

model Membership {
  id        String   @id @default(cuid())
  role      String   @default("MEMBER")
  joinedAt  DateTime @default(now())

  userId String
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)

  teamId String
  team   Team   @relation(fields: [teamId], references: [id], onDelete: Cascade)

  @@unique([userId, teamId])   // a user can only be in a team once
  @@index([teamId])
}
js
// Add a user to a team as admin
await prisma.membership.create({
  data: { userId, teamId, role: 'ADMIN' },
});

// Get all teams a user belongs to
const memberships = await prisma.membership.findMany({
  where: { userId },
  include: {
    team: { select: { id: true, name: true } },
  },
});

// Get all members of a team
const members = await prisma.membership.findMany({
  where: { teamId },
  include: {
    user: { select: { id: true, name: true, email: true } },
  },
});

Raw SQL Joins with Prisma

For complex queries, use $queryRaw with explicit JOINs:

js
// Get posts with author name and tag count
const posts = await prisma.$queryRaw`
  SELECT
    p.id,
    p.title,
    p."createdAt",
    u.name AS "authorName",
    COUNT(pt."B") AS "tagCount"
  FROM "Post" p
  INNER JOIN "User" u ON u.id = p."authorId"
  LEFT JOIN "_PostTags" pt ON pt."A" = p.id
  WHERE p.published = true
  GROUP BY p.id, u.name
  ORDER BY p."createdAt" DESC
  LIMIT ${take} OFFSET ${skip}
`;

JOIN Types

sql
-- INNER JOIN — only posts that have an author (should always match due to FK)
SELECT p.title, u.name
FROM "Post" p
INNER JOIN "User" u ON u.id = p."authorId";

-- LEFT JOIN — all users, even those with no posts
SELECT u.name, COUNT(p.id) AS post_count
FROM "User" u
LEFT JOIN "Post" p ON p."authorId" = u.id
GROUP BY u.id, u.name;

-- Users who have never written a post
SELECT u.name
FROM "User" u
LEFT JOIN "Post" p ON p."authorId" = u.id
WHERE p.id IS NULL;

Relationships in MongoDB with Mongoose

MongoDB is schema-less at the database level, so you choose your relationship strategy in application code.

Embedding (Denormalization)

Embed when the data is always read together and owned exclusively by the parent:

js
const orderSchema = new mongoose.Schema({
  userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  status: { type: String, enum: ['pending', 'paid', 'shipped'], default: 'pending' },
  // Embed line items — they belong entirely to this order
  items: [
    {
      productId: mongoose.Schema.Types.ObjectId,
      name: String,       // denormalized for read speed
      price: Number,      // snapshot at time of order
      quantity: Number,
    },
  ],
  total: Number,
}, { timestamps: true });

The name and price are intentionally duplicated from the Product document. This is a snapshot — if a product's price changes tomorrow, this order still reflects what the customer paid.

Referencing (Normalization)

Reference when data is shared across documents or queried independently:

js
const postSchema = new mongoose.Schema({
  title: String,
  body: String,
  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true,
  },
  tags: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Tag' }],
}, { timestamps: true });

Populate (Eager Loading References)

js
// Basic populate
const posts = await Post
  .find({ published: true })
  .populate('author', 'name email')      // select specific fields
  .populate('tags', 'name')
  .lean();

// Nested populate
const post = await Post.findById(id)
  .populate({
    path: 'comments',
    populate: {
      path: 'author',
      select: 'name avatar',
    },
    options: { sort: { createdAt: -1 }, limit: 10 },
  })
  .lean();

// Conditional populate
const user = await User.findById(id)
  .populate({
    path: 'posts',
    match: { published: true },    // only populate published posts
    select: 'title createdAt',
    options: { limit: 5 },
  })
  .lean();

MongoDB Aggregation — $lookup (JOIN equivalent)

For complex joins in MongoDB, use the aggregation pipeline's $lookup:

js
// Posts with author details (equivalent to SQL JOIN)
const posts = await Post.aggregate([
  { $match: { published: true } },
  {
    $lookup: {
      from: 'users',          // collection name (lowercase plural)
      localField: 'author',   // field in Post
      foreignField: '_id',    // field in User
      as: 'authorDetails',
      pipeline: [
        { $project: { name: 1, email: 1, _id: 1 } },
      ],
    },
  },
  { $unwind: '$authorDetails' },   // flatten array to object
  {
    $project: {
      title: 1,
      createdAt: 1,
      'authorDetails.name': 1,
      'authorDetails.email': 1,
    },
  },
  { $sort: { createdAt: -1 } },
  { $skip: skip },
  { $limit: limit },
]);

The N+1 Query Problem

The most common performance mistake with relationships:

js
// ❌ N+1 — fetches posts then makes one DB call per post for the author
const posts = await Post.find();
for (const post of posts) {
  post.author = await User.findById(post.author);  // N extra queries!
}

// ✅ One query — populate fetches all authors in a single additional query
const posts = await Post.find().populate('author', 'name email').lean();
js
// ❌ N+1 in Prisma — manually fetching related data in a loop
const posts = await prisma.post.findMany();
for (const post of posts) {
  post.author = await prisma.user.findUnique({ where: { id: post.authorId } });
}

// ✅ Single query — Prisma's include resolves all relations efficiently
const posts = await prisma.post.findMany({
  include: { author: { select: { id: true, name: true } } },
});

Detecting N+1 in Development

Enable Prisma query logging to see what SQL is actually running:

js
const prisma = new PrismaClient({
  log: ['query'],
});

For Mongoose, enable debug mode:

js
mongoose.set('debug', true);

If you see the same query repeated N times with different IDs, you have an N+1 problem.


Selecting Only What You Need

Always project to the minimum set of fields required. Fetching unused data wastes bandwidth and memory:

js
// Prisma — select only needed fields
const users = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    email: true,
    // NOT: password, role, createdAt, updatedAt
  },
});

// Mongoose — project fields
const users = await User.find().select('name email').lean();

For deeply nested includes, always add select at every level:

js
const post = await prisma.post.findUnique({
  where: { id },
  include: {
    author: {
      select: { id: true, name: true },     // ← don't load password, role, etc.
    },
    tags: {
      select: { name: true },               // ← only the tag name
    },
    _count: {
      select: { comments: true },           // ← just the count, not the data
    },
  },
});

Cascading Deletes

When a parent is deleted, related records should be handled explicitly:

prisma
// Prisma — cascade delete profile when user is deleted
model Profile {
  userId String @unique
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
}

// Restrict — prevent deleting a user who has posts
model Post {
  authorId String
  author   User   @relation(fields: [authorId], references: [id], onDelete: Restrict)
}

// SetNull — set authorId to null when user is deleted
model Post {
  authorId String?
  author   User?  @relation(fields: [authorId], references: [id], onDelete: SetNull)
}

In Mongoose, cascade deletes must be handled manually with middleware:

js
// Delete all posts when a user is deleted
userSchema.pre('findOneAndDelete', async function (next) {
  const userId = this.getQuery()['_id'];
  await Post.deleteMany({ author: userId });
  next();
});

Node.js Full‑Stack Course — Module 16 of 32

You now understand how to model and query relationships in both SQL and MongoDB. Continue to Module 17 to add Redis caching and dramatically improve API performance.


    Summary

    Relationships are the backbone of any data model:

    • One-to-one: unique foreign key; in Prisma add @unique on the FK field; in Mongoose embed or reference
    • One-to-many: foreign key on the "many" side; always index it; use include or populate to avoid N+1
    • Many-to-many: junction table in SQL (implicit or explicit in Prisma); array of refs in MongoDB
    • The N+1 problem is always caused by lazy-loading inside a loop — fix it with include (Prisma), populate (Mongoose), or a JOIN
    • Always select only the fields you need — never load the full document when a projection suffices
    • Decide embed vs reference in MongoDB based on access pattern: always-together → embed, independently-queried → reference
    • Cascading deletes must be defined explicitly — both Prisma (onDelete) and Mongoose (pre-hooks) require deliberate configuration

    Continue to Module 17: Caching with Redis →