You Don’t Need a Solution Mapping File

by Remy van Duijkeren | Jul 26, 2026 | Blog

What pac solution pack ships to production planted a fork. Pack from the folder pac solution sync just captured, not from whatever a solution mapping file pulls in fresh at pack time. Then the DLL you ship is the one you actually tested.

I left it there on purpose. The post wasn't built to litigate the objections.

It drew three of them, reliably, every time someone pushed back.

"That's what Microsoft's own reference architecture does." "Our build server and our tests are enough." "I just don't want binaries bloating my repo."

All three are real. None of them are strawmen.

Two don't survive close reading. The third is the one honest reason a solution mapping file is still in anyone's pipeline in 2026, and it has a better fix than mapping does.

The solution mapping file in Microsoft's own sample

Take this one at full strength first, because the post fails if I don't.

microsoft/contoso-real-estate-power-platform isn't a blog opinion. It's Microsoft's own flagship, maintained, full-stack Power Platform sample, built substantially by Scott Durow, author of spkl and now at Microsoft.

And it wires up a solution mapping file deliberately. ContosoRealEstateCore.cdsproj sets SolutionPackageMapFilePath alongside ProjectReferences to the plugin and web resource projects. sync.ps1 threads that same map through pac solution sync on the way down and runs dotnet build -c Release immediately before packing.

The README says it outright: everything is stored as code, no solution zip files should be present in this repo.

Anyone defending "don't rebuild at pack time" who doesn't know this repo exists is going to get asked about it. They need a real answer, not "Microsoft got it wrong."

The real answer is in the same file. Here's the actual solution-mapping.xml, comments included:

<!-- Note: The mapped name should have all namespace separators (.) removed -->
<FileToFile map="PluginAssemblies/ContosoRealEstateBusinessLogic-539003B6-.../ContosoRealEstateBusinessLogic.dll"
            to="../../../plugins/business-logic/ContosoRealEstateBusinessLogic/bin/publish/ContosoRealEstate.BusinessLogic.dll" />

<!-- Note: The nupkg plugin package is built and packaged using an additional MSBuild target
     in the solution csproj. It is also renamed using another target to remove the package
     version so it can be mapped -->
<!--<FileToFile map="pluginpackages/contoso_PaymentVirtualTableProvider/.../contoso_PaymentVirtualTableProvider.nupkg"
                to=".../bin/PaymentVirtualTableProvider.1.0.0.nupkg" />-->

Look at what's commented out. A FileToFile entry for a versioned nupkg plugin package, abandoned.

The comment explains why it needed help: an extra MSBuild target builds and packages it, and a second target renames it purely to strip the version number off the filename, because a solution mapping file matches on file identity and a version bump changes the filename out from under it.

Two custom build targets to make one file mappable, and the entry is switched off anyway.

Two more things sit in the same eight lines. A hand-maintained GUID embedded in the plugin's map path.

A comment warning that namespace dots have to be stripped by hand or the lookup breaks, the same naming gotcha behind two open issues in Microsoft's own repos: dotted project names breaking plugin assembly resolution in powerplatform-vscode and the same failure mode in powerplatform-build-tools.

Three separate footguns in one small file, written by the field's most credentialed advocate for this approach.

It's not just Microsoft's repo, either. Riccardo Gregori, the Microsoft MVP who built PACX, an open-source add-on for the Power Platform CLI, hit the same nupkg failure independently, outside Contoso entirely, and wrote up the fix.

His account of mapping a plugin package ends in a null-reference exception from pac solution sync --map, the identical file-identity mismatch that made Contoso's team comment their entry out. His workaround needs two separate map files, one for sync and one for build, because a single map file can't satisfy both.

I landed in the same place independently, on my own pipeline, before I'd read his account.

Two map files. One for sync, one for build. Just to keep a single component mappable.

That's the moment this post started. Not "how do I make a solution mapping file work," but "what are we actually fixing here, if the fix needs its own workaround?"

He's also written a guide to the relative-path math a solution mapping file's to attribute demands, because getting ../../../../ right by hand is easy to get wrong. A reference guide exists for this one piece of syntax because people keep needing one.

The brittleness isn't a one-off. It shows up again and again: independently in Gregori's pipeline, and then in mine.

The specific bugs are new to this post. The underlying argument isn't. I've made a version of it before, more than once, on this blog. Everyone got ALM wrong in Dynamics 365 and Dataverse and source-centric on Dataverse is a story you tell yourself both lead here.

This post is the part I hadn't spelled out yet.

"Our build server is trustworthy" answers the wrong question

This is the safety argument, and it's usually delivered as though it settles things. Mock the org, unit-test the logic, enforce policy on the build server, and that's more rigorous than trusting whatever ran in someone's dev environment.

The claim has the direction backwards.

Promoting the artifact that was verified in a real environment doesn't compete with unit testing or build-server policy. It's orthogonal to both.

The plugin project is an ordinary class library, and whatever tests and static checks a team already runs against it keep running, untouched.

What promoting the verified snapshot adds sits on top: the shipped binary also executed, at least once, against real Dataverse data, with the real configuration around it, which a unit test against a mocked org can't claim.

Set the two side by side and it isn't either-or. It's a strict superset.

Unit testsBuild-server policyRan in a real orgShips what ran
Rebuild at pack timeyesyesno, optionalno
Rebuild + ephemeral integration envyesyesyesno, repacked fresh at pack time
Commit the verified snapshotyesyesyesyes

Here's why that gap matters specifically for a compiled DLL, more than for almost anything else in a Dataverse solution. Forms, views, flows, all of it is exported state a human can actually read and reason about. A compiled assembly isn't. Nobody can open a DLL and confirm by eye that it's the build that ran in dev.

The harder something is to verify by eye, the more the "it ran somewhere real" signal matters, not less. Skip DEV for the one component nobody can inspect, and there's nothing left to catch a mismatch with.

The "trustworthy build server" defense also assumes something it never checks: that compiling the same source twice produces the same bytes. It usually doesn't, not without effort.

Ordinary build metadata, module identity, header timestamps, embedded paths, shifts between builds even when no source line changed, unless a project has deliberately turned on deterministic build settings and pinned its toolchain.

Nobody in this argument is hash-comparing the rebuilt DLL against the one that ran in dev. They're assuming it, and shipping on the assumption.

That's precisely the failure mode Jez Humble and David Farley's "build your binaries once" principle from Continuous Delivery exists to rule out.

A trustworthy compiler, run a second time, after the fact, is still a second, unverified compilation inserted between "tested" and "shipped."

There's a more disciplined version of this defense worth a straight answer. Some teams build the zip exactly once, in CI, and promote that identical zip unchanged through TEST, UAT, and PROD, no repacking at any stage. That genuinely satisfies build your binaries once, to the letter, and I'm not going to pretend otherwise.

What it drops is DEV. The zip moving through TEST was never run anywhere before it got there. Unlike most software, where CI can replicate the production runtime closely enough that a developer's machine doesn't need to be part of the lineage, Dataverse has no way to exercise a plugin's real behavior, which message fires it, what an image actually contains, outside a real environment. A unit test against a mocked context confirms your logic. It doesn't confirm the step fired the way you think it did.

There's a harder problem underneath that, and it's mechanical, not a matter of discipline. Plugin step, image, and filtering attribute registrations don't exist because someone wrote XML by hand, they exist because a plugin assembly was registered against a live environment, and pac solution sync is how that registration state gets captured into what you pack. There is no version of "skip DEV" that still produces a solution with working plugin steps in it.

Which means the team excluding DEV hasn't removed that step, they've split it into two untracked halves: one environment supplies the registration metadata that ships, a separate CI build supplies the compiled bytes, and nothing forces the two to agree. Excluding DEV from the chain doesn't remove the work DEV does. It just stops tracking whether the shipped binary and the registered metadata still describe the same plugin.

The one honest reason: repo hygiene

This is the live one.

It deserves the most constructive answer, not a dismissal.

Where it actually came from. A solution mapping file is a parameter of Microsoft's own SolutionPackager tool, documented since the CRM 2013 SDK era.

Here's Microsoft's stated rationale, in full:

"Files that are built in an automated build system, such as .xap Silverlight files and plug-in assemblies, are typically not checked into source control... By including the /map parameter, the SolutionPackager tool can be directed to read and package such files from alternate locations."

Read that closely. The stated purpose is file relocation, not artifact integrity. It never claims anything about which binary ought to reach production, that's a side effect of pointing the packer at a build-output folder, not a designed guarantee.

And the canonical example is Silverlight, a technology dead within a year of that documentation being written.

The premise described 2013 practice: TFVC made binaries in source control genuinely expensive, rebuilding was treated as equivalent to promoting, and nobody was yet expecting to be able to point at exactly what shipped.

Every one of those constraints has since inverted. Git handles plugin-DLL-scale binaries fine. Continuous delivery now treats rebuilding between stages as the documented anti-pattern, and knowing exactly which binary shipped to production is expected, not optional.

The ecosystem kept the conclusion and dropped the reasoning that produced it.

Why 2013's premise doesn't survive contact with Dataverse specifically. Every other ecosystem that says "no artifacts in source control" can actually honor it. A Node or .NET service really can regenerate its whole output from source.

A Dataverse solution never could. Forms, views, sitemaps, flows, business rules exist only as exported state captured from an environment.

A solution mapping file enforces source purity on exactly one file type, inside a system that was never source-pure for any of the neighboring ones.

That's not a principled boundary. It's the boundary that happened to be technically convenient in 2013.

The sophisticated version of this defense, answered too. Some readers are already thinking: I don't map to a local bin folder, I map to an immutable artifact store, build once on the server, push that exact artifact to dev, then map to the same version at pack time. Fair objection, and it genuinely closes the integrity gap. Mapping was only ever pointed at fresh local output by convention, not by design.

But look at the cost. Artifact stores name things by version, and mapping is a file-identity lookup, so this is the exact nupkg failure from Contoso's own repo made permanent instead of an edge case, every version bump needs its own rename-to-strip-the-version workaround, forever. The guarantee that push time and pack time resolved the same store version also lives in pipeline configuration nobody can audit from the repo alone.

Git already is a versioned, content-addressed artifact store. Standing up a second one to get what git already gives you for free is real infrastructure most teams reaching for a mapping file don't actually want to own.

The actual fix, if the hygiene concern is real for you. You don't need a solution mapping file to keep a binary out of git. You need one tweak to the pipeline, not two.

Write the .gitignore entry once. That's not a pipeline step, it's a repo setting, done once and left alone. An unpacked solution keeps plugin assemblies under PluginAssemblies and web resources under WebResources, the same folder names Microsoft's own SolutionPackager mapping examples use. Ignore the compiled bytes in those folders and keep everything else, the metadata that actually describes your components, tracked.

# Plugin assembly output (classic .dll)
PluginAssemblies/**/*.dll

# Plugin package output (NuGet path)
PluginAssemblies/**/*.nupkg

# Web resource content, keep the .data.xml metadata tracked
WebResources/**
!WebResources/**/
!WebResources/**/*.data.xml

The one actual pipeline change: copy the fresh build into those exact folder slots before you pack. A plain build step, no MSBuild target required, no mapping file:

copy Plugins\bin\Release\*.dll   <solution>\PluginAssemblies\<AssemblyName>-<guid>\
copy WebResources\dist\*         <solution>\WebResources\<prefix>_\

pac solution pack --folder <solution> --zipfile out.zip

pac solution sync and pac solution pack both stay completely default. Neither one takes a flag.

The binary never reaches git, and the shipped zip contains whichever build you chose, exactly what a mapping-file pipeline exists to achieve, reached with a static .gitignore entry and one copy step.

A solution mapping file can't get away with that little. --map is a documented parameter on both pac solution sync and pac solution pack, and Contoso's own sync.ps1 from Section 1 uses it on both: threaded through sync on the way down, threaded through the build again right before pack on the way up. Drop it from sync and the binary gets written straight into the folder git tracks. Drop it from pack and the wrong build ships. Two commands, two configurations, both expected to resolve the same map file identically. That's the actual mechanical source of the brittleness in Section 1, not a side effect of it.

Call it what it is: the inverse of a solution mapping file. Same relocation trick Microsoft's own tool uses, at one touch point instead of two, exception handed to the opposite system.

Two systems read that folder: git, and the packer. Exactly one of them has to be handed a special case, since the folder has to hold the binary at pack time and must not hold it in git.

A solution mapping file hands the exception to the packer, keyed by file identity, which is why it needs GUID paths, dot-stripping rules, and version-stripping rename targets, the exact case that defeated Microsoft's own mapping file.

.gitignore plus copy hands the exception to git, keyed by a path glob, which never inspects what's at the path, only that it exists.

File-identity lookups break the moment identity changes, which a version bump does by definition. Path globs don't care what changed, only where.

copy bin/*.dll <dest> is correct at every version, forever, with no rename target to maintain.

Three positions, not two

Name the honest structure here, because it isn't "my position versus the industry's."

There are three positions.

  1. A solution mapping file. Accept that git and the folder diverge, hand the exception to the packer.
  2. .gitignore plus copy. Accept the same divergence, hand the exception to git instead.
  3. Commit the snapshot, my own default from the earlier post. Refuse the divergence entirely, so no exception is needed on either side.

Most teams reaching for a solution mapping file actually want position two.

Nobody told them it existed as a separate, cheaper choice.

The two camps don't need different tooling. They need a different .gitignore.

Go look at your own pack step. Trace where the DLL it ships actually comes from.

If a mapping file is pulling it from local build output instead of from what pac solution sync captured, you're shipping code nobody tested.

Microsoft's own flagship sample is proof that the plumbing to make that work reliably gets expensive fast, right up to the point where even their own team gave up and commented it out.

Related