ModelingEvolution.Booking 1.0.0-preview.4

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).

Hazard 4 — End−1 is for single-type models. AddSingletonEventHandler<T>(FromRelativeStreamPosition.End - 1) subscribes T's event types as ONE joined stream and starts one event before its end: a model folding two types gets exactly ONE event — whichever landed last — so a config written after the last sync left the slots model with no bitmap and no dates (found on the ERP host, §6.105). Read models never depend on other read models, so AvailableSlotsModel (folds BookingConfigured + AvailabilitySynced) is registered from START — the stream is a handful of small events. If you write your own multi-type model, register it from Start too; the package pins "every End−N registration folds ≤ N types" (EndMinusOneJoinTests, container-derived) and proves both arrival orders on a real store.

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