The Shortcut That Isn't
Here's the scenario. You're building a licensee or candidate portal on Power Pages for a professional exam. Somewhere in the requirements is a line like "candidates can book their exam sitting online." The candidate is already a contact record, eligibility is already tracked in Dataverse, and a custom entity representing an exam slot — with a lookup to the candidate, a date, a location, and a status — looks like the fastest way to get from requirement to working demo.
It is the fastest path to a working demo. It also treats scheduling as if it were the same kind of problem as tracking a case status: a record with a field that changes value. It isn't. Scheduling is a contested-resource problem — a fixed number of seats, in a fixed room, at a fixed time, that many candidates are trying to claim at once, with the added twist that most of them will change their mind at least once before exam day. Dataverse can store that shape of data. It was never built to arbitrate it under contention.
A quick note on terms, since this article uses them precisely: a sitting is a scheduled exam session at a given room and time; a slot is the bookable unit within that sitting; a seat is one unit of capacity within a slot. A small in-person exam might have one seat per slot. A large one might have many. The concurrency problem below applies at whichever of those levels is actually finite and contested for a given client.
This is a pattern I've seen play out directly on an exam-scheduling system built on Dynamics 365 and Power Pages for a provincial regulatory body: the booking experience worked fine in every demo and every quiet stretch between sittings, and the cracks only showed up once real registration volume hit the same handful of sittings at the same time.
What Dataverse Is Actually For
Dynamics 365 and Dataverse are excellent at owning the facts around a booking: which candidate is scheduled into which sitting, what their eligibility status was at the time, whether they've completed the exam, and the full case history around all of it. That's relationship and record-of-fact data, and it's exactly what model-driven forms, business process flows, and security roles are designed to manage.
Dataverse does have real concurrency tools — the Web API supports optimistic concurrency via
ETags, and the SDK exposes the same thing through ConcurrencyBehavior.IfRowVersionMatches.
A single synchronous plugin operating on one record is itself transactionally bounded, too.
What it doesn't give you, out of the box, is a clean way to arbitrate many simultaneous
requests against one shared, finite pool — the "1 seat left" scenario, where two
candidates click "confirm" within the same second and a capacity check-then-write, spread
across a plugin or a flow, lets both bookings through before either write lands. You can wire
up optimistic concurrency against a single capacity record and add a retry loop for the
conflicts it correctly rejects — that's a legitimate fix, entirely inside Dataverse. Most
teams don't build it, and building it correctly means reimplementing, by hand, the
contention-handling a scheduling engine is supposed to provide by default.
The Cancellation Problem
Booking is the easy half. Cancellation is where this pattern usually breaks first, and it's the issue I've seen most directly: a candidate cancels, the portal shows a friendly confirmation, and the seat does not actually come back.
The reason is almost always the same. Releasing a seat isn't a simple field update — it's a read-check-write, same as booking one, just running in the opposite direction: read the current capacity, confirm the cancellation is valid, decrement the "held" count or reactivate the slot, and make that visible to whatever is checking availability next. If that sequence runs as a flow that fires asynchronously, or a plugin that isn't wrapped in the same transaction as the status change, there's a window where the cancellation has "happened" from the candidate's point of view but the capacity count hasn't caught up — or worse, catches up twice, because a retry or a duplicate trigger ran the same release logic again.
Two failure modes show up constantly once volume is real instead of a demo:
- Phantom-held seats: a cancellation is recorded on the candidate's case, but the capacity counter or slot status never gets decremented back down — so a seat that's genuinely free shows as full, and a coordinator ends up manually auditing a spreadsheet to find seats the system insists don't exist.
- Double-released seats: a retried flow run or an out-of-order trigger releases the same seat twice, so the system believes it has more capacity than the room actually has — and the overbooking doesn't surface until exam day, in front of the candidates.
Neither failure is a Dataverse bug. It's what happens when a concurrency-sensitive operation gets built with tools designed for record management, not resource arbitration.
Room and Proctor Allocation: The Same Problem, One More Dimension
Once a portal supports more than one exam location, the scheduling problem doesn't stay the same size — it compounds. A booking isn't just claiming a seat in an abstract capacity count anymore; it's claiming a seat, in a specific room, with a specific proctor, at a specific time, and all three of those have their own independent capacity limits that have to agree with each other before a booking is valid. A room can be double-booked even when the overall seat count looks fine. A proctor can be assigned to two sittings that overlap because nothing was checking proctor availability against room availability in the same transaction.
This is still fundamentally the same boundary question as the seat-release problem — Dataverse can hold the record of which room and proctor were assigned to a sitting, but arbitrating whether that assignment is currently valid, across multiple contested dimensions at once, is exactly the kind of problem a purpose-built scheduling or resource-booking engine is designed to solve, and a case-management platform is not.
Three Ways to Fix This — and Why Only One Holds Up in Regulated Work
Once you accept that availability shouldn't live in Dataverse, there are three realistic places to put it instead. They're not equally good options for every client, and in government and regulatory work specifically, one of them tends to lose before the technical comparison even starts.
Dynamics 365 Field Service / Universal Resource Scheduling. This is worth naming directly, because it's a real answer, not an oversight: Microsoft already ships a scheduling engine — Universal Resource Scheduling — on top of Dataverse, and it's legitimately good at what it does. The problem is fit, not capability. URS itself isn't available under base Dataverse or Power Apps licensing — it comes bundled with Field Service, Customer Service, or Project Operations, all licensed per named user, and all built around internal staff: dispatchers assigning technicians to work orders, agents booked to cases. Not thousands of external, largely anonymous-until-authenticated candidates self-booking through a public portal. Its core pattern is also the wrong shape for this problem: one resource assigned to one job, not many external registrants competing for a small number of shared, fixed-capacity slots. You can bend URS toward exam scheduling, but at that point you're forcing a dispatch engine into a registration problem — the exact mistake this article is arguing against, just one layer up. Worth it if a client is already running Field Service or Customer Service for other reasons. Rarely worth licensing for exam scheduling alone. (Check current Microsoft licensing terms before ruling this in or out for a specific client — tier boundaries move.)
A third-party booking or event-registration platform. Structurally, this is the right shape — these products are built specifically for many external users claiming shared, contested capacity, and concurrency-safe booking and release is their core job. For a commercial client without other constraints, this is often the pragmatic choice. For a regulatory body, it usually runs into a wall before the technical fit even gets evaluated: candidate records are regulated personal information, and handing them to an external vendor means a data-residency review, a security audit, a new contract, and an identity handoff between an already-authenticated Power Pages session and a third party's login — friction that government procurement and security teams are rarely willing to absorb for a single feature.
A custom-built scheduling service, integrated with Dataverse deliberately.
Highest upfront engineering cost of the three, and it carries a real risk of its own: built
carelessly, it's the same seat-release bug this article opened with, just moved to a
different codebase. "Built properly" isn't hand-waving here — concretely, it means capacity
changes go through a single-writer path: a single atomic
UPDATE ... SET seats_remaining = seats_remaining - 1 WHERE seats_remaining > 0
statement, so the database's own row-level locking closes the race — with a
CHECK constraint on the column as a backstop, not the mechanism itself. Or a
queue that processes bookings and cancellations one at a time per slot, so two requests for
the same seat are never actually evaluated concurrently. Either approach gives you the same
guarantee a scheduling engine gives you out of the box — the difference is you're the one
who has to build and test it, which is exactly why this is the highest-cost option of the
three. Built that way, it's also the option that keeps
candidate data inside
infrastructure the client already controls and has cleared for audit, lets the domain model
match the client's actual rules — rooms, proctors, eligibility windows, regulatory blackout
periods — without bending someone else's abstraction to fit, and avoids adding a new vendor
relationship to a government engagement where vendor governance is already the slow part of
every procurement cycle.
For the regulated and government clients this pattern shows up in most often, the custom-built service is the option I'd actually recommend — not because "custom" sounds more rigorous, but because data-residency and vendor-governance constraints tend to rule out the SaaS option before technical fit is even the deciding factor, and URS's licensing model and dispatch-shaped data model make it a poor match unless it's already part of the stack for other reasons. The role Dataverse should play stays the same regardless of which path a client takes:
- Dataverse owns the candidate, the case, and the record of the outcome — who was booked, into what, when, and what happened. This is the system of record for the fact that a booking exists.
- The scheduling service owns availability — the concurrency-safe arithmetic of seats, rooms, and proctors, and the authoritative answer to "is this slot free right now." Purpose-built, chosen deliberately for the concurrency guarantee it provides.
- An integration layer keeps the two in sync — Dataverse asks the scheduling service "is this available," the scheduling service is the one that atomically commits or releases the hold, and Dataverse gets told the outcome and records it. Dataverse never computes availability itself; it reflects a decision made somewhere built to make it safely.
This mirrors the same discipline that keeps ledger and payment data out of Dataverse: the platform is genuinely excellent at owning the record of what happened, and just as clearly the wrong tool for arbitrating a contested resource in real time. For the deeper version of that argument, see Integration Boundaries, Not Workarounds: Where Dynamics 365 Should Stop.
What This Looks Like in a Plugin
Concretely, a plugin firing on cancellation should never be the thing deciding whether capacity is now available again. It should record the cancellation on the case and hand the release off to the scheduling service that's actually authoritative for availability:
// Wrong: Dynamics computes and owns availability
public void OnBookingCancelled(IPluginExecutionContext context)
{
var bookingId = context.PrimaryEntityId;
var slot = GetRelatedSlot(bookingId);
// Read-check-write on a shared counter, inside a single-record plugin
var currentCount = slot.GetAttributeValue("new_bookedcount");
slot["new_bookedcount"] = currentCount - 1;
slot["new_status"] = new OptionSetValue(SlotStatus.Available);
_service.Update(slot);
}
// Right: Dynamics records the fact, the scheduling service owns capacity
public void OnBookingCancelled(IPluginExecutionContext context)
{
var bookingId = context.PrimaryEntityId;
// Pre-image carries the slot reference; cancellation doesn't need to look it up
var preImage = context.PreEntityImages["PreImage"];
var slotReference = preImage.GetAttributeValue("new_slotreference");
// Dynamics updates what it legitimately owns
var booking = new Entity("new_exambooking", bookingId);
booking["new_status"] = new OptionSetValue(BookingStatus.Cancelled);
_service.Update(booking);
// Capacity release is handed off, not computed here
PublishIntegrationEvent(new BookingCancelledEvent
{
BookingId = bookingId,
SlotReference = slotReference,
CorrelationId = Guid.NewGuid()
});
}
The scheduling service receives that event, performs the atomic capacity release, and confirms back — so the only system ever deciding "is this seat free" is the one built to answer that question safely under concurrent load. If you haven't set up reliable event handoffs between Dynamics and downstream systems yet, that pattern is covered in Event-Driven Dynamics 365 with Azure Service Bus.
Seeing the Boundary Enforced, Not Just Described
Everything above is easier to trust with a working example than with prose alone, so we built one. Anielak Solutions maintains a reference implementation of the concurrency-safe scheduling service this article describes — a portfolio project, not a client deployment, running against entirely synthetic data for a fictional institution (Cascadia Professional Licensing Authority). It's a narrow build on purpose: the engine, plus an admin dashboard, nothing else.
The dashboard's own concurrency simulator is the point of the whole exercise: it fires a burst of simultaneous hold requests at the same exam slot and shows exactly one of them win. The guarantee behind that isn't application code checking a counter — it's a partial unique index in PostgreSQL that permits at most one active (held or committed) reservation per slot, full stop, regardless of how many API instances or threads are racing for it at the same moment. That's a different concrete technique than the atomic-update pattern described above, and arguably a cleaner one: the database schema itself makes the double-booking this article opened with structurally impossible, rather than relying on application logic to avoid it correctly every time.
- Live dashboard — browse exam slots, rooms, and examiners, and run the concurrency simulator yourself
- API reference (Swagger) — the hold/commit/release endpoints behind the dashboard
Why This Matters More in Government and Regulated Work
For a regulatory body, exam integrity isn't just a UX concern — it's a defensibility concern. If a candidate disputes being denied a seat that should have been available, or if two candidates are legitimately double-booked into the same room, the organization needs to be able to reconstruct exactly what the system believed was true at every point in time. That's much easier to produce when Dataverse holds a clean record of bookings and outcomes, and a dedicated scheduling service holds an equally clean, purpose-built log of every hold, commit, and release — rather than trying to reconstruct concurrency behavior from Dataverse audit history that was never designed to capture it.
It also changes how exam-season incidents get handled operationally. When availability logic lives in a system built for it, a capacity discrepancy is a data question with a clear answer. When it's spread across plugins and flows layered on top of Dataverse over a few years, it becomes a debugging exercise under time pressure, usually the week registration opens — exactly when there's the least appetite for one.
How to Tell You've Drifted
A few honest signals that exam or appointment scheduling has drifted into being a Dataverse-native problem it was never designed to solve:
- A custom entity has a field literally tracking a running count, like
bookedcountorseatsremaining, updated by more than one plugin or flow. - "Let me check if that seat actually opened up" involves someone manually refreshing a view or re-running a report, because the system's own number isn't trusted.
- Cancellations are handled by a flow that isn't wrapped in the same transaction as the status change it depends on.
- Overbooking incidents get resolved by a human moving candidates around manually rather than the system ever preventing the conflict.
- Room, proctor, and seat capacity are tracked in three different places that occasionally disagree with each other.
Any one of these is a sign the scheduling boundary has blurred. None of them require a rebuild — they require capacity arbitration to move to a scheduling service built to guarantee it, with Dataverse relegated to recording the outcome.
Best Practices Summary
- Let Dataverse own the candidate, the case, and the record of the booking outcome — never live capacity arithmetic.
- Treat availability as a read-check-write that must be atomic, and give that guarantee to a system built to provide it — not a plugin or flow chained across separate operations.
- Design cancellation with the same rigor as booking. Releasing a seat correctly is at least as hard as claiming one, and it's where most capacity bugs actually surface.
- Model room, proctor, and seat capacity as related but independently-arbitrated constraints, not a single count that hides conflicts between them.
- Route every hold, commit, and release through a correlation ID so a capacity discrepancy can be traced to the exact operation that caused it.
- Ask "what would this system say happened if two people clicked at the same second?" for every booking and cancellation path before trusting it at volume.
- Weigh data residency and vendor governance before technical fit. For regulated clients, those constraints often decide between a custom-built scheduling service and a third-party platform before feature comparisons even matter.
Conclusion
A candidate portal built on Power Pages and Dynamics 365 can absolutely deliver self-service exam scheduling well. The mistake isn't building it on that stack — it's asking Dataverse to also be the thing that arbitrates a finite, contested resource under real concurrency, because the candidate record happened to already be there. That job belongs to a system designed to guarantee it, connected to Dataverse deliberately rather than blended into it.
For most clients we work with in government and regulated sectors, that means a purpose-built scheduling service they control, not a bent-to-fit Microsoft module or an external SaaS platform holding regulated candidate data — the data-residency and vendor-governance questions tend to settle that before the technical comparison finishes. It's a bigger upfront build than adding a custom entity. It's also the version that's still correct three exam seasons later.
It costs a little more up front to draw that line — an extra integration point, a conversation about which system decides availability. It pays for itself the first time registration opens at volume and every seat the system says is free actually is.