VERSION: 1.0.0 · JAN 2025 · COMPLEXITY: HIGH

Designing a Constraint-Driven Booking System for Pet Services

A deep dive into architecting a real-time booking platform — covering GraphQL schema design, conflict detection, dynamic pricing engines, and multi-role access built for production operations.

Designing a Constraint-Driven Booking System for Pet Services
System Complexity

A single platform coordinating lodging, daycare, and grooming — each with distinct booking rules, time constraints, and pricing logic.

Real-Time Constraints

Availability, pricing, and unit assignment all needed to update live as bookings changed — with zero tolerance for conflicts.

This article covers the technical decisions behind building this system. Each section explores a different layer of the architecture.

The Problem Space

Building a booking system sounds straightforward until you're dealing with real operational complexity. Paws Paradise isn't a simple appointment scheduler — it's a multi-service platform where lodging is booked by night, daycare by time slot, and grooming by appointment window. Each service type has distinct rules, and all three need to run simultaneously without conflict.

The system needed to handle:

90+ rooms and slots with real-time availability
Per-pet pricing with add-ons, discounts, and duration logic
Three user roles: admin, staff, customer — each with distinct permissions
Email and SMS notifications triggered by booking state changes
A 6-step booking flow that validates at every transition

GraphQL Schema Design

The foundation of the system is the GraphQL schema. Getting this right meant thinking in terms of business constraints first — not database tables.

The key insight: model the domain, not the UI. A booking isn't just a row in a database — it's a state machine with transitions, validations, and side effects.

SCHEMA.GRAPHQL
type Booking {
  id: ID!
  service: ServiceType!
  pet: Pet!
  unit: Unit
  status: BookingStatus!
  checkIn: DateTime!
  checkOut: DateTime
  pricing: PricingSummary!
  createdBy: User!
}

enum BookingStatus {
  PENDING
  CONFIRMED
  CHECKED_IN
  CHECKED_OUT
  CANCELLED
}

type Query {
  availableUnits(
    serviceType: ServiceType!
    checkIn: DateTime!
    checkOut: DateTime
  ): [Unit!]!
}

The availableUnits query is the core of conflict detection — it accepts the service type and date range, then returns only units with no overlapping confirmed bookings.

Conflict Detection Logic

Double-booking is the worst failure mode in a lodging system. The detection logic needs to be airtight — checking not just exact matches but all possible overlap patterns.

RESOLVER.TS
async function getAvailableUnits(
  serviceType: ServiceType,
  checkIn: Date,
  checkOut: Date | null
): Promise<Unit[]> {
  const conflicting = await db.booking.findMany({
    where: {
      serviceType,
      status: { in: ['PENDING', 'CONFIRMED', 'CHECKED_IN'] },
      OR: [
        { checkIn: { lt: checkOut }, checkOut: { gt: checkIn } },
        { checkIn: { gte: checkIn, lt: checkOut } },
      ]
    },
    select: { unitId: true }
  })

  const conflictingIds = conflicting.map(b => b.unitId)

  return db.unit.findMany({
    where: {
      serviceType,
      id: { notIn: conflictingIds },
      status: 'VACANT'
    }
  })
}

The OR condition covers all overlap cases — a booking that starts before and ends during, or starts during the requested window. Missing either case would allow double-bookings to slip through.

The Pricing Engine

Pricing is computed from a rule graph — not hardcoded formulas. This means business owners can change rates without touching code.

The formula: base_rate × duration × size_multiplier + addons − discounts

PRICING.TS
function computePrice(booking: BookingInput): PricingSummary {
  const base = getRateForService(booking.serviceType, booking.petSize)
  const duration = getDurationUnits(booking.checkIn, booking.checkOut)
  const subtotal = base * duration

  const addons = booking.addons.reduce(
    (sum, addon) => sum + addon.price, 0
  )

  const discount = booking.discountCode
    ? applyDiscount(subtotal, booking.discountCode)
    : 0

  return {
    base: subtotal,
    addons,
    discount,
    total: subtotal + addons - discount,
    breakdown: buildLineItems(booking)
  }
}

Every transaction exposes a breakdown — line items per pet, per service, per add-on. This builds client trust and makes disputes easy to resolve.

Multi-Role Access

Three roles with meaningfully different access patterns require more than just route guards. The permission model needs to live at the data layer — not just the UI.

Admin: full access to all data, config, pricing rules, and reports
Staff: can manage bookings and units, cannot change pricing or config
Customer: can only see and manage their own bookings and pets

I implemented this as a context-aware middleware layer on the GraphQL resolvers — every query and mutation checks the caller's role before resolving. No frontend guard can substitute for this.

Key Takeaways

MODEL THE DOMAIN FIRST

Schema design that reflects real business constraints is harder to build but far easier to extend and debug.

CONFLICT DETECTION IS NOT OPTIONAL

Any booking system without airtight overlap detection will eventually produce double-bookings. Test every edge case.

EXPOSE PRICING LOGIC

A transparent breakdown per line item builds client trust and reduces support overhead significantly.

PERMISSIONS BELONG AT THE DATA LAYER

UI-only access control is a liability. Enforce permissions in resolvers — treat every request as potentially untrusted.

#graphql#architecture#booking_systems#typescript#node
PREV_LOGNEXT_LOG