ModelingEvolution.Booking 1.0.0-preview.3

ModelingEvolution.Booking

Meeting booking as an event-sourced module on MicroPlumberd 1.2.x / KurrentDB: one BookingCalendar-{mailbox} stream arbitrates non-overlap of meetings on a mailbox (optimistic concurrency — a lost version race retries once, then refuses as slot_taken); each booking lives on its own Booking-{BookingId} stream (MeetingBooked, MeetingCancelled); availability is synced from O365 and offered per meeting type; bookings carry an ExternalReference (kind:value) a consumer uses to find them again.

builder.Services.AddPlumberd(KurrentDBClientSettings.Create(connectionString));
builder.Services.AddBooking(builder.Configuration);   // command handlers, read models, calendar integration (Booking:* options)

The pipeline as two public primitives (1.0.0-preview.2, ADR-3 split)

A host that only TAKES intents (no calendar credential) and a worker that MATERIALISES them can each compose the half they need — the website's own BookingCommandHandler runs the very same two objects:

// intent-taking unit: read models + step 1, no ICalendarService
builder.Services.AddSingletonEventHandler<BookingConfigModel>(FromRelativeStreamPosition.End - 1);
builder.Services.AddSingletonEventHandler<AvailableSlotsModel>(FromRelativeStreamPosition.End - 1);
builder.Services.Configure<BookingOptions>(cfg.GetSection("Booking"));   // Mailbox ⇒ calendar stream; ReservationLease
builder.Services.AddBookingSlotArbiter();
var hold = await arbiter.ReserveAsync(bookingId, date, time, MeetingTypeKey.Standard);   // idempotent for the same id + window; faults: slot_taken | invalid_* | too_close
await arbiter.TryReleaseAsync(bookingId, "reason");

// materialising worker: + ICalendarService (yours) + steps 2–4
builder.Services.AddSingleton<ICalendarService, YourCalendar>();   // or the sink
builder.Services.AddBookingMaterialiser();
var result = await materialiser.MaterialiseAsync(new MeetingIntent(bookingId, date, time, type, name, email, phone, comments, reference, tags, "portal"));
// result.WasAlreadyBooked: a re-delivered intent — nothing created twice; result.Booked is the stored event

Three hazards, stated: (1) ICalendarService.CreateEventAsync — the 8-argument member (with transactionId) is the abstract one; the 7-argument call is a default-interface convenience forwarding transactionId: null. (2) An implementer written before the key does not compile — add the parameter and honour it (same key ⇒ same event). (3) If you MOCK ICalendarService (NSubstitute etc.), set up the 8-argument overload: production code calls the keyed member; a setup on the 7-argument overload is never hit and your test sees an empty event id.

preview.3: MeetingIntent.InviteName/InviteEmail (init) — the calendar invite only, never on MeetingBooked (a delegated buyer: Name/Email = null, Invite* = the address you hold and can erase); IBookingMaterialiser.CancelAsync(id, reason) (calendar delete → release AS THE GATE → MeetingCancelled; idempotent; nothing-booked releases and appends nothing); AvailabilitySyncChecked beat every sync on its own category + AvailabilitySyncStatusModel (LastSyncAt, LastChangeAt, IsFresh(now, threshold)) — register it End−1 beside your read models; TimeProvider honoured by AvailableSlotsModel and the arbiter (the host's registration wins; TryAddSingleton(TimeProvider.System) otherwise).

MaterialiseAsync checks Booking-{id} for MeetingBooked FIRST, calls CreateEventAsync(…, transactionId: id) (the calendar's idempotency key — a crash between the calendar and the append cannot orphan a second event on retry), appends MeetingBooked + BookingTagAttached per key in ONE append, and confirms the hold.

Read models: BookingLookupModel (by id / by tag), AvailableSlotsModel, BookingConfigModel. The concurrency invariant is tested against a real KurrentDB in the source repository (RealStoreSlotArbitrationTests), on both the pre- and post-1.2 client libraries.

No packages depend on ModelingEvolution.Booking.

Version Downloads Last updated
1.0.0-preview.11 0 08/19/2026
1.0.0-preview.10 0 08/19/2026
1.0.0-preview.9 0 08/19/2026
1.0.0-preview.8 6 08/17/2026
1.0.0-preview.7 0 08/17/2026
1.0.0-preview.6 5 08/17/2026
1.0.0-preview.5 1 08/17/2026
1.0.0-preview.4 3 08/17/2026
1.0.0-preview.3 0 08/17/2026
1.0.0-preview.2 3 08/17/2026
1.0.0-preview.1 0 08/17/2026