Menu

Embedded Documents

Embedded documents are one of MongoDB’s most powerful features, allowing you to store related data within a single document. Adonis ODM provides first-class support for embedded documents with a familiar, type-safe API.

What are Embedded Documents?

Embedded documents are documents stored inside other documents. They’re perfect for:

  • One-to-few relationships (e.g., user addresses, order items)
  • Data that’s always accessed together (e.g., user profile with preferences)
  • Avoiding joins for better performance
  • Maintaining data locality for atomic operations

Defining Embedded Documents

Basic Embedded Document

import { BaseModel, column, embedded } from 'adonis-odm'
import { DateTime } from 'luxon'

// Define the embedded document schema
class Address {
  @column()
  declare street: string

  @column()
  declare city: string

  @column()
  declare state: string

  @column()
  declare zipCode: string

  @column()
  declare country: string
}

// Use in the main model
export default class User extends BaseModel {
  @column({ isPrimary: true })
  declare _id: string

  @column()
  declare name: string

  @column()
  declare email: string

  // Single embedded document
  @embedded(Address)
  declare address: Address

  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime
}

Array of Embedded Documents

class OrderItem {
  @column()
  declare productId: string

  @column()
  declare name: string

  @column()
  declare quantity: number

  @column()
  declare price: number

  @column()
  declare total: number
}

export default class Order extends BaseModel {
  @column({ isPrimary: true })
  declare _id: string

  @column()
  declare customerId: string

  // Array of embedded documents
  @embedded([OrderItem])
  declare items: OrderItem[]

  @column()
  declare totalAmount: number

  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime
}

Nested Embedded Documents

class ContactInfo {
  @column()
  declare email: string

  @column()
  declare phone: string
}

class Address {
  @column()
  declare street: string

  @column()
  declare city: string

  @column()
  declare state: string

  @column()
  declare zipCode: string

  // Nested embedded document
  @embedded(ContactInfo)
  declare contact: ContactInfo
}

export default class Company extends BaseModel {
  @column({ isPrimary: true })
  declare _id: string

  @column()
  declare name: string

  @embedded(Address)
  declare headquarters: Address

  @embedded([Address])
  declare branches: Address[]
}

Working with Embedded Documents

Creating Documents with Embedded Data

// Create user with address
const user = await User.create({
  name: 'John Doe',
  email: '[email protected]',
  address: {
    street: '123 Main St',
    city: 'New York',
    state: 'NY',
    zipCode: '10001',
    country: 'USA'
  }
})

// Create order with items
const order = await Order.create({
  customerId: 'customer123',
  items: [
    {
      productId: 'prod1',
      name: 'Laptop',
      quantity: 1,
      price: 999.99,
      total: 999.99
    },
    {
      productId: 'prod2',
      name: 'Mouse',
      quantity: 2,
      price: 29.99,
      total: 59.98
    }
  ],
  totalAmount: 1059.97
})

Accessing Embedded Data

// Access embedded document properties
const user = await User.findOrFail('user123')
console.log(user.address.street)
console.log(user.address.city)

// Access array of embedded documents
const order = await Order.findOrFail('order123')
for (const item of order.items) {
  console.log(`${item.name}: ${item.quantity} x $${item.price}`)
}

Updating Embedded Documents

// Update entire embedded document
const user = await User.findOrFail('user123')
user.address = {
  street: '456 Oak Ave',
  city: 'Los Angeles',
  state: 'CA',
  zipCode: '90210',
  country: 'USA'
}
await user.save()

// Update specific fields
const user = await User.findOrFail('user123')
user.address.street = '789 Pine St'
user.address.zipCode = '10002'
await user.save()

// Update array items
const order = await Order.findOrFail('order123')
order.items[0].quantity = 2
order.items[0].total = order.items[0].price * 2
await order.save()

Querying Embedded Documents

Basic Queries

// Query by embedded document field
const users = await User.query()
  .where('address.city', 'New York')
  .all()

// Query by nested field
const companies = await Company.query()
  .where('headquarters.contact.email', 'like', '%@company.com')
  .all()

// Multiple embedded field conditions
const users = await User.query()
  .where('address.state', 'CA')
  .where('address.city', 'Los Angeles')
  .all()

Array Queries

// Query array elements
const orders = await Order.query()
  .where('items.productId', 'prod123')
  .all()

// Query array with conditions
const orders = await Order.query()
  .where('items', 'elemMatch', {
    quantity: { $gte: 5 },
    price: { $lte: 100 }
  })
  .all()

// Query array size
const orders = await Order.query()
  .where('items', 'size', 3) // Orders with exactly 3 items
  .all()

Advanced Embedded Queries

// Using MongoDB operators
const users = await User.query()
  .where('address.zipCode', 'in', ['10001', '10002', '10003'])
  .all()

// Existence checks
const users = await User.query()
  .whereExists('address.phone')
  .all()

// Regular expressions
const users = await User.query()
  .where('address.zipCode', 'regex', /^100/)
  .all()

Embedded Query Builder

Using the Embedded Query Builder

// Query embedded documents with dedicated builder
const orders = await Order.query()
  .whereEmbedded('items', (query) => {
    query
      .where('price', '>', 100)
      .where('quantity', '>=', 2)
  })
  .all()

// Complex embedded queries
const companies = await Company.query()
  .whereEmbedded('branches', (query) => {
    query
      .where('city', 'San Francisco')
      .whereEmbedded('contact', (contactQuery) => {
        contactQuery.where('email', 'like', '%@branch.com')
      })
  })
  .all()

Aggregation with Embedded Documents

// Count embedded array elements
const stats = await Order.query()
  .aggregate([
    {
      $project: {
        customerId: 1,
        itemCount: { $size: '$items' },
        totalAmount: 1
      }
    },
    {
      $group: {
        _id: '$customerId',
        orderCount: { $sum: 1 },
        avgItemsPerOrder: { $avg: '$itemCount' },
        totalSpent: { $sum: '$totalAmount' }
      }
    }
  ])

// Unwind array for analysis
const itemStats = await Order.query()
  .aggregate([
    { $unwind: '$items' },
    {
      $group: {
        _id: '$items.productId',
        totalQuantity: { $sum: '$items.quantity' },
        totalRevenue: { $sum: '$items.total' },
        orderCount: { $sum: 1 }
      }
    },
    { $sort: { totalRevenue: -1 } }
  ])

Validation and Constraints

Embedded Document Validation

import { rules } from '@adonisjs/validator'

class Address {
  @column({
    validate: [
      rules.required(),
      rules.string(),
      rules.maxLength(100)
    ]
  })
  declare street: string

  @column({
    validate: [
      rules.required(),
      rules.string(),
      rules.maxLength(50)
    ]
  })
  declare city: string

  @column({
    validate: [
      rules.required(),
      rules.regex(/^\d{5}(-\d{4})?$/) // US ZIP code format
    ]
  })
  declare zipCode: string
}

Array Validation

class OrderItem {
  @column({
    validate: [
      rules.required(),
      rules.string()
    ]
  })
  declare productId: string

  @column({
    validate: [
      rules.required(),
      rules.number(),
      rules.range(1, 1000)
    ]
  })
  declare quantity: number

  @column({
    validate: [
      rules.required(),
      rules.number(),
      rules.range(0.01, 10000)
    ]
  })
  declare price: number
}

export default class Order extends BaseModel {
  @embedded([OrderItem], {
    validate: [
      rules.array(),
      rules.minLength(1),
      rules.maxLength(50)
    ]
  })
  declare items: OrderItem[]
}

Performance Considerations

Best Practices

// Index embedded fields for better query performance
export default class User extends BaseModel {
  static get indexes() {
    return [
      { 'address.city': 1 },
      { 'address.state': 1, 'address.city': 1 },
      { 'address.zipCode': 1 }
    ]
  }

  @embedded(Address)
  declare address: Address
}

// Limit embedded array size
export default class Order extends BaseModel {
  @embedded([OrderItem], {
    maxLength: 100 // Prevent documents from becoming too large
  })
  declare items: OrderItem[]
}

Projection for Large Embedded Arrays

// Select only needed embedded fields
const orders = await Order.query()
  .select('customerId', 'totalAmount', 'items.name', 'items.quantity')
  .where('customerId', 'customer123')
  .all()

// Limit array elements in results
const orders = await Order.query()
  .project({
    customerId: 1,
    totalAmount: 1,
    items: { $slice: 5 } // Only first 5 items
  })
  .all()

Migration and Schema Evolution

Adding New Embedded Fields

// Migration to add new embedded field
export default class AddPhoneToAddress extends BaseMigration {
  public async up() {
    // Add phone field to existing address embedded documents
    await this.db.collection('users').updateMany(
      { 'address': { $exists: true } },
      { $set: { 'address.phone': null } }
    )
  }

  public async down() {
    // Remove phone field
    await this.db.collection('users').updateMany(
      {},
      { $unset: { 'address.phone': '' } }
    )
  }
}

Restructuring Embedded Documents

// Migration to restructure embedded data
export default class RestructureOrderItems extends BaseMigration {
  public async up() {
    const orders = await this.db.collection('orders').find({}).toArray()
    
    for (const order of orders) {
      const updatedItems = order.items.map(item => ({
        ...item,
        metadata: {
          addedAt: new Date(),
          source: 'migration'
        }
      }))
      
      await this.db.collection('orders').updateOne(
        { _id: order._id },
        { $set: { items: updatedItems } }
      )
    }
  }
}

Next Steps