Aggregates
Aggregates cluster entities and value objects with one root that enforces invariants transactionally — the consistency boundary for writes in DDD.
Introduction
Aggregates cluster entities and value objects with one root that enforces invariants transactionally — the consistency boundary for writes in DDD. Airbnb booking aggregates protect availability, pricing, and cancellation rules atomically without locking half the listings database.
Real production story
Airbnb introduced instant book without a clear aggregate boundary — listing availability, pricing rules, and reservation state updated in three services without a single invariant owner. Double-bookings spiked during high-demand events: two guests confirmed for one night because availability decrements raced. The fix redesigned around a ListingReservation aggregate root: all mutations through BookListing command; optimistic concurrency on version; domain events emitted after commit. Double-bookings dropped to statistical noise; support refunds from overlap fell 95%.
Business problem
Airbnb marketplace invariants span listing, calendar, price, and guest rules. Violating aggregate boundaries creates guest trust incidents and host payout disputes — expensive to unwind.
- Trust: Double-booking destroys host and guest confidence — social media amplifies damage.
- Financial liability: Wrong cancellation policy application triggers refund classes and chargebacks.
- Scale: Fine-grained locking on listing row does not scale globally — need correct boundary size.
Architecture overview
Aggregate root is the only entry point for mutations. External objects hold references to root ID, not internal entities. Size aggregates for transactional consistency needs — small enough to perform, large enough for invariants.
- Design rules: One transaction = one aggregate; invariant enforcement inside root.
- Size heuristic: If two entities always change together, same aggregate; if independently, split.
- Concurrency: Optimistic versioning on root; reject stale commands with conflict.
- Events: Raise domain events from aggregate after invariants satisfied — before persistence.
Architecture motivation
Aggregates define transaction boundaries. One aggregate instance modified per transaction; references to other aggregates by ID only; eventual consistency across aggregates via events.
- Force: Concurrent booking attempts on popular listings during events.
- Constraint: Cannot distributed-lock entire listing catalog — partition by aggregate ID.
- Outcome: Invariants enforced in domain layer; infrastructure is persistence detail.
Internal architecture
Airbnb ListingReservation aggregate — root owns calendar slots and pricing snapshot:
- Listing details fetched for read — not mutated through Reservation aggregate.
- Cross-aggregate: Payment aggregate referenced by PaymentId only.
ListingReservation (aggregate root)├── ReservationId├── ListingId (ref — not embedded Listing entity)├── GuestId (ref)├── DateRange (value object)├── NightlyPriceSnapshot (value object — frozen at book)├── Status (enum: Pending · Confirmed · Cancelled)└── CalendarHold (entity — internal, not referenced externally)Rules enforced in root:· no overlapping confirmed holds· cancellation policy applied atomically· instant-book vs request-to-book invariants
Data flow
BookListing command flow: load aggregate by ListingId+dates key → check invariants → apply state change → persist with version check → publish ReservationConfirmed event → payment saga listens asynchronously.
- Write: Single DB transaction on aggregate row + calendar hold rows.
- Read: Query side may denormalize availability — rebuilt from events.
- Conflict: Version mismatch → 409 to client → user refreshes availability.
System design diagram
Two diagrams show the Aggregates topology and the primary request/event path used in production at scale.
Production code example
ListingReservation aggregate — TypeScript domain model with invariant enforcement:
- Factory methods encode commands — no public setters on aggregate.
- Version passed to repo.save enables optimistic concurrency on hot listings.
export class ListingReservation {private constructor(readonly id: ReservationId,readonly listingId: ListingId,private guestId: GuestId,private range: DateRange,private status: ReservationStatus,private version: number,private holds: CalendarHold[]) {}static book(cmd: BookListingCommand): DomainEvent[] {const agg = repo.load(cmd.listingId, cmd.range);if (agg.holdsOverlap(cmd.range)) {throw new AvailabilityConflictError(cmd.listingId, cmd.range);}agg.applyHold(cmd.guestId, cmd.range, cmd.priceSnapshot);agg.status = ReservationStatus.Confirmed;agg.version += 1;const events = [new ReservationConfirmed(agg.id, agg.listingId, cmd.guestId)];repo.save(agg, agg.version - 1); // optimistic checkreturn events;}cancel(policy: CancellationPolicy, actor: GuestId): DomainEvent[] {if (this.guestId.value !== actor.value) throw new UnauthorizedCancel();const refund = policy.computeRefund(this.range, this.priceSnapshot);this.status = ReservationStatus.Cancelled;this.version += 1;return [new ReservationCancelled(this.id, refund)];}}
Enterprise case study
Airbnb instant book aggregate redesign: Distributed updates caused double-booking during peak demand.
- Before: Three services update calendar; 0.3% double-book rate on hot listings.
- Decision: ListingReservation aggregate, optimistic locking, outbox events, saga for payment.
- After: Double-book near zero; book path p99 120ms; support refund class down 95%.
Trade-offs
- Large vs small aggregate: Large = bigger lock scope; small = more eventual consistency complexity.
- Sync vs async to payment: Confirm booking before payment risks unpaid holds — timeout saga releases hold.
- Global vs per-listing: One aggregate per listing-night vs per reservation — choose by contention pattern.
- ORM temptation: Lazy-load entire listing graph — breaks boundary; explicit load root only.
Security considerations
Aggregate commands authorize at root: Guest cannot cancel another guest's reservation — check GuestId in root method.
- Command validation: All inputs validated before state change — no anemic setters.
- Audit: Domain events capture who/when for dispute resolution.
- Rate limit: Book command rate limited per guest — anti-scraping at application layer.
Scalability analysis
Aggregate design affects hot listing scale: Beyoncé-adjacent property gets thousands of concurrent books — root must be narrow and fast.
- Hot aggregate: Shard by listingId; short transactions; no external calls inside transaction.
- Read scaling: CQRS projection for search availability — not aggregate query for browse.
- Event volume: ReservationConfirmed fan-out async — do not block commit on notifications.
Failure scenarios
Aggregate boundary failures: Dual aggregate update in one request; external HTTP inside transaction; missing version check.
- Double-book: Two roots for same listing — merge to one root per bookable window.
- Orphan hold: Crash after persist before event — outbox ensures downstream release.
- Stale read book: User books from stale cache — version conflict returns clear retry UX.
Staff engineer insights
- If your transaction touches two aggregate roots, your aggregate boundaries are wrong — redesign before adding distributed transactions.
- Aggregates should fit in one engineer's head — more than ~3 internal entities warrants workshop scrutiny.
- External calls inside aggregate methods are the top cause of slow roots and deadlocks — raise events instead.
Interview questions
Interview Prep
Practice concise answers, then expand each card for the explanation.
1AdvancedQuestionHow do you choose aggregate boundaries for an e-commerce order?+
Answer
Follow-up
2AdvancedQuestionTwo aggregates must stay consistent immediately. Options?+
Answer
Follow-up
3AdvancedQuestionAggregate is too contended at scale. What now?+
Answer
Follow-up
Architecture review questions
- Is there exactly one aggregate root per consistency cluster?
- Are cross-aggregate references by ID only?
- Are invariants enforced inside root methods — not in controllers?
- Is one aggregate modified per transaction?
- Is optimistic or pessimistic concurrency defined for hot roots?
- Are domain events raised from aggregate after invariants pass?
Summary
Aggregates at Airbnb protect booking invariants atomically through a ListingReservation root with optimistic concurrency and domain events — eliminating double-bookings that distributed partial updates caused.