dversedev

Guide

Migrating from spkl

What maps to what, what changes, and what still needs other tooling.

Migrating from spkl to dverse

A mechanical, step-by-step migration guide. Every transformation is specified with before/after code, and the end state is verifiable: dverse plan exits 0 and a rebuilt deploy performs 0 writes.

Concept map

spkl dverse
spkl.json (plugins, earlyboundtypes sections) dataverse.yaml (environments) + attributes in code (registration)
spkl plugins / spkl instrument dverse apply (or dotnet build -t:Deploy) — but as a diff, not a push
— (no dry run) dverse plan (--json, exit 0 = clean / 2 = changes)
spkl earlybound + crmsvcutil-style generated .cs files dverse model sync → committed dataverse.snapshot.json; classes generated in-memory at compile time
crmsvcutil relationship navigation properties (account.contact_customer_accounts, OrganizationServiceContext tracking) Rels schema-name constants on each model class + explicit one-liners: service.QueryChildren<Contact>(accountRef, Contact.Cols.ParentCustomerId) for 1:N, the lookup column itself for N:1, service.Associate(Account.Rels.<name>, target, related…) for N:N — no tracked context, query cost visible at the call site
CrmPluginRegistrationAttribute (kept in IL) [Step] / [Image] / [CustomApi] (stripped from IL via [Conditional], read by the generator)
Plugin class implementing IPlugin, Execute(IServiceProvider) Plain method taking IStepContext<TEntity>; the IPlugin entry class is generated
Hand-rolled context helpers / base classes (PluginBase etc.) Source-embedded runtime: ctx.Target, ctx.PreImage, ctx.Service, ctx.Trace, ctx.Fail, …
Image registration by hand (Image1, Image1Attributes) Inferred from ctx.PreImage.X accesses; explicit [Image(...)] only as escape hatch
Image1Alias [Image(..., Alias = "...")] — default pre/post; only matters for ctx.Raw consumers and shared helpers expecting a specific alias
Deployment profile in spkl.json (profile, solution) dataverse.yaml per environment; --env / DVERSE_ENV to select
ExecutionOrder Rank = n
Offline / Server flags (supporteddeployment) Deployment = Deployment.ServerOnly / OfflineOnly / Both (default server-only; diffed, so dropping it resets the row)
PRT "Run in User's Context" (impersonatinguserid — spkl doesn't model it) RunAsUser = "<systemuser guid>" (default: calling user). The guid is org-specific: it doesn't port between environments — prefer an environment variable / step config + ctx.ServiceAs(...) for multi-env repos. dverse adopt scaffolds live values with a warning comment
FilteringAttributes = "a,b" (string) Filtering = new[] { Account.Cols.A, Account.Cols.B } (compile-checked)
IsolationModeEnum.Sandbox always sandbox (the only mode that exists online)
[CrmPluginRegistration("Name", "FriendlyName", "Description", "GroupName", ...)] on a CodeActivity [WorkflowActivity(Name = "...", Group = "...")] on the class (Name defaults to the full type name)
Unsecure/secure configuration strings UnsecureConfig = "…" / SecureConfig = "…" on [Step]; optionally a typed record parameter after the context (void H(IStepContext<Account> ctx, MyConfig cfg), [Secure] for the secure string) replaces hand-parsing in the constructor
EntityLogicalName = "none" (steps on global custom actions, custom API messages) [Step("message_name", Stage.PostOperation)] — no entity argument at all; the handler takes the non-generic IStepContext and reads/writes ctx.Raw.InputParameters/OutputParameters
EntityLogicalName = "some_entity" without generated early-bound classes [Step("some_entity", Msg.Update, Stage.PreOperation)] — logical-name form, non-generic IStepContext, no snapshot entry required
spkl webresources (webresources section in spkl.json, per-file uniquename list) webresources: sets in dataverse.yaml (directory + prefix: convention instead of per-file entries); synced by dverse plan/apply — content-hash diff, one PublishXml, env-only resources pruned. Nonconforming legacy uniquenames keep their names via a map: entry (file → uniquename)
spkl download-webresources [/o] dverse webresources pull [--force] — downloads every web resource in the set's solution (plus the prefix/map scope) into the set's local path; never overwrites differing local files without --force
spkl get-webresources (write deployed resources into spkl.json) dverse webresources pull prints a ready-to-paste map: snippet for every resource the sync couldn't otherwise reproduce — nonconforming uniquenames, and types not derivable from the file extension ({ name: ..., type: N }); dataverse.yaml is never edited automatically
— (no PCF support; controls deployed separately with pac pcf push) dverse pcf init scaffolds the standard pcf-scripts project; a pcf: entry in dataverse.yaml makes plan/apply build and deploy the controls — hash-diffed, zero writes when unchanged

Migration steps

0. Prerequisites

Dataverse online only. dverse authenticates via Microsoft Entra ID and registers everything sandbox-isolated — spkl connection strings for on-premises / IFD (AD auth) have no dverse equivalent. If your target is an on-premises org, stay on spkl.

Complete sections 2–4 of getting-started.md: install the CLI, create dataverse.yaml, run dverse auth login, and run dverse model sync --entities <every entity your plugins touch>.

1. Swap package references

Remove from the plugin csproj:

xml
<PackageReference Include="spkl" Version="..." />
<!-- and any spkl.fakes / base-class helper packages -->

Add:

xml
<PackageReference Include="Dverse.Sdk" Version="<pinned version>" />
<ItemGroup>
  <AdditionalFiles Include="..\..\dataverse.snapshot.json" Link="dataverse.snapshot.json" />
</ItemGroup>

Keep Microsoft.CrmSdk.CoreAssemblies and keep your existing .snk — the assembly identity (name + public key token) must stay the same so dverse updates the existing pluginassembly row instead of creating a second one. Add determinism if missing:

xml
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>

Package mode: if the assembly is (or will be) registered as a plugin package (dependent assemblies), the .snk is optional — packages don't require strong-naming. Set <DversePluginPackage>true</DversePluginPackage> and match the existing identity: dverse finds the package by unique name {publisher prefix}_{package id}, so set <DversePackageId> to the nupkg id you registered with spkl/PRT and prefix: in dataverse.yaml to your publisher prefix. Steps still match by Key/natural key/name inside the package — Id-pinning (step 4) applies unchanged. Moving an assembly between classic and package registration is manual in both directions: unregister the old component (steps first), then deploy in the new mode.

Convert non-SDK-style (legacy packages.config) projects to SDK-style first; dverse's generators require the modern compiler (the net462 target itself is fine).

2. Delete generated early-bound files

Delete the crmsvcutil/spkl-generated EarlyBoundTypes.cs (often thousands of lines). The dverse model generator provides Account, Account.Cols.*, and optionset enums from the snapshot with the same Microsoft.Xrm.Sdk.Entity base. Namespace differs: generated types live in Dverse.Model by default — either update using directives, or keep your old namespace (and even old class names) via model: namespace: / model: classNames: in dataverse.yaml, which often makes this step a no-op. If your code references entities not yet in the snapshot, add them to dverse model sync --entities ... and re-run.

Enum naming: dverse names optionset members from the labels of the LCID pinned via model: language: (default English, 1033). When that language is not provisioned in the org, model sync falls back to the organization base language, warns once, and records the effective LCID in the snapshot (labelLanguage:) so the choice is visible in review — identifiers never depend on who ran the sync. Members are PascalCase with punctuation stripped ("Preferred Customer"PreferredCustomer).

Relationship navigation: code using crmsvcutil navigation properties (account.contact_customer_accounts, contact.parentcustomerid_account) or OrganizationServiceContext rewrites to the explicit helpers — service.QueryChildren<Contact>(accountRef, Contact.Cols.ParentCustomerId) (1:N), the lookup column itself (N:1), and service.Associate(Account.Rels.<name>, target, related…) / Disassociate (N:N). The Rels constants carry the relationship schema names from the snapshot, so a deleted relationship breaks the build.

3. Convert each plugin class

Before (spkl):

csharp
[CrmPluginRegistration(
    MessageNameEnum.Update, "account", StageEnum.PreOperation, ExecutionModeEnum.Synchronous,
    "creditlimit", "Block credit limit downgrade", 1, IsolationModeEnum.Sandbox,
    Image1Type = ImageTypeEnum.PreImage, Image1Name = "PreImage",
    Image1Attributes = "creditlimit,statuscode")]
public class BlockCreditLimitDowngrade : IPlugin
{
    public void Execute(IServiceProvider serviceProvider)
    {
        var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
        var factory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
        var service = factory.CreateOrganizationService(context.UserId);
        var tracing = (ITracingService)serviceProvider.GetService(typeof(ITracingService));

        var target = (Entity)context.InputParameters["Target"];
        var preImage = context.PreEntityImages["PreImage"];

        var newLimit = target.GetAttributeValue<Money>("creditlimit");
        var oldLimit = preImage.GetAttributeValue<Money>("creditlimit");
        if (newLimit != null && oldLimit != null && newLimit.Value < oldLimit.Value)
            throw new InvalidPluginExecutionException("Credit limits can't be lowered.");
    }
}

After (dverse):

csharp
using Dverse;
using Dverse.Model;

public partial class AccountPlugins
{
    [Step(typeof(Account), Msg.Update, Stage.PreOperation,
        Filtering = new[] { Account.Cols.CreditLimit })]
    public void BlockCreditLimitDowngrade(IStepContext<Account> ctx)
    {
        if (ctx.Target.CreditLimit < ctx.PreImage.CreditLimit)
            throw ctx.Fail("Credit limits can't be lowered.");
    }
}

Mechanical rules:

  1. Drop : IPlugin and the Execute boilerplate; the method body starts where your real logic started. One method per step; group related handlers in one class per entity.
  2. Every service lookup maps to a context member: IPluginExecutionContextctx.Raw (rarely needed), CreateOrganizationService(UserId)ctx.Service, CreateOrganizationService(null)ctx.SystemService, ITracingServicectx.Trace(...).
  3. (Entity)context.InputParameters["Target"] + GetAttributeValue<...>("col") → typed ctx.Target.Col. Money unwraps to decimal?, optionsets to generated enums — comparisons become plain operators.
  4. context.PreEntityImages["..."]ctx.PreImage.Col. Delete the image registration parameters entirely — the columns are inferred from the accesses. Only if you pass the image somewhere opaque, declare [Image(ImageType.Pre, Account.Cols.X, ...)] (compiler error DVERSE021 tells you exactly when).
  5. Attribute mapping: MessageNameEnum.XMsg.X (string constants — custom messages are plain strings), "account"typeof(Account), StageEnum.PreValidation/PreOperation/PostOperationStage.*, ExecutionModeEnum.AsynchronousMode = ExecutionMode.Asynchronous, ExecutionOrderRank, FilteringAttributes = "a,b"Filtering = new[] { ... } with Cols constants. spkl's step name parameter maps to Name = "..." (keep it only if the historical name matters — otherwise drop it and dverse derives the name from Key as "dverse: {key}"); the description parameter maps to Description = "..." (or drop it). Names must stay unique per assembly (DVERSE024).
  6. throw new InvalidPluginExecutionException(msg)throw ctx.Fail(msg) (traced automatically, and unhandled exceptions are wrapped with a sanitized message + full trace for free).
  7. Delete DateTime.UtcNow reads in favor of ctx.UtcNow (consistent within an execution).

4. Adopt the existing registrations (important!)

dverse matches desired steps against the environment in this order: explicit IdKey (recovered from dverse's name template) → natural key (plugin type, message, entity, stage) when unique on both sides → name. Ambiguity is a hard error, never a guess.

Your spkl-registered steps have neither dverse keys nor dverse names, and they hang off different plugin type names (spkl: one class per plugin = your class name; dverse: a generated Dverse_<Class>_<Method> type). The natural key therefore does not match either — every step you want to keep needs its sdkmessageprocessingstepid pinned via Id = "...".

dverse adopt does that for you. It reads the live registration of the spkl-era assembly (read-only — zero writes) and emits attribute scaffolding with every step's Id pinned and message/stage/mode/rank/filtering/images mapped back onto [Step]/[Image] properties:

bash
dverse adopt --assembly OldCompany.Plugins --out src/MyCompany.Plugins/Adopted.cs

Then, for each scaffolded method, port the real logic from the old class (section 3) into the handler body — every body is a throw new NotImplementedException(...) until you do, so deploying unported code fails loudly instead of silently doing nothing. Steps on entities missing from dataverse.snapshot.json come out commented with a dverse model sync TODO; keys are synthesized from the live step names and are yours to rename before the first apply (afterwards they are the steps' identity).

Step names and image aliases are preserved. The scaffold pins each live name via Name = "..." and each live image alias via Alias = "...", so the first apply neither renames your steps nor your images (drop an override to move onto the dverse: {key} / pre/post defaults) — the existing rows are updated in place (rebinding them to the generated plugin types; ids, statistics and async queues survive). Exceptions, each marked with a NOTE/comment in the scaffold: live names shared by more than one step fall back to the name template (dverse names must be unique per assembly), Both images are split into separate pre/post rows whose halves take the default aliases (one alias cannot name two images — DVERSE025), and extra images of the same kind come out commented (dverse registers one pre and one post image per step). For those extras the diff matches the row whose alias the code names (falling back to the first, ordinally) and deletes the leftover rows on the first apply — a matched step's images are code-owned field state, so this happens under --no-prune too; merge the extra columns into the kept [Image] before applying if the handler needs them.

This works even when your new project's assembly has a different name than the adopted one: plan/apply reads Id-pinned steps regardless of which assembly's plugin types they currently hang off, and the first apply rebinds them onto the new assembly's types. The old assembly row and its (now empty) plugin types are out of scope for the diff and are never touched — decommission them manually (Plugin Registration Tool or a DELETE pluginassemblies(<id>) via the Web API) once the cutover is verified.

Image halves that are invalid for the step come out commented in the scaffold instead of live attributes — a Both or post-registered image on a PreValidation/PreOperation step (the post half never populates at runtime), or a pre-image on Create. They were dead registration data; the first apply deletes the dead env-side half.

Alternatives, when preserving the exact rows doesn't matter:

  • Clean cutover: skip adopt, write the handlers fresh, run dverse apply — it creates the new steps alongside the old ones and prunes the stale spkl-era steps and plugin types on the assembly in the same pass (apply deletes env-only components on this assembly by default; pass --no-prune=steps to keep them, or a bare --no-prune for every kind). One deploy, no window where logic is missing, but the step rows are recreated (statistics/ids reset; carry configuration over by declaring it in code — UnsecureConfig/SecureConfig on the [Step]).
  • Pin by Id manually: the fallback adopt automates — set Id = "<sdkmessageprocessingstepid guid>" on individual [Step] attributes yourself (find the guids in the Plugin Registration Tool or via sdkmessageprocessingsteps in the Web API).

After the cutover, dverse plan must exit 0. Put it in CI to keep it that way.

5. Replace the deployment pipeline

Before After
spkl plugins <path> /p:profile dverse apply --assembly <dll> --env <name> or dotnet build -t:Deploy
manual Plugin Registration Tool edits forbidden by convention — the code is the source of truth; dverse plan detects drift (exit 2)
re-run spkl earlybound dverse model sync --entities ... (commit the snapshot diff)

6. Verify the migration

bash
dotnet build                                   # all DVERSE diagnostics green
dverse apply --assembly <dll>                     # cutover
dverse plan  --assembly <dll>                     # review the diff first — deletions included
dverse plan  --assembly <dll>                     # MUST exit 0 with "0 to add, 0 to change, 0 to delete"
dotnet build && dverse plan --assembly <dll>      # rebuild → plan MUST still exit 0 (determinism check)

Then exercise one plugin end to end (trigger the operation, check plugintracelogs — dverse writes enter/exit traces prefixed with the step key).

Gotchas

  • Do not run spkl and dverse against the same assembly concurrently. They will fight over step names and images. Migrate an assembly completely, then retire spkl for it.
  • apply deletes all env-only steps/types on the assembly by default, including ones a colleague registered by hand. Always read the plan output before pruning.
  • Secure/unsecure step configuration is desired state like everything else: declare it with UnsecureConfig/SecureConfig on the [Step] and (optionally) take a typed config record parameter after the context instead of parsing constructor strings by hand. Three caveats: values live in source and the manifest — anyone with repo read sees the "secure" value, so put vault references there, not raw secrets. Reading secure configs back requires an admin-level caller (sdkmessageprocessingstepsecureconfig is admin-only readable); a non-admin caller gets a warning and a presence-only fallback: adding or removing a SecureConfig still plans correctly (and plan stays clean when both sides have one), but value drift in the environment cannot be detected — use an admin identity when that matters. And dverse only reads the steps of the assembly it deploys: a secure-config row shared with a step on another assembly (possible with bespoke programmatic registration; PRT/spkl never do this) is never rewritten in place — value changes bind a fresh row — and its deletion is rejected by the platform and left in place with a warning, so clean it up manually once nothing references it.
  • Multiple [Step] attributes on one method (e.g. Create + Update) are a first-class pattern: use ctx.Current — the submitted value, falling back to the pre-image, and on Create simply the Target. Its reads register a pre-image only on the steps that can have one, so no DVERSE002 gymnastics. ctx.MessageName lets you guard message-specific logic (including direct ctx.PreImage access, which is validated at runtime on such handlers).
  • Mixed plugin + CodeActivity assemblies migrate fine: prune never touches workflow activity plugin types (isworkflowactivity = true) whose class still exists in the built DLL, so unattributed activities are left alone and only reported (! workflow activity ... — left alone). Put [WorkflowActivity(Name = ..., Group = ...)] on an activity class to make it fully managed — dverse then creates the plugintype row and keeps name/friendlyname/group in sync. Deleting or renaming the class itself removes its type from the DLL; since the platform rejects assembly updates while the stale row remains, dverse deletes that row under prune (the default) before the content changes — check the plan first if live processes might still reference the activity (--no-prune refuses the apply instead of half-applying).
  • dverse registers images with aliases pre/post by default. Code that reads context.PreEntityImages["PreImage"] through ctx.Raw will not find that alias — use ctx.PreImage instead, or keep the historical alias with [Image(..., Alias = "PreImage")] (what dverse adopt scaffolds for foreign aliases).
  • spkl steps registered with entity "none" (global custom actions, custom API messages) map to the entity-less form [Step("message_name", stage)], not to a typeof(...); dverse adopt scaffolds them that way automatically. The environment row genuinely has no sdkmessagefilterid — a plan against a hand-registered global step matches on (plugin type, message, no entity, stage) like any other natural key.

What dverse deliberately does not do

Very little, these days. Beyond spkl's scope (plugin registration, web resources), dverse also deploys declared schema (tables/columns/option sets — schema:), forms and views as committed files (forms: / views:, captured with dverse forms pull / views pull), reference data (seed:), PCF controls, environment variables, and model-driven apps with their sitemaps from committed customizations fragments (apps: — dverse packs and imports the staged solution itself). A repo shaped like solution-layout.md deploys end to end with dverse apply, no pac and no provisioning scripts.

What still needs other tooling:

  • Full solution unpack/pack round-trips and managed export for shipping — the Power Platform CLI (pac solution unpack|pack|export) remains the tool for whole-solution ALM. Managed export from dverse is on the backlog.

Both coexist with dverse cleanly — dverse never touches components it doesn't manage.