Guide
Debugging, without
the round trip.
dverse debug runs the steps registered for a message on your
machine, against real data, with a debugger attachable. Reads reach the environment.
Writes do not.
What it replaces
Plugins execute on a server you cannot attach to, so the documented way to debug one is the Plug-in Profiler: install a solution into the environment, switch a step to profiled, trigger the event in the app, download the captured context, then attach Visual Studio to the Plug-in Registration Tool and replay it. Every iteration is a round trip through the org.
dverse takes the other route. Your assembly is already built locally, the registration is already described by the manifest, and the context is something dverse constructs anyway — so it constructs one here, and runs your handler in this process. Nothing is installed into the environment, and there is nothing to profile, capture or replay.
A run
Say what you want to happen. dverse builds the project, works out which steps that message would fire, and runs them in the platform's order.
$ dverse debug update account 3f2a4c00-… creditlimit=500
Simulating against 'dev' (https://contoso-dev.crm4.dynamics.com) — reads are live, writes are not.
✓ account Update · 3f2a4c00…
PreOperation
✓ accountplugins-blockcreditlimitdowngrade-update-20 0.9 ms
✓ accountplugins-derive-creditutilization-update-20 1.4 ms
| recomputed utilisation from 0.42 to 8.20
PostOperation
✓ accountplugins-schedulefollowup-update-40 2.3 ms
| enqueued job 'account-followup'
▸ asynchronous — queued during the run, executed after it
✓ dverse-job-dispatcher · queued job 92.0 ms
| job:account-followup: follow-up complete (attempt 1)
Changes (simulated — nothing was written)
~ account 3f2a4c00…
creditlimit 12,000.00 → 500.00
dverse_creditutilization 0.42 → 8.20
+ task a91b7c22…
subject "Review lowered credit limit"
dverse's own async queue: 1 job row created and consumed.
Dataverse traffic — 3 reads, 0 writes, 418 ms · slowest: accountplugins-blockcreditlimitdowngrade-update-20 286 ms
286 ms 1 row accountplugins-blockcreditlimitdowngrade-update-20 Retrieve account 3f2a4c00… [creditlimit]
89 ms 12 rows accountplugins-derive-creditutilization-update-20 RetrieveMultiple contact [statecode] where parentcustomerid = 3f2a4c00…
43 ms 0 rows dverse-job-dispatcher RetrieveMultiple account [dverse_teststamp] where accountid = 3f2a4c00… top 1
The reads in that run are real: the guard genuinely queried your environment for a
duplicate. The writes are not — the account was never created, and the traffic line says
0 writes because that is a fact about the run, not a promise.
Reads are live; writes are yours alone
Between your handler and Dataverse sits an overlay. A read it has not seen goes to the environment and is cached. A write is kept locally — and read back, so a handler that updates a row and retrieves it again sees its own change while the environment still holds the old one.
- Rows created during the run appear in later queries the run makes, as they would have.
- Rows deleted during the run disappear from them.
- A message the overlay does not model is refused by name rather than forwarded. Being helpful there could mutate a real environment during what you were told is a dry run.
The whole pipeline, not one handler
Stages run 10 → 20 → the write → 40, by rank within each stage, and the target instance is
shared across them — so a PreOperation step mutating ctx.Target
changes both what gets written and what later steps see.
- Writes your plugins make re-enter the pipeline, so step-on-step interaction shows up. Depth is capped at 8, matching the platform;
--no-cascadeswitches it off. - Asynchronous steps run, after the synchronous work — which is when the platform runs them too. Their images are captured when they are queued, not when they run. A failure there does not fail the operation, because by then the transaction has committed.
- Filtering is honoured on Update and ignored elsewhere, exactly as the platform does it.
Skipis honoured. A step that writes to its own row and skips itself terminates here too.
Attaching a debugger
--debug prints the process id and waits. Set a breakpoint in your
handler, attach, and it is hit on the first run — no profiler, no replay file, and the call
stack is your code rather than a generated entry point.
dverse debug update account 3f2a4c00-… creditlimit=500 --debug
dverse builds a separate, symbol-bearing copy into
obj/dverse-debug/ for this. The assembly destined for your
environment is built without a PDB on purpose — a debug directory in the PE would make
a registration-only edit change the assembly hash and defeat the upload skip. The copy
you debug is never uploaded, and the one that is stays byte-identical.
Custom APIs and command buttons
A custom API is just another message, so it runs the same way — with the steps registered
on its own message around it, and the response printed. Since a
[Command] button's backend is a custom API, this debugs
the button without opening a browser.
$ dverse debug api contoso_Recalculate --on account:3f2a4c00-… DryRun=true
✓ contoso_Recalculate
MainOperation
✓ contoso_Recalculate 11.7 ms
PostOperation
✓ generalapis-auditrecalculate-40 1.2 ms
Response
Recalculated 12
Message "12 accounts recalculated"
No data changes.
Arguments are matched to the declared parameters, so a name that is not one is refused
rather than dropped. That matters: Dataverse silently discards an undeclared parameter and
the handler sees null, which reads like a backend bug.
Optional<T> is preserved too — omitting a key is a
different thing from passing null, and this is the only easy way to exercise it.
Writing values
Columns are typed from dataverse.snapshot.json, so write them
plainly and dverse converts. A Money column has to arrive as
Money, not a decimal, or your typed model property reads null and
the handler takes a branch it never would in production.
creditlimit=500 | Money, decimal, integer — a dot for the decimal separator. |
|---|---|
statuscode=1 | Choice, state and status columns, by value. |
donotemail=true | Boolean. |
createdon=2026-07-30T12:00:00Z | Date and time, ISO 8601. |
primarycontactid=contact:<guid> | Lookup. The table is required — a polymorphic lookup could be either of two, and guessing would send the handler somewhere real but wrong. |
name= | Clears the column. |
name="" | An actually-empty string. |
Options
--debug, -d | Print the process id and wait for a debugger. |
|---|---|
--calls | The full query behind every read, plus its FetchXML. |
--trace | Also show the runtime's enter/exit framing. What your handlers traced is shown anyway. |
--no-cascade | Do not re-enter the pipeline for writes the plugins make. |
--no-async | Queue asynchronous steps without running them. |
--max-depth <n> | Cascade depth limit. Default 8. |
--no-build | Use the existing build output. |
--env <name> | Which environment to read from. |
Exit codes: 0 the operation succeeded ·
2 a plugin rejected it · 1 error.
What it does not do
It runs your plugins, not the platform. Business rules, classic workflows, real-time flows, duplicate detection, auto-numbering, calculated and rollup columns, cascade and share behaviour, and privilege checks are not simulated. Plugins registered in the environment from other assemblies are not executed — dverse does not have them.
Every run ends with what it could not do faithfully, and that section is never suppressed. A simulator that quietly skips things is worse than none.
One consequence worth stating plainly: a run reads live data, so two runs are not
guaranteed to agree — if the underlying rows changed, the simulation changes with them.
A dverse debug run is a debugging tool, not a test.
For tests, the SDK ships TestStep.For<T> and
InMemoryOrganizationService, which are deterministic by
construction.