The dverse solution layout
A best-practice repository shape for a real Dataverse/Dynamics 365 project deployed entirely
with dverse — one command, no provision.ps1, no pac. It scales from a single plugin
assembly to a multi-team codebase with several assemblies, web resources, PCF controls,
declared schema, forms/views, app modules, and seed data.
New here? Start with getting-started.md — it walks one project from empty folder to deployed plugin. This document is the organizing guide: where everything lives, how many assemblies to have, and how deploys compose across environments.
The tree
your-solution/
├─ dataverse.yaml # environments, model config, and what each env deploys — committed
├─ dataverse.local.yaml # personal overrides (a dev org, a default env) — gitignored
├─ dataverse.snapshot.json # metadata snapshot for early-bound models — committed, `dverse model sync`
├─ schema/ # declared tables/columns/option sets (`dverse schema adopt` bootstraps)
│ └─ core.yaml
├─ seed/ # reference-data rows (referenced from `seed:` in dataverse.yaml)
│ └─ settings.yaml
├─ src/
│ ├─ MyCo.Plugins.Core/ # classic strong-named assembly — the default
│ │ ├─ MyCo.Plugins.Core.csproj
│ │ ├─ MyCo.Plugins.Core.snk
│ │ └─ AccountPlugins.cs …
│ └─ MyCo.Plugins.Integration/ # a second assembly — package mode (has NuGet dependencies)
│ ├─ MyCo.Plugins.Integration.csproj
│ └─ ErpSyncPlugins.cs …
├─ tests/
│ ├─ MyCo.Plugins.Core.Tests/
│ └─ MyCo.Plugins.Integration.Tests/
├─ webresources/
│ ├─ src/ # authored TS/JS/HTML (your build input)
│ └─ dist/ # build output dverse deploys (path: in dataverse.yaml)
├─ pcf/
│ └─ CreditGauge/ # a PCF control project (pac/dverse pcf init)
├─ forms/ # committed formxml, {entity}/{type}/{name}.xml (`dverse forms pull`)
│ └─ account/main/Account.xml
├─ views/ # committed savedquery xml, {entity}/{name}.xml (`dverse views pull`)
│ └─ account/Active Accounts.xml
├─ apps/ # app module + sitemap customizations fragments ({{webresource:…}} tokens)
│ └─ sales/{appmodule,sitemap}.xml
├─ ribbons/ # committed RibbonDiffXml, {entity}.xml + application.xml
│ └─ account.xml
├─ commands/ # modern command-bar buttons (appaction rows) as YAML
│ └─ account/your_account.approve.yaml # `dverse commands pull` writes {entity}/{uniquename}.yaml
└─ .github/workflows/deploy.yml # CI: drift gate on PRs, apply on mainNot every repo needs every folder. A plugins-only project is just dataverse.yaml, the
snapshot, and src/ — add folders as you adopt each component kind.
Naming and boundaries
dverse owns exactly what you declare, and it recognizes "yours" by a publisher prefix. Keep these consistent across the whole repo:
- One publisher prefix (
prefix:indataverse.yaml, e.g.myco). Every table, column, option set, web resource, PCF control, and env variable dverse creates is prefixed with it — and prefix-scoping is how prune/adopt know what to manage versus leave alone. - One solution per repo (
solution:indataverse.yaml). Web resources conventionally get their own solution (solution:on thewebresources:entry) so registration churn and web UI churn don't share a version history, but a single solution is fine to start. - Step keys are stable identity. Give a
[Step]aKeywherever you want to rename freely: the deterministic step name derives from it, so moving a handler between classes or assemblies keeps the same environment row. - Test data gets a prefix. Have integration tests create only recognisably-named records and delete them in teardown — never real data.
How many assemblies?
Start with one. Split only when you have a concrete reason:
| Reason | Split |
|---|---|
| A NuGet dependency (Newtonsoft.Json, a shared lib) | a package-mode assembly (package: true) — the classic single assembly can't carry dependencies |
| Team / ownership boundaries, independent release cadence | one assembly per team, each its own src/ project |
| Isolation of risky/experimental handlers | a separate assembly so a bad deploy is contained |
Don't split for tidiness — every assembly is another build, another pluginassembly row, and
another thing to reason about. Classic (strong-named, single DLL) is the default; package mode
is opt-in per assembly. See getting-started, "Plugin packages", for what package mode changes.
Deploying the whole repo
dverse plan / dverse apply with no --assembly deploy the entire repo in one pass:
every assembly listed under assemblies:, plus the shared environment-level components (schema,
web resources, PCF, env vars, seed) diffed and written once.
The deploy is scoped to the directory you run from. At the repo root that is everything
configured; inside one project's directory it is just that project — sibling assemblies, web
resource sets, and PCF projects outside the current directory are neither built nor diffed (a
note on stderr lists what was skipped). Environment variables have no directory and always stay
in scope — the deployed assembly's manifest is validated against them. --all restores the
full configured scope from anywhere.
# dataverse.yaml
default: dev
model:
entities: [account, contact, myco_contract]
environments:
dev:
url: https://myco-dev.crm.dynamics.com
solution: mycosolution
prefix: myco
assemblies: # the repo's plugin assemblies, deployed together
- src/MyCo.Plugins.Core # classic — a bare path is the simplest form
- path: src/MyCo.Plugins.Integration
package: true # this one carries NuGet dependencies
webresources:
- path: webresources/dist
prefix: myco_/
solution: mycowebresources
build: npm run build
pcf:
- path: pcf/CreditGauge
ribbons:
- path: ribbons # {entity}.xml + application.xml; the file IS the whole ribbon
commands:
- path: commands # *.yaml; appaction rows, field-level diffed and prefix-pruned
environmentVariables:
myco_ApiEndpoint: https://erp.example.comdverse plan # from the repo root: builds every assembly, shows the combined diff
dverse apply # deploys all assemblies + web resources + PCF + env vars in one run
dverse plan --json # machine-readable: an `assemblies[]` array + the env-level components once
cd src/MyCo.Plugins.Core
dverse apply # scoped: deploys only this project (no sibling builds or diffs)
dverse apply --all # full configured scope, regardless of the current directoryAn assemblies: entry is a path to a .csproj, a directory containing one, or a prebuilt
.dll; { path, package, packageId } is the long form. A repo with no plugin assembly at
all — only web resources, schema, or env vars — is a valid environment-only deploy: bare
dverse plan/apply does the right thing with an empty assemblies:.
The MSBuild targets (dotnet build -t:Deploy, -t:Plan) still operate on a single project
(they pass --assembly), so they remain the right choice inside one project's directory. Use
the bare CLI at the repo root for a whole-repo deploy.
Version bumps compose. When content changed but a version didn't increase,
applybumps each assembly's ownAssemblyVersion(and each PCF control's manifest), rebuilds, and re-plans — per assembly, so one changed project doesn't perturb the others. See getting-started "Versions increase automatically."
Environments & CI
One environment is the normal case. dverse converges the environment you develop in — your
sandbox. Getting from there to production is unchanged: export a managed solution and import it
downstream, the way Power Platform ALM already works. dverse does not apply to production, and it
never touches components it does not manage. Most repositories therefore declare exactly one
environments: block and never pass --env at all.
dataverse.local.yaml(gitignored) holds a developer's personal org anddefault:, so everyone can point at their own sandbox without touching the committed config. This — not a second block indataverse.yaml— is how a team with several sandboxes works.- More than one block is worth it when you genuinely deploy from source to more than one
environment: same shape, different URLs and solutions, with
--envchoosing. A shared integration org alongside personal sandboxes is the usual reason. - Service-principal auth in CI: set
DVERSE_TENANT_ID/DVERSE_CLIENT_ID/DVERSE_CLIENT_SECRET(never commit them); dverse uses them automatically. Interactivedverse auth login(device code) is for humans. - Drift gate on PRs:
dverse planexits2when the environment doesn't match the branch (0= clean,1= error) — fail the job on2. Ordotnet build -t:Plan -p:DversePlanFailOnChanges=trueper project. See getting-started for the full CI recipe. - Apply on main only, serialized per environment, with the service-principal secrets wired in.
What deploys, in one pass
dverse apply converges everything above in dependency order: declared schema first (steps may
bind to its tables), then assemblies/steps/APIs, environment variables, web resources, PCF,
views, forms, ribbons, commands, app modules, reference data — and finally exactly ONE
PublishXml covering what the apply touched. Capture designer work back into the repo with dverse schema adopt,
dverse forms pull, dverse views pull, dverse commands pull, and dverse webresources pull.
Each of those sets is also a prune boundary: dverse deletes environment-only components only
inside a scope the repo declares (a webresources:/commands: set's prefix:, or its own
reserved namespaces). Declare nothing for a kind and it manages nothing — the safe default when
adopting dverse into an org whose publisher prefix is older than the repo. Turn prune off for one
kind with --no-prune=commands (or prune: false on the individual set) instead of dropping it
everywhere with a bare --no-prune.
Not covered by dverse today, so they still need other tooling or a manual step: managed solution export, security roles, and custom-API parameter metadata.