Skip to main content

Documentation Index

Fetch the complete documentation index at: https://www.aidonow.com/llms.txt

Use this file to discover all available pages before exploring further.

Scheduling service architecture: four domain aggregates, three DynamoDB tables, nine indexes, and CalendarProvider trait with three implementations

Executive Summary

This paper documents the design and implementation of a scheduling service built using a domain-first sequencing discipline: domain aggregates and types were defined before DynamoDB table design, which was defined before HTTP handlers, which were defined before external integrations. This sequencing produced a three-table DynamoDB schema whose indexes are derived directly from the domain’s query requirements rather than from REST endpoint conventions. The analysis covers four domain aggregates, a nine-index DynamoDB design, a CalendarProvider trait with three provider implementations, hand-rolled RFC 5545 ICS generation without an external library, EventBridge publishing across thirteen typed event keys, and six end-to-end test scenarios. The central finding is that domain-first sequencing is not a stylistic preference — it is a structural discipline that prevents a class of access-pattern bugs that are expensive to correct after a schema is in production.

Key Findings

  • Domain aggregate design before HTTP layer design produces DynamoDB table schemas shaped by access patterns rather than REST conventions, eliminating retroactive index additions that require table migration or application-layer filtering workarounds.
  • Embedding availability rules in the EventType aggregate, rather than a separate availability table, eliminates a join at slot generation time at the cost of increased record size; this trade-off favors the scheduling access pattern, where availability rules are always read together with the event type.
  • GSI3 on the bookings table — a time-range index with a composite sort key — is the critical structural requirement for double-booking prevention; without it, conflict detection requires either a full scan over all bookings for an event type or application-layer filtering of an over-fetched result set, both of which introduce race conditions or unacceptable latency at scale.
  • Hand-rolling RFC 5545 ICS generation is appropriate when the required subset is small and available libraries produce non-standard output; the implementation cost of approximately 150 lines is lower than the integration cost of maintaining a dependency with documented compatibility issues against calendar clients.
  • Fire-and-forget EventBridge publishing with thirteen typed event keys is sufficient for scheduling domain events because EventBridge’s at-least-once delivery guarantee matches the durability requirements of the downstream consumers — notification services and CRM integrations — that subscribe to those events.

1. Domain-First Sequencing Produces DynamoDB Schemas Shaped by Access Patterns, Not REST Conventions

The conventional approach to building a CRUD-adjacent service is to define the HTTP API first. Endpoints establish the data contract, and the persistence layer is shaped to support those endpoints. This sequencing is intuitive: the API is the visible surface, and most developers have strong intuitions about what REST endpoints should look like. For services backed by DynamoDB, this sequencing produces schemas shaped by REST conventions rather than by access patterns. A REST-first designer building a scheduling service will often produce a normalized schema with separate tables for event types, availability rules, bookings, and calendar connections — mirroring the HTTP resource hierarchy. DynamoDB is not a relational database. Its query model requires that access patterns be known before the schema is finalized, because adding a Global Secondary Index after the fact may require a full table migration, and removing one requires a corresponding application-layer workaround. The scheduling service described in this paper was built in the reverse order:
  1. Domain structs, types, and value objects were defined in Rust, establishing the canonical representation of the domain.
  2. DynamoDB table design was derived from the domain’s query requirements — specifically, from the set of questions the application needs to answer and the indexes required to answer them efficiently.
  3. HTTP handlers were implemented against the domain layer, with no influence on the table design.
  4. External integrations (CalendarProvider implementations, EventBridge publishing) were layered on top.
This paper documents what each layer produced and why the sequencing mattered.

2. Four Domain Aggregates Establish the Canonical Model Before Any Persistence Decision Is Made

The domain contains three aggregates and one value object. The distinction is intentional: aggregates are stored and have identity; value objects are computed and have no independent existence.

2.1 EventType

EventType represents a bookable service — a named, configurable unit of time that an owner makes available for others to book.
pub struct EventType {
    pub id: EventTypeId,
    pub owner_id: OwnerId,
    pub slug: Slug,                          // URL-safe identifier; unique per owner
    pub title: String,
    pub description: Option<String>,
    pub duration_minutes: u32,
    pub buffer_minutes: u32,                 // padding after each booking
    pub max_advance_days: u32,               // how far ahead slots are available
    pub availability_rules: Vec<AvailabilityRule>,
    pub status: EventTypeStatus,             // Active | Inactive
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

pub struct AvailabilityRule {
    pub weekday: Weekday,
    pub windows: Vec<TimeWindow>,            // start/end within the day
}

pub struct TimeWindow {
    pub start: NaiveTime,
    pub end: NaiveTime,
}
The critical design decision here is the embedding of availability_rules directly in EventType. An alternative design stores availability rules in a separate table keyed by event_type_id. The embedded design was chosen for the following reason: slot generation always requires both the event type’s configuration (duration, buffer, max advance days) and its availability rules. A separate availability table would require a join — or, in DynamoDB terms, a second read — on every slot generation request. Embedding eliminates that read at the cost of increased record size. For the expected number of availability rules per event type (typically seven or fewer, one per weekday), this is the correct trade-off.

2.2 Booking

Booking represents a confirmed reservation against a specific EventType.
pub struct Booking {
    pub id: BookingId,
    pub event_type_id: EventTypeId,
    pub owner_id: OwnerId,
    pub booker_id: BookerId,
    pub booker_email: Email,
    pub booker_name: String,
    pub start_time: DateTime<Utc>,
    pub end_time: DateTime<Utc>,
    pub status: BookingStatus,
    pub confirmed_at: Option<DateTime<Utc>>,
    pub cancelled_at: Option<DateTime<Utc>>,
    pub cancellation_reason: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

pub enum BookingStatus {
    Pending,
    Confirmed,
    Completed,
    Cancelled,
    NoShow,
}
The status lifecycle is linear with two terminal states: Completed and Cancelled/NoShow. The Pending → Confirmed transition is the critical path for double-booking prevention: a booking is not blocking until it reaches Confirmed. The time-range conflict detection index (GSI3, described in Section 3) queries only Confirmed bookings to determine slot availability.

2.3 CalendarConnection

CalendarConnection stores OAuth token state per provider per owner. After a migration to the platform’s credential vault (described in Section 2.4), the record carries a vault reference rather than raw tokens.
pub struct CalendarConnection {
    pub id: CalendarConnectionId,
    pub owner_id: OwnerId,
    pub provider: CalendarProvider,          // Google | Outlook | ICal
    pub vault_reference: VaultReference,     // opaque reference into credential vault
    pub sync_enabled: bool,
    pub last_synced_at: Option<DateTime<Utc>>,
    pub version: u64,                        // optimistic locking attribute
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}
The version attribute supports optimistic locking on token refresh operations. A conditional update expression on DynamoDB requires that the stored version matches the expected value before applying the update — preventing concurrent refresh attempts from producing inconsistent state.

2.4 TimeSlot (Value Object)

TimeSlot is not stored. It is computed on demand by intersecting three data sources: the EventType’s availability windows for the requested date range, the set of Confirmed bookings that overlap those windows, and the free/busy intervals returned by the owner’s CalendarConnection provider.
pub struct TimeSlot {
    pub start: DateTime<Utc>,
    pub end: DateTime<Utc>,
    pub available: bool,
}
Treating TimeSlot as a value object rather than a stored entity was the correct decision. Storing computed slots would require invalidation logic whenever a booking is created, cancelled, or rescheduled, or whenever the owner’s calendar changes. The computation is inexpensive relative to the DynamoDB reads required to gather the inputs, and the result is always derived from authoritative state rather than from a potentially stale cache.

3. Three Tables with Nine Indexes Are Derived Entirely from Domain Query Requirements

The three-table design was derived directly from the domain aggregates and the access patterns the application requires.

3.1 scheduling_event_types

Table structure:
AttributeRoleValue Pattern
PKPartition keyOWNER#{owner_id}
SKSort keyEVENTTYPE#{slug}
GSI1_PKGSI1 partition keyEVENTTYPE#{slug}
GSI2_PKGSI2 partition keyOWNER#{owner_id}
GSI2_SKGSI2 sort keySTATUS#{status}
Access patterns supported:
  • Fetch a specific event type by owner and slug (base table, equality query)
  • Fetch an event type by slug alone, without knowing the owner (GSI1 — enables public booking links)
  • List all active event types for an owner (GSI2, begins_with("STATUS#Active"))
The slug uniqueness constraint is enforced at write time using a DynamoDB conditional expression:
// Conditional put: fails if an item with this PK/SK already exists
let request = client
    .put_item()
    .table_name(TABLE_NAME)
    .set_item(Some(item))
    .condition_expression("attribute_not_exists(PK) AND attribute_not_exists(SK)")
    .build()?;
The slug uniqueness condition (attribute_not_exists(PK) AND attribute_not_exists(SK)) enforces uniqueness within a single owner’s partition. It does not enforce global slug uniqueness across owners. GSI1, which indexes by slug globally, may return multiple items if two owners happen to choose the same slug. If global slug uniqueness is required, a separate uniqueness sentinel record in a dedicated constraint table is needed.

3.2 scheduling_bookings

Table structure:
AttributeRoleValue Pattern
PKPartition keyEVENTTYPE#{event_type_id}
SKSort keyBOOKING#{booked_at_iso}#{booking_id}
GSI1_PKGSI1 partition keyBOOKER#{booker_id}
GSI2_PKGSI2 partition keyOWNER#{owner_id}
GSI2_SKGSI2 sort keySTATUS#{status}#{booked_at_iso}
GSI3_PKGSI3 partition keyEVENTTYPE#{event_type_id}
GSI3_SKGSI3 sort keyTIME#{start_time_iso}
Access patterns supported:
  • Fetch all bookings for an event type in chronological order (base table, begins_with("BOOKING#"))
  • Fetch all bookings by a specific booker across all event types (GSI1)
  • Fetch an owner’s bookings filtered by status and time range (GSI2, composite sort key enables both filters simultaneously)
  • Detect time conflicts for a proposed slot (GSI3 — discussed below)
GSI3 is the architectural keystone of the bookings table. When a new booking is requested for a time slot, the service must determine whether any Confirmed booking already occupies an overlapping interval. Without GSI3, this requires fetching all bookings for the event type and filtering in application code — an approach that fails under concurrent load because two requests arriving simultaneously may each observe the same empty slot and both proceed to write. GSI3 enables a bounded query: fetch all bookings for the event type with start_time in the range [proposed_start - max_duration, proposed_end]. This returns only the bookings that could possibly overlap the proposed slot. A filter expression on status = Confirmed then eliminates non-blocking bookings. The bounded query, combined with a conditional write that enforces non-overlap, prevents double-booking under concurrent load.
// GSI3 conflict detection query
let response = client
    .query()
    .table_name(TABLE_NAME)
    .index_name("GSI3")
    .key_condition_expression(
        "GSI3_PK = :pk AND GSI3_SK BETWEEN :range_start AND :range_end"
    )
    .filter_expression("#status = :confirmed")
    .expression_attribute_names("#status", "status")
    .expression_attribute_values(":pk", AttributeValue::S(format!("EVENTTYPE#{}", event_type_id)))
    .expression_attribute_values(":range_start", AttributeValue::S(format!("TIME#{}", window_start.to_rfc3339())))
    .expression_attribute_values(":range_end", AttributeValue::S(format!("TIME#{}", window_end.to_rfc3339())))
    .expression_attribute_values(":confirmed", AttributeValue::S("Confirmed".to_string()))
    .build()?;
The time range passed to GSI3 must extend backward by the maximum possible booking duration to catch bookings whose start_time precedes the proposed slot but whose end_time overlaps it. If the event type’s duration_minutes is the only duration in play, the lower bound is proposed_start - duration_minutes. If variable-duration event types share the table, the lower bound must account for the longest possible duration across all event types.

3.3 scheduling_calendar_connections

Table structure:
AttributeRoleValue Pattern
PKPartition keyOWNER#{owner_id}
SKSort keyPROVIDER#{provider_name}
A single record per provider per owner. No additional indexes are required: the only access pattern is fetching a specific owner’s connection for a specific provider, which maps directly to the base table key structure. Token refresh operations use conditional updates with the version attribute for optimistic locking:
// Conditional update: fails if version has changed since last read
let request = client
    .update_item()
    .table_name(TABLE_NAME)
    .key("PK", AttributeValue::S(format!("OWNER#{}", owner_id)))
    .key("SK", AttributeValue::S(format!("PROVIDER#{}", provider_name)))
    .update_expression("SET vault_reference = :new_ref, version = :new_version, updated_at = :now")
    .condition_expression("version = :expected_version")
    .expression_attribute_values(":new_ref", AttributeValue::S(new_vault_ref))
    .expression_attribute_values(":new_version", AttributeValue::N((current_version + 1).to_string()))
    .expression_attribute_values(":expected_version", AttributeValue::N(current_version.to_string()))
    .expression_attribute_values(":now", AttributeValue::S(Utc::now().to_rfc3339()))
    .build()?;

4. A CalendarProvider Trait Abstracts Three Calendar Backends Behind a Single Interface

External calendar integration is abstracted behind a CalendarProvider trait. The trait defines the interface for free/busy data retrieval, which is the only calendar operation required for slot generation.
#[async_trait]
pub trait CalendarProvider: Send + Sync {
    async fn get_free_busy(
        &self,
        connection: &CalendarConnection,
        range_start: DateTime<Utc>,
        range_end: DateTime<Utc>,
    ) -> Result<Vec<BusyInterval>, CalendarError>;
}

pub struct BusyInterval {
    pub start: DateTime<Utc>,
    pub end: DateTime<Utc>,
}
Three implementations exist: GoogleCalendarProvider calls the Google Calendar API freeBusy endpoint with the requested time range. The response contains a list of busy intervals per calendar. OAuth2 token refresh is handled via the google-apis-rs client, which manages the refresh token exchange transparently. The vault reference in CalendarConnection is resolved to an access token before each API call; if the token has expired, a refresh is performed and the new vault reference is written back to the connection record via the conditional update described in Section 3.3. OutlookCalendarProvider calls the Microsoft Graph getSchedule endpoint, which returns a similar busy-interval structure. The token refresh flow differs: Microsoft’s MSAL-compatible token exchange requires a different grant type and endpoint. The implementation handles this divergence within the provider; the trait interface is identical. ICalProvider is read-only. It polls an .ics URL — typically a Google Calendar iCal export URL or a third-party calendar feed — parses the VCALENDAR/VEVENT structure, and extracts DTSTART/DTEND pairs for events in the requested range. There is no write capability. This provider does not require OAuth credentials: the .ics URL is stored directly in the CalendarConnection record’s vault reference as an opaque string. The trait abstraction enables slot generation to treat all three provider types identically. The slot generation function accepts a Box<dyn CalendarProvider> and calls get_free_busy without knowledge of which provider is in use. Provider selection occurs at the point where a CalendarConnection is resolved, based on the provider field.

5. Hand-Rolling RFC 5545 ICS Generation Is Justified When Available Libraries Have Documented Compatibility Failures

Booking confirmation emails include an attached .ics file that recipients can import into any calendar application. The service generates these files without an external library.

5.1 The Library Evaluation

Two Rust crates were evaluated for ICS generation:
CriterionExternal Crate AExternal Crate BHand-Rolled
Maintenance statusUnmaintained (last commit 2021)ActiveN/A
RFC 5545 compliancePartialPartialTargeted subset
Calendar client compatibilityImport failures in OutlookUnknownVerified in Google, Outlook, Apple
Line-folding (RFC 5545 §3.1)IncorrectNot testedImplemented correctly
Dependency surface3 transitive deps7 transitive depsZero
Implementation costIntegration + workaroundsUnknown~150 lines
The decision to hand-roll was driven by the compatibility finding on the unmaintained crate and the absence of verified compatibility data for the active crate. RFC 5545 line-folding — the requirement that lines longer than 75 octets be folded with a CRLF followed by a single whitespace character — is the most common source of calendar import failures in practice. Both evaluated crates had known or unknown issues in this area.

5.2 The Implementation

The hand-rolled implementation covers the subset of RFC 5545 required for booking confirmations: VCALENDAR, VEVENT, DTSTART, DTEND, DTSTAMP, UID, SUMMARY, DESCRIPTION, and ORGANIZER.
pub fn generate_ics(booking: &Booking, event_type: &EventType) -> String {
    let uid = format!("{}@scheduling.internal.example.com", booking.id);
    let dtstamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
    let dtstart = booking.start_time.format("%Y%m%dT%H%M%SZ").to_string();
    let dtend = booking.end_time.format("%Y%m%dT%H%M%SZ").to_string();

    let mut lines: Vec<String> = vec![
        "BEGIN:VCALENDAR".to_string(),
        "VERSION:2.0".to_string(),
        "PRODID:-//Scheduling Service//EN".to_string(),
        "CALSCALE:GREGORIAN".to_string(),
        "METHOD:REQUEST".to_string(),
        "BEGIN:VEVENT".to_string(),
        fold_line(format!("UID:{}", uid)),
        fold_line(format!("DTSTAMP:{}", dtstamp)),
        fold_line(format!("DTSTART:{}", dtstart)),
        fold_line(format!("DTEND:{}", dtend)),
        fold_line(format!("SUMMARY:{}", event_type.title)),
        fold_line(format!("DESCRIPTION:{}", build_description(booking, event_type))),
        fold_line(format!("ORGANIZER;CN={}:mailto:{}", event_type.title, booking.booker_email)),
        "END:VEVENT".to_string(),
        "END:VCALENDAR".to_string(),
    ];

    lines.join("\r\n") + "\r\n"
}

// RFC 5545 §3.1: fold lines longer than 75 octets
fn fold_line(line: String) -> String {
    if line.len() <= 75 {
        return line;
    }
    let mut result = String::new();
    let mut current_len = 0;
    for ch in line.chars() {
        let ch_len = ch.len_utf8();
        if current_len + ch_len > 75 {
            result.push_str("\r\n ");
            current_len = 1; // space counts
        }
        result.push(ch);
        current_len += ch_len;
    }
    result
}
All timestamps use UTC with the Z suffix. The UID is derived from the booking_id to ensure idempotency: re-sending a confirmation email produces an .ics file with the same UID, which calendar clients interpret as an update to the existing event rather than a duplicate.

6. Thirteen Typed EventBridge Event Keys Decouple Booking Lifecycle Transitions from Downstream Consumers

The service publishes domain events to EventBridge on all significant state transitions. Thirteen event keys are defined:
CategoryEvent Keys
Booking lifecyclebooking.created, booking.confirmed, booking.cancelled, booking.completed, booking.no_show, booking.rescheduled
Event type lifecycleevent_type.created, event_type.updated, event_type.deactivated
Calendar integrationcalendar.connected, calendar.disconnected, calendar.sync_failed
Operationalslot.conflict_detected
Publishing is fire-and-forget. The service does not implement retry logic for EventBridge PutEvents calls beyond the SDK’s default retry behavior. This is a deliberate choice: EventBridge provides at-least-once delivery to subscribed targets, and the downstream consumers — a notification service and a CRM integration — are designed for idempotent processing. Implementing application-level retry with a persistent outbox would add significant complexity for a durability requirement that EventBridge already satisfies. The slot.conflict_detected event is published when a booking attempt fails the GSI3 conflict check. This event feeds an operational monitoring rule that tracks conflict frequency per event type — a signal used to identify event types whose availability windows are undersized relative to demand.

7. Six End-to-End Scenarios Exercise the Full Stack from HTTP Handler Through EventBridge

Six end-to-end scenarios exercise the full stack from HTTP handler through DynamoDB and EventBridge:

Scenario 1: Happy Path

Create an event type → query available slots for the next 7 days → create a booking against a slot → confirm the booking. Verifies that slot generation correctly applies availability windows, that the booking transitions through Pending → Confirmed, and that booking.confirmed is published to EventBridge.

Scenario 2: CRM Association

Execute the happy path, then verify that the EventBridge consumer for booking.confirmed has linked the booking to a CRM contact record. This test is the integration boundary test: it confirms that the EventBridge event contains sufficient data for downstream consumers to act without an additional read from the scheduling service.

Scenario 3: Conflict Detection

Create an event type and a Confirmed booking. Attempt a second booking for the same slot. Verify that the second attempt returns HTTP 409 and that slot.conflict_detected is published. This is the critical correctness test for GSI3.

Scenario 4: Reschedule

Confirm a booking. Reschedule it to a new slot. Verify that the original slot becomes available (queryable via slot generation), the new slot is blocked, and booking.rescheduled is published.

Scenario 5: No-Show

Confirm a booking. After the booking’s start time has passed (simulated in test by back-dating the start_time), mark the booking as no-show. Verify status transition to NoShow and booking.no_show publication.

Scenario 6: Calendar Sync Integration

Create a CalendarConnection for a Google Calendar provider (using a test double that returns a controlled set of busy intervals). Query available slots. Verify that the busy intervals from the test double are reflected as unavailable in the slot query response, and that the slots outside the busy intervals remain available.

8. Recommendations for Scheduling Service Design

  1. Define your domain aggregates and their query requirements before designing DynamoDB table schemas. The set of questions the application must answer determines the indexes required. HTTP endpoint design should have no influence on index selection: REST resource hierarchies do not map cleanly to DynamoDB access patterns.
  2. Embed related data in your aggregates when the data is always read together. Availability rules embedded in EventType eliminate a read on every slot generation request. Apply this principle selectively: embedding is appropriate when the embedded data is always fetched with the parent, when the embedded collection is bounded in size, and when write patterns do not require independent updates to the embedded data at high frequency.
  3. Design the conflict detection index before any other secondary index in your scheduling system. For any scheduling or reservation system, double-booking prevention is the highest-correctness requirement. The index that supports conflict detection — a time-range index with a composite sort key — should be the first index designed, because its structure constrains the sort key format on the primary table.
  4. Use optimistic locking (version attribute) on any record in your service that undergoes concurrent updates from multiple processes. OAuth token refresh is an async operation that may be triggered by multiple concurrent requests. A conditional update expression on a version attribute prevents concurrent refresh operations from producing inconsistent token state at the cost of a ConditionalCheckFailedException that the caller must handle by retrying with a fresh read.
  5. Hand-roll RFC 5545 ICS generation in your system when the required subset is small and available libraries have documented compatibility issues. Evaluate any ICS generation library against actual import behavior in Google Calendar, Microsoft Outlook, and Apple Calendar before adopting it. Correct line-folding (RFC 5545 §3.1) is the most frequent source of import failures.
  6. Publish EventBridge events in your service with typed keys for every significant domain state transition, including failure events. The slot.conflict_detected event is as operationally valuable as the booking lifecycle events. Typed failure events allow downstream systems to build monitoring and alerting without polling the scheduling service’s API.

Conclusion

The scheduling service described in this paper demonstrates that domain-first sequencing is not an academic exercise. The three-table DynamoDB schema produced by this approach is demonstrably better suited to the application’s access patterns than a schema produced by REST-first design would have been: the indexes exist because the domain required them, not because an endpoint suggested them. GSI3 — the time-range conflict detection index — is the clearest example. A REST-first designer would have been unlikely to design this index before implementing the booking creation endpoint, because the conflict detection requirement only becomes apparent when the domain logic is examined carefully. The hand-rolled RFC 5545 implementation and the CalendarProvider trait represent the same principle applied to different problem dimensions: examine the actual requirement carefully before adopting a dependency. The ICS requirement is a bounded subset of a large specification; the calendar integration requirement is a bounded interface with three implementations that differ only in token handling and API endpoints. In both cases, the domain-first examination produced the simplest adequate solution. As scheduling and reservation workloads continue to migrate to event-driven architectures, the combination of DynamoDB access-pattern design with EventBridge publishing described here will become a standard pattern. The thirteen event keys defined in this service represent a minimum viable event vocabulary for a scheduling domain; teams extending this pattern should expect to add event keys as downstream consumers identify new integration requirements.
All content represents personal learning from personal projects. Code examples are sanitized and generalized. No proprietary information is shared. Opinions are my own and do not reflect my employer’s views.