dversedev

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
$ 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-cascade switches 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.
  • Skip is 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
$ 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=500Money, decimal, integer — a dot for the decimal separator.
statuscode=1Choice, state and status columns, by value.
donotemail=trueBoolean.
createdon=2026-07-30T12:00:00ZDate 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, -dPrint the process id and wait for a debugger.
--callsThe full query behind every read, plus its FetchXML.
--traceAlso show the runtime's enter/exit framing. What your handlers traced is shown anyway.
--no-cascadeDo not re-enter the pipeline for writes the plugins make.
--no-asyncQueue asynchronous steps without running them.
--max-depth <n>Cascade depth limit. Default 8.
--no-buildUse 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.