ModelingEvolution.Booking
1.0.0-preview.11
dotnet add package ModelingEvolution.Booking --version 1.0.0-preview.11
NuGet\Install-Package ModelingEvolution.Booking -Version 1.0.0-preview.11
<PackageReference Include="ModelingEvolution.Booking" Version="1.0.0-preview.11" />
<PackageVersion Include="ModelingEvolution.Booking" Version="1.0.0-preview.11" />
<PackageReference Include="ModelingEvolution.Booking" />
paket add ModelingEvolution.Booking --version 1.0.0-preview.11
#r "nuget: ModelingEvolution.Booking, 1.0.0-preview.11"
#:package ModelingEvolution.Booking@1.0.0-preview.11
#addin nuget:?package=ModelingEvolution.Booking&version=1.0.0-preview.11&prerelease
#tool nuget:?package=ModelingEvolution.Booking&version=1.0.0-preview.11&prerelease
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_contended; another hold overlapping ⇒ 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_unavailable | booking_moved | slot_taken | slot_contended | 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.
Hazard 6 — ReadEventsOfType<T>(stream, maxCount) caps the events READ, not the matches. MicroPlumberd applies
maxCount to the store read BEFORE the type filter: ReadEventsOfType<MeetingCancelled>(booking, maxCount: 1) reads
MeetingBooked@0 and stops — never true — so preview.≤4's CancelAsync appended a second MeetingCancelled on a
second cancel (found by dev-1b on the ERP, test-1's double cancel). preview.5 reads the tiny Booking-{id} stream
without a cap; RealStoreCancelIdempotencyTests cancels twice on a real store and counts ONE. Do not put a
maxCount on a typed read unless the type is the stream's first event AND you can prove it stays so.
preview.5: IBookingSlotArbiter.TryGetHoldAsync(id) — a PURE READ of the window this booking holds (reserved-and-
unexpired or confirmed) or null; no reserve, no append (§6.103/§6.104: the worker that must know "does THIS booking hold
THIS slot" before materialising asks here instead of probing ReserveAsync). AddBookingReadModels() is public — the ONE registration of the five read models (BookingConfigModel and
AvailabilitySyncStatusModel End−1; AvailableSlotsModel, BookingCalendarModel, BookingLookupModel from Start). A host
composing a la carte calls it rather than mirroring the lines; AddBooking() calls it too.
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.
preview.6: BookingFault codes split (rev-b2's S6_91 finding): slot_unavailable (not offered / not free in the availability model),
booking_moved (this booking holds another window — a booking does not move), slot_taken (another booking's hold
overlaps — the genuine contest; the word an ERP maps stays), slot_contended (concurrent writers, retry exhausted); the
translated exception rides on BookingFaultException.Cause. ExternalReference moved to the ModelingEvolution.Tags package (namespace ModelingEvolution.Tags,
was ModelingEvolution.Booking.ValueTypes) — ONE tag type across Booking and Chat (a channel's DefineChannel.Tags is
the same type, so BookingLookupModel.ByTag(offer:…) and ChannelReadModel.ByTag(offer:…) are one question). Rules
unchanged; consumers change one using. Booking depends on the package.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- MicroPlumberd (>= 1.2.2)
- MicroPlumberd.Services (>= 1.2.2)
- MicroPlumberd.SourceGenerators (>= 1.2.2)
- ModelingEvolution.JsonParsableConverter (>= 1.0.1)
- ModelingEvolution.Tags (>= 1.0.0-preview.11)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-preview.11 | 25 | 8/19/2026 |
| 1.0.0-preview.10 | 28 | 8/19/2026 |
| 1.0.0-preview.9 | 34 | 8/19/2026 |
| 1.0.0-preview.8 | 55 | 8/17/2026 |
| 1.0.0-preview.7 | 51 | 8/17/2026 |
| 1.0.0-preview.6 | 45 | 8/17/2026 |
| 1.0.0-preview.5 | 48 | 8/17/2026 |
| 1.0.0-preview.4 | 53 | 8/17/2026 |
| 1.0.0-preview.3 | 51 | 8/17/2026 |
| 1.0.0-preview.2 | 56 | 8/17/2026 |
| 1.0.0-preview.1 | 52 | 8/17/2026 |