Based on my personal experience (I have been working on Dynamics CRM / Power Platform since 2009 and have delivered dozens of projects), today a typical middle-sized CRM / Model Driven App project contains:
- A data model made of roughly 30 tables.
- A few dozen basic Web Resources (images, form JS, command bar JS, theme assets, etc.).
- One or more Plugin Packages.
- One or more HTML/React Web Resources.
- One or more PCF controls.
- One or more Power Automate flows (with related Connection References).
- One or more Model Driven Apps.
- Roles, field security profiles… everything concerning the security model.
This is the typical content of a middle-sized project; obviously there are smaller ones, but also much larger and more complex initiatives, each with its own peculiarities.
Looking at the structure of my typical medium project, over time I have tried to standardize ALM processes, industrializing my delivery approach, with the goal of improving (making more efficient) the way I work and the way the people working with me operate.
🧱 Solutions
This industrialized approach also involves defining the Dataverse solutions that compose the application in a consistent way: I prefer a layered approach where solutions are organized by component type, with a strict and well-defined dependency direction.
1st principle: Each solution contains only and exclusively components that depend on components present in lower-level solutions.
Following the 1st principle, the solutions I typically define are the following:
Layer | Name | Description |
---|---|---|
1 | 0100 - WebResources | Contains all project Web Resources (images, form and command bar JS, theme, assets, HTML pages, etc.). |
2 | 0200 - PCF | Contains custom PCF controls implemented for the project. |
3 | 0300 - Data Model | Contains the system data model (tables, columns, forms, views). |
4 | 0400 - Business Logic | Contains Plugin Packages, Steps, Custom APIs, Power Automate flows—everything that is business logic. |
5 | 0500 - Other | Contains apps, sitemaps, security roles(1), and the rest of the solution components. |
(1) For roles a specific discussion is needed: if system forms and views depend on roles, then roles go into the Data Model solution; otherwise they go into "Other".
Layering note: the order of the layers is based on import prerequisites (a form may require a Web Resource / PCF already present), not on the functional responsibility flow (UI consuming the data model). Web Resources and PCF controls do not create formal dependencies toward tables/columns until they are attached to forms, so they can be imported earlier.
Naming convention note: Each solution starts with a 4-digit number. The first 2 digits represent the current layer. The second two digits are used when the solution becomes too large and you decide to fragment its content. Example: suppose you have N React Web Resources (which are typically heavier). You might decide to have a separate solution for each one. In that case I would name them:
- 0100 - WebResources - Base
- 0101 - WebResources - Home Page
- 0102 - WebResources - Monitoring Dashboard
- 0103 - WebResources - Customer Details
- …
🔢 Source Versioning
In my projects, both the source code of the custom components I build (Plugins, Web Resources, PCF controls, etc.) and the solutions described earlier—downloaded and unpacked into their minimal components through pac solution sync
—are versioned in a Git repository (Azure DevOps / Visual Studio or GitHub, based on client preference).
However, when we download solutions locally to version them, we have a problem: they contain all our customizations, including Web Resources (compiled, in the case of React-based Web Resources) and .nupkg
files of our Plugin Packages.
Anyone who has studied ALM theory knows one thing:
2nd principle: The repository must not contain artifacts—only source code.
To avoid storing in the repo what are effectively duplicates of already existing objects (think simple Web Resources) or artifacts (the HTML/JS/CSS of React Web Resources, or plugin .nupkg
files), mapping files come to the rescue (we already discussed them in the article "Relative Paths in Solution Mapping Files" and in "When Plugin Packages Break Your Mapping Files (and How to Fix It)").
The rules for composing and incrementing the build version number are described in the "Version Format" paragraph in the Pipeline section.
🧩 And what about PCF?
For the PCF solution the technique I use is slightly different. While in other cases I first create the solution in Dataverse and then obtain a local copy via pac solution clone
/ pac solution sync
, for PCF I:
- Create a solution directly locally using
pac solution init
. - Add the PCF project(s) to the solution as references using
pac solution add-reference
. - Modify the
.cdsproj
file adding thePropertyGroup
shown below so that I get a Managed solution if I compile in Release mode, and an Unmanaged one if I compile in Debug mode.
<PropertyGroup>
<SolutionPackageType Condition="'$(Configuration)' == 'Debug'">Unmanaged</SolutionPackageType>
<SolutionPackageType Condition="'$(Configuration)' == 'Release'">Managed</SolutionPackageType>
<SolutionFileVersion></SolutionFileVersion>
<SolutionPackageEnableLocalization>false</SolutionPackageEnableLocalization>
</PropertyGroup>
After that, I just compile the solution in Release mode and push it to Dataverse using pac solution import
… and PCF controls are up and running.
🚀 Pipeline
With such an organization of solutions, if we want to manage releases in a deterministic and reproducible way, we should build a build pipeline that performs the following steps:
- Define the version number associated with the current build (
Build.BuildNumber
). - Compile the React Web Resources.
- Compile the projects related to plugin DLLs to generate the
PluginPackage
(.nupkg
). - Update the solutions' versions setting them to
Build.BuildNumber
. - Compile the PCF solution (0200 - PCF) in
Release
mode. - Compile the remaining Dataverse solutions in
Release
mode, retrieving:- Simple Web Resources from the folder where they reside in the repo.
- React Web Resources from the folder containing the built output generated in step #2.
- Plugin packages from the
bin\Release
subfolder of the corresponding project.
🧮 Version Format
The value used for Build.BuildNumber
(or equivalent runner number) must follow the format <major.minor.patch.build>
.
The version number of the .nupkg
(Plugin Package) is normally not relevant for import: the environment accepts the update as long as existing plugin types/contracts are not broken. However, the version MUST change (major or minor) when one or more plugin classes previously deployed are physically removed; otherwise you risk hitting the following error:
Exception type: System.ServiceModel.FaultException[Microsoft.Xrm.Sdk.OrganizationServiceFault]
Message: Plugin Assemblies import: FAILURE. Error: Plugin: 'NAME OF PLUGIN ASSEMBLY', Version=<0.1.0.0> ... caused an exception.: Existing plug-in types have been removed. Please update major or minor verion of plug-in assembly. ...
🔁 Back to the pipeline
Theoretically implementing such a pipeline seems quite trivial.
In reality, when you try to set it up you hit several problems:
- Passing the version number to solutions at compile time (via
dotnet build
, as we would for any other .NET project) does not work. The version number passed as a parameter is not reflected in the actual solution version. The only way to make the solution actually reflect the current build version is to add a PowerShell script that physically modifies the Solution.xml files, changing theVersion
node, manually or through thepac solution version
command. - Mapping files do not work with Plugin Packages. Or rather… they work during sync, but not during build. You need something that manually copies the
.nupkg
files generated by compiling the plugin DLLs into the solution folder before building the solution itself. - The
.nupkg
files generated by the DLL build, moreover, have a different name than expected by the Dataverse solutions. So you also need a rename step "in the middle," before or after the copy.
' Package generated by the DLL project build
Greg.Plugins.1.0.0.nupkg' "Expected" name by the Dataverse solution
nn_Greg.Plugins.nupkg
I tried N different ways to build a general purpose pipeline that performed all necessary steps, overcoming the problems listed above, but honestly none of the solutions I arrived at ever fully satisfied me.
Too many variables inside the pipeline code.
Too many steps to manually modify project by project.
…until…
🚨 Betim to the rescue 🚨
My friend Betim Beja suggested I try using his project templates, completely open source and available on GitHub.
For me, a turning point.
His AlbanianXrm.CDSProj.Sdk
is an SDK-style Visual Studio project template that can be used to replace the standard definition of .cdsproj
projects automatically generated by pac solution clone
/ pac solution sync
.
Compared to the "original" Dataverse template, it drastically simplifies managing all the issues I listed above.
⁉️ How to use it
After creating your solution inside Dataverse (manually, or via pacx solution create
):
Step 1. Clone the solution locally using pac solution clone
.
Step 2. Open the file solutionname.cdsproj
, which should look similar to the following:
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<PowerAppsTargetsPath>$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps</PowerAppsTargetsPath>
</PropertyGroup>
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" />
<Import Project="$(PowerAppsTargetsPath)\Microsoft.PowerApps.VisualStudio.Solution.props" Condition="Exists('$(PowerAppsTargetsPath)\Microsoft.PowerApps.VisualStudio.Solution.props')" />
<PropertyGroup>
<ProjectGuid>5aa1879b-b4a3-4734-bcad-d964d642cdff</ProjectGuid>
<TargetFrameworkVersion>v4.6.2</TargetFrameworkVersion>
<!--Remove TargetFramework when this is available in 16.1-->
<TargetFramework>net462</TargetFramework>
<RestoreProjectStyle>PackageReference</RestoreProjectStyle>
<SolutionRootPath>src</SolutionRootPath>
</PropertyGroup>
<!--
Solution Packager overrides, un-comment to use: SolutionPackagerType (Managed, Unmanaged, Both)
Solution Localization Control, if you want to enabled localization of your solution, un-comment SolutionPackageEnableLocalization and set the value to true. - Requires use of -loc flag on Solution Clone or Sync
-->
<!--
<PropertyGroup>
<SolutionPackageType>Managed</SolutionPackageType>
<SolutionPackageEnableLocalization>false</SolutionPackageEnableLocalization>
</PropertyGroup>
-->
<ItemGroup>
<PackageReference Include="Microsoft.PowerApps.MSBuild.Solution" Version="1.*" />
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\.gitignore" />
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\bin\**" />
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\obj\**" />
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\*.cdsproj" />
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\*.cdsproj.user" />
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\*.sln" />
</ItemGroup>
<ItemGroup>
<None Include="$(MSBuildThisFileDirectory)\**" Exclude="@(ExcludeDirectories)" />
<Content Include="$(SolutionPackageZipFilePath)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.Common.targets" />
<Import Project="$(PowerAppsTargetsPath)\Microsoft.PowerApps.VisualStudio.Solution.targets" Condition="Exists('$(PowerAppsTargetsPath)\Microsoft.PowerApps.VisualStudio.Solution.targets')" />
</Project>
Step 3. Remove all its content and simply replace it with the following:
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="AlbanianXrm.CDSProj.Sdk/1.0.4">
<PropertyGroup>
<SolutionPackageType Condition="'$(Configuration)' == 'Debug'">Unmanaged</SolutionPackageType>
<SolutionPackageType Condition="'$(Configuration)' == 'Release'">Managed</SolutionPackageType>
<SolutionFileVersion>$(FileVersion)</SolutionFileVersion>
</PropertyGroup>
</Project>
And you're done.
The SolutionFileVersion
node performs the magic of automatically managing the solution version based on the value passed via dotnet build /p:FileVersion=1.0.1.1
. No manual edits required.
🕸️ And the web resources?
If the solution you are managing is the one that contains the web resources, add the mapping file:
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="AlbanianXrm.CDSProj.Sdk/1.0.4">
<PropertyGroup>
<SolutionPackageMapFilePath>$(MSBuildThisFileDirectory)map.build.xml</SolutionPackageMapFilePath>
<SolutionPackageType Condition="'$(Configuration)' == 'Debug'">Unmanaged</SolutionPackageType>
<SolutionPackageType Condition="'$(Configuration)' == 'Release'">Managed</SolutionPackageType>
</PropertyGroup>
</Project>
Which, in my case, looks like this (nn
is the publisher prefix):
<?xml version="1.0" encoding="utf-8"?>
<Mapping>
<Folder map="WebResources\nn_" to="..\..\..\..\..\..\src\WebResources\nn_" />
</Mapping>
📦 Nice… but what about the .nupkg
files?
Betim thought of that too. If you want to avoid the whole annoying process of:
- Manually compiling plugin DLLs to generate
.nupkg
files. - Renaming them one by one into the name "expected" by the Dataverse solution.
- Moving them one by one into the appropriate folder where
dotnet build
will look when rebuilding the solution (managed or unmanaged).
Just add an ItemGroup
like the following to the project:
<ItemGroup>
<ProjectReference Include="..\..\src\Greg.Plugins\Greg.Plugins.csproj" />
</ItemGroup>
Referencing the project created via pac plugin init
, and that's it. The project itself, during build, will do the dirty work for you.
Below is a complete file example, where I also add a few extra parameters at my discretion:
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="AlbanianXrm.CDSProj.Sdk/1.0.4">
<PropertyGroup>
<SolutionPackageMapFilePath>$(MSBuildThisFileDirectory)map.build.xml</SolutionPackageMapFilePath>
<SolutionPackageType Condition="'$(Configuration)' == 'Debug'">Unmanaged</SolutionPackageType>
<SolutionPackageType Condition="'$(Configuration)' == 'Release'">Managed</SolutionPackageType>
<SolutionFileVersion>$(FileVersion)</SolutionFileVersion>
<!-- other properties added to manage logging and zip file generation -->
<SolutionPackageEnableLocalization>true</SolutionPackageEnableLocalization>
<SolutionPackageErrorLevel Condition="'$(Configuration)' == 'Debug'">Verbose</SolutionPackageErrorLevel>
<SolutionPackageErrorLevel Condition="'$(Configuration)' == 'Release'">Info</SolutionPackageErrorLevel>
<SolutionPackageZipFilePath Condition="'$(SolutionFileVersion)' != '' AND '$(Configuration)' == 'Debug'">$(OutputPath)$(MSBuildProjectName)_v$(SolutionFileVersion)_unmanaged.zip</SolutionPackageZipFilePath>
<SolutionPackageZipFilePath Condition="'$(SolutionFileVersion)' != '' AND '$(Configuration)' == 'Release'">$(OutputPath)$(MSBuildProjectName)_v$(SolutionFileVersion)_managed.zip</SolutionPackageZipFilePath>
<SolutionPackageZipFilePath Condition="'$(SolutionFileVersion)' == ''">$(OutputPath)$(MSBuildProjectName)_v$(SolutionFileVersion).zip</SolutionPackageZipFilePath>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Greg.Plugins\Greg.Plugins.csproj" />
</ItemGroup>
</Project>
✅ Conclusions
In the end the problems that make us waste time in Model Driven projects are always the same: version numbers that communicate nothing, mapping breaking with Plugin Packages, manual rename of .nupkg
files, uneven handling between PCF and Web Resources, PowerShell scripts patching Solution.xml
. All repetitive work that adds no value and we'd rather forget.
The SDK template AlbanianXrm.CDSProj.Sdk
does most of the heavy lifting: no scripts to force versioning, no manual copy/rename of packages, no “magical” builds different project by project. You reference the plugin project and it prepares the .nupkg
as needed; you pass FileVersion
and the solution aligns; you build and import. Done.
The Power Platform / Dynamics CRM community is, once again, that place where someone has already suffered before you and decided to share the cure. If today you are still manually renaming files or fixing version numbers at the end of the build, take a test solution, apply the template and measure how much time you save. After a few rounds it will be hard to go back.
A special thanks to Betim Beja for providing the AlbanianXrm.CDSProj.Sdk
accelerator: it turned several recurring nuisances into details we simply no longer have to manage.
If you feel like it, let me know what you still miss to make your ALM truly “boring” (and therefore healthy): that is usually where it’s worth investing in the next improvement.
Top comments (0)