Power Platform Solution Architecture Patterns: Designing Layered Solution Structures, Hub-and-Spoke Architectures, Defining Component Boundaries, and Producing Technical Architecture Documents
1. Conceptual Foundation
Enterprise-grade Microsoft Power Platform implementations require a rigorous approach to solution architecture. At the core of this discipline is the understanding of how Microsoft Dataverse manages customizations, tracks dependencies, and enforces boundaries across environments. This section establishes the theoretical and structural foundations necessary to design scalable, maintainable, and deployable Power Platform architectures.
Solution Layering: Unmanaged vs. Managed Layers
In Microsoft Dataverse, solutions act as containers for application components. The platform evaluates these components using a deterministic, bottom-up layering model. Understanding this model is critical for preventing configuration drift and ensuring predictable deployments.
+-------------------------------------------------------+
| Unmanaged Layer | <-- Ad-hoc customizations & unmanaged imports
+-------------------------------------------------------+
| Managed Layer N (Spoke App B) | <-- Last installed managed solution (wins conflicts)
+-------------------------------------------------------+
| Managed Layer 2 (Spoke App A) |
+-------------------------------------------------------+
| Managed Layer 1 (Core Hub) | <-- Base managed solution
+-------------------------------------------------------+
| System Solution | <-- Out-of-the-box tables & platform components
+-------------------------------------------------------+
The Unmanaged Layer
The unmanaged layer sits at the absolute top of the solution stack. It is a single, shared layer present in every Dataverse environment. Any direct customization made within an environment (such as editing a form, adding a column, or creating a flow in the maker portal) and any imported unmanaged solution write directly to this layer.
In a healthy Application Lifecycle Management (ALM) model, the unmanaged layer should only contain active work-in-progress within dedicated Development environments. In downstream environments (Test, UAT, Production), the unmanaged layer must remain completely empty. If a developer or administrator makes a direct change in a production environment, an unmanaged customization is created. This customization sits above all managed layers, effectively "blocking" any future updates deployed via managed solutions for that specific component. This phenomenon is known as unmanaged shadowing.
Managed Layers
Managed solutions are deployed to downstream environments as read-only packages. When multiple managed solutions are installed, they are stacked in the order of their installation. The system solution (containing out-of-the-box tables like Account and Contact) forms the base layer. Managed Layer 1 (typically a core or hub solution) sits above the system layer. Managed Layer 2 (an extension or spoke solution) sits above Layer 1, and so on.
Dataverse Merge Logic and Conflict Resolution
When multiple solutions modify the same component, Dataverse resolves conflicts using one of two behaviors:
- Last One Wins (Top-Down Override): For non-mergeable properties (such as a column's display name, maximum length, or an environment variable's definition), the managed solution installed last (the top-most managed layer) overrides the properties defined by previously installed solutions.
- Merge Logic: For structural and UI components, Dataverse executes a deep XML merge.
- Form XML (FormXml): Dataverse merges form layouts by combining tabs, sections, and columns. If Solution A adds Tab 1 and Solution B adds Tab 2 to the same form, the runtime form displays both tabs. If both solutions modify the same section, the layout is merged based on the relative positioning attributes.
- Model-Driven App Navigation (SiteMap): SiteMap XML is merged hierarchically. Areas, Groups, and SubAreas from all managed solutions are combined. If there are duplicate IDs, the last installed solution's definition takes precedence.
- Ribbon Customizations (RibbonCustomization): Command bar and ribbon definitions are merged by evaluating the
<CommandDefinition>,<RuleDefinition>, and<Layout>elements. Dataverse computes a unified ribbon schema at runtime.
Solution Segmentation and Table Segmentation
Historically, adding a table to a solution meant including the entire table metadata and all its associated assets (every column, form, view, relationship, and localized label). This monolithic approach caused massive dependency webs and accidental unmanaged layers. Modern Power Platform architecture mandates Solution Segmentation.
Segmented Solutions
A segmented solution contains only the specific assets of a component that have been modified or added, rather than the entire component. For example, if you are extending the out-of-the-box Account table to add a single custom column, your solution should only contain that specific column and the modified form—not the entire Account table metadata, its 100+ standard columns, and its default views.
Table Segmentation Options
When adding an existing table to a solution in the Power Apps Maker Portal or via the Power Platform CLI (pac solution), you are presented with two primary choices:
- Include All Objects: This adds the table definition, all existing columns, forms, views, charts, keys, relationships, and business rules to the solution. This option must only be used when creating a brand-new custom table that does not exist in the target environments.
- Select Components (Segmented): This allows the architect to hand-pick only the modified assets.
- Include Table Metadata: Includes the top-level table properties (such as whether auditing or quick find is enabled). This should only be checked if you are explicitly changing a table-level property.
- Selected Attributes: Only the specific custom columns created for your application.
- Selected Forms/Views: Only the specific forms or views that have been customized or newly created.
By enforcing strict table segmentation, you minimize the size of the exported solution zip file, reduce import times, and prevent your solution from accidentally overwriting customizations made by other teams on shared tables.
Hub-and-Spoke Architecture in Power Platform
In large enterprise deployments, a single, monolithic solution becomes unmanageable. It leads to long deployment times, merge conflicts among developers, and a lack of clear ownership. The Hub-and-Spoke architecture pattern solves this by separating core platform capabilities from business-specific applications.
+----------------------------------+
| Core Hub App |
| - Shared Tables (e.g. Account) |
| - Global Option Sets / Choices |
| - Core Security Roles |
| - Shared Custom APIs |
+----------------------------------+
|
+--------------------------+--------------------------+
| |
v v
+----------------------------------+ +----------------------------------+
| Spoke App A | | Spoke App B |
| - App-Specific Tables | | - App-Specific Tables |
| - Canvas Apps & Cloud Flows | | - Canvas Apps & Cloud Flows |
| - Custom Plugins & PCF Controls | | - Custom Plugins & PCF Controls |
| - Extends Hub Tables | | - Extends Hub Tables |
+----------------------------------+ +----------------------------------+
The Hub Solution
The Hub solution acts as the foundation of the enterprise platform. It is owned by a centralized platform engineering or Center of Excellence (CoE) team. The Hub contains:
- Core Data Model: Shared custom tables and highly segmented out-of-the-box tables (e.g.,
Account,Contact,Product) that are utilized across multiple business units. - Global Choices (Option Sets): Standardized lookup lists and choice columns (e.g., Country Codes, Business Unit Identifiers) to ensure data consistency.
- Core Security Roles: Base security roles defining read/write access to shared tables.
- Shared Custom APIs and Plugins: Reusable, high-performance business logic registered as Custom APIs that spokes can invoke.
The Hub is developed in an isolated Hub Development environment, exported as a managed solution, and imported into all Spoke Development environments as well as downstream Test and Production environments.
Spoke Solutions
Spoke solutions are owned by individual business unit development teams. Each spoke represents a distinct business application (e.g., HR Onboarding, Sales Forecasting, Field Service Extensions). A Spoke solution contains:
- App-Specific Tables: Custom tables that are only relevant to that specific application.
- Extensions of Hub Tables: Segmented additions to Hub tables (e.g., Spoke App A adds a custom column
new_hr_startdateto the sharedContacttable). - User Interfaces: Canvas Apps, Model-Driven Apps, Custom Pages, and Power Apps Component Framework (PCF) controls.
- Automations: Power Automate cloud flows and spoke-specific plugins.
Benefits of Hub-and-Spoke
- Parallel Development: Multiple teams can build applications simultaneously in their own Spoke Development environments without interfering with each other's code or database schemas.
- Strict Governance: The core data model is locked down. Spoke teams cannot modify the Hub solution; they can only consume and extend it.
- Reduced Deployment Risk: Upgrades to Spoke App A can be deployed to Production independently of Spoke App B, minimizing downtime and regression testing.
Component Boundaries and Dependency Tracking
A critical challenge in multi-solution architectures is managing dependencies. Dataverse enforces strict dependency tracking to ensure system integrity.
Types of Dependencies
Dataverse automatically calculates and enforces three types of dependencies:
- Solution Internal Dependencies: These exist when a component within a solution cannot exist without another component in the same solution. For example, a custom column depends on its parent table. Dataverse handles these automatically.
- Published Dependencies: These are created when a component in one solution references a component in another solution, and both are published. For example, if Spoke Solution A contains a lookup column pointing to a custom table in the Hub Solution, Spoke Solution A has a published dependency on the Hub Solution.
- Unpublished Dependencies: These occur during active development when a component references an unpublished change in another component. These must be resolved by publishing all customizations before exporting.
Managing Cross-Solution Boundaries
To maintain clean boundaries and prevent circular dependencies (where Solution A depends on Solution B, and Solution B depends on Solution A, making deployments impossible), architects must adhere to the following rules:
- Downstream Referencing Only: Spoke solutions can depend on the Hub solution. The Hub solution must never reference or depend on any Spoke solution.
- No Spoke-to-Spoke Dependencies: Spoke App A must never directly reference components in Spoke App B. If Spoke App A and Spoke App B need to share a table, a column, or a choice list, that component must be refactored and moved down into the Hub solution.
- Use Custom APIs as Integration Contracts: If a spoke needs to trigger logic in another spoke, it should not do so via direct database triggers or synchronous plugin steps. Instead, define a Custom API in the target spoke (or Hub) that acts as a formal, versioned integration contract.
Technical Architecture Documents (TAD) for PL-400 Scenarios
A Technical Architecture Document (TAD) is the primary deliverable produced by a Power Platform Solution Architect. It translates business requirements into concrete technical designs that developers can implement. For PL-400 developer scenarios, a TAD must be highly technical, precise, and unambiguous.
Key Sections of a Power Platform TAD
- Executive Summary & Business Context: Brief overview of the business problem and the high-level solution.
- Environment Strategy: Detailed map of the environment topology (Dev, Test, UAT, Prod), including region allocations, security group bindings, and data loss prevention (DLP) policies.
- Solution Layering & Packaging Design: Visual diagram and description of the solution structure (Hub-and-Spoke, publishers, prefixes, and segmentation rules).
- Data Model & Schema Design: Entity-Relationship Diagram (ERD) detailing tables, columns, data types, relationships (1:N, N:1, N:N), keys, and cascade behaviors.
- Security Architecture: Detailed matrix of security roles, business units, team structures, column-level security profiles, and sharing models.
- Extensibility & Custom Code Design: Specifications for custom plugins, Custom APIs, workflow activities, and PCF controls. This must include execution stage registrations, transaction boundaries, and exception handling strategies.
- Integration Architecture: Design of real-time and batch integrations using Azure Service Bus, Webhooks, virtual tables, or Dataverse APIs.
- ALM & CI/CD Pipeline Specification: Definition of the source control branching strategy, automated build/release pipelines, and deployment settings configuration.
2. Architecture & Decision Matrix
When designing extensibility points within the Power Platform, developers and architects must choose the most appropriate technology for implementing business logic. The table below compares the four primary extensibility patterns available to a PL-400 developer.
Extensibility Pattern Comparison
| Architectural Dimension | Power Automate (Cloud Flows) | Dataverse Plugins (C#) | Azure Functions (C# / Node.js) | Power Apps Component Framework (PCF) |
|---|---|---|---|---|
| Complexity | Low (Low-code visual designer) | High (Requires C# .NET Framework, SDK knowledge) | High (Requires Azure development, API design) | Very High (Requires TypeScript, React, Node.js, Webpack) |
| Scalability | Medium (Subject to API request limits and action burst caps) | High (Runs directly within the Dataverse transaction pipeline) | Extremely High (Serverless auto-scaling, independent of Dataverse resources) | Client-Side (Scales with the user's local device performance) |
| Execution Context | Asynchronous (Near real-time, runs outside database transaction) | Synchronous or Asynchronous (Runs inside or immediately after transaction) | Asynchronous (Triggered via Webhooks, Service Bus, or Plugin callouts) | Client-Side (Runs in the context of the user's browser or mobile app) |
| Offline Support | No (Requires active internet connection to trigger and run) | No (Requires connection to Dataverse database) | No (Requires internet connection to invoke Azure endpoint) | Yes (Can run offline in Power Apps Mobile using offline-enabled canvas/model apps) |
| Licensing | Included in Power Apps/Power Automate licenses (subject to request limits) | Included in base Dataverse/Power Apps capacity | Requires Azure Subscription (consumption or premium plan costs) | Included in Power Apps licenses (unless consuming premium external APIs) |
| PL-400 Relevance | High (Testing focus on solution packaging, child flows, run-as context) | Critical (Testing focus on pipeline stages, transaction rollback, depth, performance) | High (Testing focus on integration, service principal auth, webhook registration) | Critical (Testing focus on manifest configuration, lifecycle methods, packaging) |
Detailed Architectural Trade-offs
Power Automate (Cloud Flows)
- When to use: Best suited for asynchronous, non-blocking business logic, long-running orchestrations, multi-step approvals, and integrations with third-party systems via standard or custom connectors.
- Trade-offs: Not suitable for real-time validation where a user action must be blocked immediately if validation fails. Cloud flows cannot participate in the Dataverse database transaction, meaning they cannot roll back database changes if a subsequent step fails.
Dataverse Plugins (C#)
- When to use: Mandatory for synchronous, real-time business logic, data validation, enforcing complex security rules, and operations that must participate in the database transaction (ensuring atomic "all-or-nothing" execution).
- Trade-offs: Limited by a strict 2-minute execution timeout. Plugins run within the Dataverse sandbox, restricting access to local system resources, certain network protocols, and external file systems. Poorly written plugins can severely degrade database performance and cause transaction blocking.
Azure Functions
- When to use: Ideal for heavy computational processing, long-running operations (exceeding 2 minutes), integration with legacy on-premises systems (via hybrid connections), and scenarios requiring third-party NuGet packages or libraries not supported in the Dataverse plugin sandbox.
- Trade-offs: Introduces architectural complexity, external hosting costs, and network latency. Requires robust security configurations (OAuth 2.0, Managed Identities) to authenticate back to Dataverse.
Power Apps Component Framework (PCF)
- When to use: Used exclusively to extend the user interface. Use PCF when out-of-the-box controls (text boxes, dropdowns) cannot satisfy complex UI/UX requirements, such as advanced data visualizations, interactive maps, or custom input controls.
- Trade-offs: PCF controls are strictly client-side. They cannot enforce server-side security or business rules. They require a professional development skillset to build, debug, and maintain.
3. Step-by-Step Implementation Guide
This guide details the process of establishing a Hub-and-Spoke solution architecture, developing a custom PCF control, writing a Dataverse plugin, and packaging the entire system for deployment.
Step 1: Establish the Solution Publisher and Customization Prefix
To prevent naming collisions and establish clear ownership, you must create a dedicated Solution Publisher.
- Navigate to the Power Apps Maker Portal (
make.powerapps.com) and select your Development environment. - In the left navigation pane, select Solutions.
- On the command bar, select New solution. In the right-hand panel, under the Publisher dropdown, select + New publisher.
- Configure the Publisher with the following exact values:
- Display name:
Contoso Enterprise - Name:
contoso_enterprise - Prefix:
contoso - Choice value prefix:
10000(This ensures all choice values created under this publisher start with 10000, preventing conflicts with other publishers).
- Display name:
- Select Save.
# Alternatively, initialize the solution project using the Power Platform CLI (PAC CLI)
pac solution init --publisher-name contoso_enterprise --publisher-prefix contoso
Step 2: Create the Hub Solution (Core Data Model)
The Hub solution will contain the core shared tables.
- In the Maker Portal, select New solution.
- Configure the solution:
- Display name:
Contoso Core Hub - Name:
ContosoCoreHub - Publisher: Select
Contoso Enterprise. - Version:
1.0.0.0
- Display name:
- Select Create.
- Inside the
ContosoCoreHubsolution, select New > Table > Table. - Create a core table named
Project(Schema Name:contoso_project). - Add a custom column:
- Display name:
Project Code - Schema name:
contoso_projectcode - Data type:
Single line of text - Required:
Business required
- Display name:
- Select Save Table.
Step 3: Create the Spoke Solution (HR Extensions)
The Spoke solution will extend the Hub and contain app-specific components.
- Navigate back to Solutions and select New solution.
- Configure the solution:
- Display name:
Contoso HR Spoke - Name:
ContosoHRSpoke - Publisher: Select
Contoso Enterprise(Must use the same publisher to allow component movement and extension). - Version:
1.0.0.0
- Display name:
- Select Create.
- Inside the
ContosoHRSpokesolution, select Add existing > Table. - Select the
Projecttable from the list. - Crucial Step (Table Segmentation): In the dialog, do not check "Include all objects". Instead, select Select components.
- Select only the
Projecttable metadata and the main form. Do not include any other columns or views. Select Add. - Now, select the
Projecttable within your spoke solution, select New > Column. - Create an HR-specific column:
- Display name:
HR Approver - Schema name:
contoso_hrapprover - Data type:
Lookup - Related table:
User
- Display name:
- Select Save.
Step 4: Develop and Package a PCF Control
We will create a custom PCF control to display the Project Code in a stylized format.
- Open a terminal (PowerShell or VS Code Terminal) and create a directory for the PCF project:
mkdir -p ContosoPCF/ProjectCodeControlcd ContosoPCF/ProjectCodeControl
- Initialize the PCF project using the PAC CLI:
pac pcf init --namespace ContosoControls --name ProjectCodeControl --template field --run-npm-install
- Open the directory in VS Code. Edit the
ControlManifest.Input.xmlto define the properties (see Section 4 for the complete manifest code). - Implement the control logic in
index.ts(see Section 4 for the complete TypeScript code). - Build the PCF control:
npm run build
- Create a solution project (
cdsproj) to package the PCF control:cd ..mkdir ContosoPCF_Solutioncd ContosoPCF_Solutionpac solution init --publisher-name contoso_enterprise --publisher-prefix contoso# Add a reference to the PCF control projectpac solution add-reference --path ../ProjectCodeControl - Build the solution project using MSBuild or dotnet build (generates the solution zip file):
The packaged solution zip file will be located indotnet build /p:configuration=Release
bin/Release/ContosoPCF_Solution.zip.
Step 5: Develop and Register a Dataverse Plugin
We will write a C# plugin to validate that the Project Code follows a strict regex pattern (^PROJ-[0-9]{4}$) before saving.
- Create a new .NET Framework 4.6.2 Class Library project named
ContosoPlugins. - Install the required NuGet packages via the Package Manager Console:
Install-Package Microsoft.CrmSdk.CoreAssemblies -Version 9.0.2.56
- Create a class file named
PreValidateProjectCreate.cs(see Section 4 for the complete C# code). - Build the project to generate
ContosoPlugins.dll. - Open the Plugin Registration Tool (PRT) and connect to your Development environment.
- Select Register > Register New Assembly. Browse to and select
ContosoPlugins.dll. Ensure "Sandbox" and "Database" are selected, then select Register Selected Plugins. - Right-click the registered plugin type
ContosoPlugins.PreValidateProjectCreateand select Register New Step. - Configure the step:
- Message:
Create - Primary Entity:
contoso_project - Event Pipeline Stage of Execution:
Pre-validation(Stage 10 - runs outside the database transaction, ideal for validation). - Execution Mode:
Synchronous - Deployment:
Server
- Message:
- Select Register New Step.
- To make this plugin solution-aware, navigate to the Maker Portal, open the
ContosoCoreHubsolution, select Add existing > Developer > Plugin assembly, selectContosoPlugins, and add it. Then add the registered step under Plugin steps.
4. Complete Code Reference
This section provides complete, production-grade, compilable code samples for the components built in the previous section.
4.1. C# Dataverse Plugin
This plugin validates the format of the contoso_projectcode column on the contoso_project table during the Pre-validation stage of the Create and Update messages.
//-----------------------------------------------------------------------
// <copyright file="PreValidateProjectCreate.cs" company="Contoso">
// Copyright (c) Contoso. All rights reserved.
// </copyright>
// <summary>
// Synchronous Pre-Validation plugin to enforce Project Code formatting.
// </summary>
//-----------------------------------------------------------------------
using System;
using System.Text.RegularExpressions;
using Microsoft.Xrm.Sdk;
namespace ContosoPlugins
{
/// <summary>
/// Validates that the Project Code column matches the required enterprise format: PROJ-XXXX (where X is a digit).
/// </summary>
public class PreValidateProjectCreate : IPlugin
{
private const string ProjectCodeSchemaName = "contoso_projectcode";
private const string TargetEntityName = "contoso_project";
private const string ProjectCodeRegexPattern = @"^PROJ-[0-9]{4}$";
/// <summary>
/// Executes the plugin logic.
/// </summary>
/// <param name="serviceProvider">The service provider containing the execution context.</param>
public void Execute(IServiceProvider serviceProvider)
{
if (serviceProvider == null)
{
throw new ArgumentNullException(nameof(serviceProvider));
}
// Retrieve the execution context
IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
ITracingService tracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
tracingService.Trace("PreValidateProjectCreate: Starting execution. Depth: {0}", context.Depth);
// Guard clause: Verify the target is an Entity and matches the expected table
if (!context.InputParameters.Contains("Target") || !(context.InputParameters["Target"] is Entity))
{
tracingService.Trace("PreValidateProjectCreate: Target is missing or is not an Entity. Exiting.");
return;
}
Entity targetEntity = (Entity)context.InputParameters["Target"];
if (targetEntity.LogicalName != TargetEntityName)
{
tracingService.Trace("PreValidateProjectCreate: Target entity logical name '{0}' does not match expected '{1}'. Exiting.", targetEntity.LogicalName, TargetEntityName);
return;
}
// Evaluate based on the message type (Create vs Update)
string projectCodeValue = null;
if (context.MessageName.Equals("Create", StringComparison.OrdinalIgnoreCase))
{
if (targetEntity.Contains(ProjectCodeSchemaName))
{
projectCodeValue = targetEntity.GetAttributeValue<string>(ProjectCodeSchemaName);
}
}
else if (context.MessageName.Equals("Update", StringComparison.OrdinalIgnoreCase))
{
if (targetEntity.Contains(ProjectCodeSchemaName))
{
projectCodeValue = targetEntity.GetAttributeValue<string>(ProjectCodeSchemaName);
}
else
{
// If the column is not in the update target, it is not being modified.
tracingService.Trace("PreValidateProjectCreate: Project Code is not being modified in this update operation. Exiting.");
return;
}
}
else
{
tracingService.Trace("PreValidateProjectCreate: Message '{0}' is not supported. Exiting.", context.MessageName);
return;
}
// Perform validation
tracingService.Trace("PreValidateProjectCreate: Validating Project Code value: '{0}'", projectCodeValue ?? "NULL");
if (string.IsNullOrWhiteSpace(projectCodeValue))
{
throw new InvalidPluginExecutionException(
OperationStatus.Failed,
(int)PluginErrorCodes.NullValueNotAllowed,
"Project Code is required and cannot be empty.");
}
Regex regex = new Regex(ProjectCodeRegexPattern, RegexOptions.Compiled);
if (!regex.IsMatch(projectCodeValue))
{
tracingService.Trace("PreValidateProjectCreate: Validation failed for value '{0}'. Throwing exception.", projectCodeValue);
throw new InvalidPluginExecutionException(
OperationStatus.Failed,
(int)PluginErrorCodes.InvalidFormat,
$"The Project Code '{projectCodeValue}' is invalid. It must strictly follow the format 'PROJ-YYYY' (e.g., PROJ-1234).");
}
tracingService.Trace("PreValidateProjectCreate: Validation succeeded.");
}
}
/// <summary>
/// Custom error codes for the plugin to be consumed by client applications.
/// </summary>
public enum PluginErrorCodes
{
NullValueNotAllowed = 90001,
InvalidFormat = 90002
}
}
4.2. TypeScript PCF Control
ControlManifest.Input.xml
This manifest defines the metadata for the PCF control, specifying that it binds to a single line of text column.
<?xml version="1.0" encoding="utf-8" ?>
<control manifest-version="1.0.0"
control-type-name="ProjectCodeControl"
namespace="ContosoControls"
constructor="ProjectCodeControl"
version="1.0.0"
display-name-key="Project Code Formatter"
description-key="Displays and formats the enterprise project code."
control-type-group="Standard">
<property name="projectCodeValue"
display-name-key="Project Code Value"
description-key="The bound project code column."
of-type="SingleLine.Text"
usage="bound"
required="true" />
<resources>
<code path="index.ts" order="1"/>
<css path="css/ProjectCodeControl.css" order="2"/>
</resources>
<feature-usage>
<use-feature name="Utility" required="true" />
</feature-usage>
</control>
index.ts
The complete TypeScript implementation of the PCF control. It renders an input field that enforces uppercase formatting and visual validation feedback.
import { IInputs, IOutputs } from "./generated/ManifestTypes";
export class ProjectCodeControl implements ComponentFramework.StandardControl<IInputs, IOutputs> {
private _container: HTMLDivElement;
private _inputElement: HTMLInputElement;
private _validationMessageElement: HTMLSpanElement;
private _context: ComponentFramework.Context<IInputs>;
private _notifyOutputChanged: () => void;
private _currentValue: string | null;
private _refreshData: EventListenerOrEventListenerObject;
/**
* Empty constructor.
*/
constructor() {
// No-op
}
/**
* Used to initialize the control instance.
* @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to names defined in the manifest, as well as utility functions.
* @param notifyOutputChanged A callback method to alert the framework that the control has new outputs ready to be retrieved asynchronously.
* @param state A piece of data that persists in one session for a single user. Can be set at any point in a controls lifecycle by calling 'setControlState' in the bundling compiler.
* @param container If a control is marked control-type-group="Standard", it will receive an empty div element within which it can render its content.
*/
public init(
context: ComponentFramework.Context<IInputs>,
notifyOutputChanged: () => void,
state: ComponentFramework.Dictionary,
container: HTMLDivElement
): void {
this._context = context;
this._notifyOutputChanged = notifyOutputChanged;
this._container = container;
// Create the main wrapper container
const wrapper = document.createElement("div");
wrapper.setAttribute("class", "project-code-wrapper");
// Create the input element
this._inputElement = document.createElement("input");
this._inputElement.setAttribute("type", "text");
this._inputElement.setAttribute("class", "project-code-input");
this._inputElement.setAttribute("placeholder", "PROJ-0000");
// Retrieve initial value
this._currentValue = context.parameters.projectCodeValue.raw || null;
if (this._currentValue) {
this._inputElement.value = this._currentValue;
}
// Create validation message element
this._validationMessageElement = document.createElement("span");
this._validationMessageElement.setAttribute("class", "project-code-validation-error");
// Bind event listener for input changes
this._refreshData = this.refreshData.bind(this);
this._inputElement.addEventListener("input", this._refreshData);
// Append elements to container
wrapper.appendChild(this._inputElement);
wrapper.appendChild(this._validationMessageElement);
this._container.appendChild(wrapper);
// Initial validation check
this.validateInput(this._currentValue);
}
/**
* Event handler for input element changes.
*/
private refreshData(evt: Event): void {
const rawValue = this._inputElement.value;
// Force uppercase formatting
const formattedValue = rawValue.toUpperCase();
this._inputElement.value = formattedValue;
this._currentValue = formattedValue;
this.validateInput(formattedValue);
this._notifyOutputChanged();
}
/**
* Validates the input value against the enterprise regex pattern.
*/
private validateInput(value: string | null): void {
const regex = /^PROJ-[0-9]{4}$/;
if (!value || value.trim() === "") {
this._validationMessageElement.innerText = "Project Code is required.";
this._inputElement.classList.add("invalid");
} else if (!regex.test(value)) {
this._validationMessageElement.innerText = "Format must be PROJ-YYYY (e.g., PROJ-1234).";
this._inputElement.classList.add("invalid");
} else {
this._validationMessageElement.innerText = "";
this._inputElement.classList.remove("invalid");
this._inputElement.classList.add("valid");
}
}
/**
* Called when any value in the property bag has changed. This includes field values, data-sources, single values of utilities, etc.
* @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to names defined in the manifest, as well as utility functions.
*/
public updateView(context: ComponentFramework.Context<IInputs>): void {
this._context = context;
const newValue = context.parameters.projectCodeValue.raw || null;
if (newValue !== this._currentValue) {
this._currentValue = newValue;
this._inputElement.value = newValue || "";
this.validateInput(newValue);
}
}
/**
* It is called by the framework prior to a control receiving new data.
* @returns an object based on nomenclature defined in manifest, expecting object of the property names and values.
*/
public getOutputs(): IOutputs {
return {
projectCodeValue: this._currentValue || undefined
};
}
/**
* Called when the control is to be removed from the DOM cleanup.
*/
public destroy(): void {
this._inputElement.removeEventListener("input", this._refreshData);
}
}
4.3. Solution Manifest Configuration
Solution.xml (Hub Solution)
This is the complete manifest file for the ContosoCoreHub solution, defining the publisher and dependencies.
<?xml version="1.0" encoding="utf-8"?>
<ImportExportXml version="9.2.24014.123" classid="4010FB3E-0015-418B-A0F2-0702EFD777D4" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<SolutionManifest>
<UniqueName>ContosoCoreHub</UniqueName>
<LocalizedNames>
<LocalizedName description="Contoso Core Hub" languagecode="1033" />
</LocalizedNames>
<Descriptions>
<Description description="Contains core enterprise data models and shared tables." languagecode="1033" />
</Descriptions>
<Version>1.0.0.0</Version>
<Managed>true</Managed>
<Publisher>
<UniqueName>contoso_enterprise</UniqueName>
<LocalizedNames>
<LocalizedName description="Contoso Enterprise" languagecode="1033" />
</LocalizedNames>
<Descriptions>
<Description description="Centralized enterprise publisher." languagecode="1033" />
</Descriptions>
<EMailAddress xsi:nil="true" />
<SupportingWebsiteUrl xsi:nil="true" />
<CustomizationPrefix>contoso</CustomizationPrefix>
<CustomizationOptionValuePrefix>10000</CustomizationOptionValuePrefix>
</Publisher>
<RootComponents>
<RootComponent type="1" schemaName="contoso_project" behavior="0" />
<RootComponent type="90" schemaName="contoso_ContosoPlugins" behavior="0" />
</RootComponents>
<MissingDependencies />
</SolutionManifest>
</ImportExportXml>
customizations.xml (Segmented Spoke Table Snippet)
This snippet from the ContosoHRSpoke solution's customizations.xml demonstrates table segmentation. It adds only the custom column contoso_hrapprover to the existing contoso_project table, without re-declaring the entire table structure.
<ImportExportXml xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Entities>
<Entity>
<Name LocalizedName="Project" OriginalName="Project">contoso_project</Name>
<Attributes>
<!-- Only the customized/added attribute is included in the spoke solution -->
<Attribute PhysicalName="contoso_hrapprover">
<Type>lookup</Type>
<Name>contoso_hrapprover</Name>
<LogicalName>contoso_hrapprover</LogicalName>
<RequiredLevel>none</RequiredLevel>
<DisplayMask>ValidForForm|ValidForGrid</DisplayMask>
<LookupType>single</LookupType>
<LookupTargets>
<LookupTarget>systemuser</LookupTarget>
</LookupTargets>
<displaynames>
<displayname description="HR Approver" languagecode="1033" />
</displaynames>
<Descriptions>
<Description description="The HR user assigned to approve this project." languagecode="1033" />
</Descriptions>
</Attribute>
</Attributes>
</Entity>
</Entities>
</ImportExportXml>
4.4. Deployment Settings Configuration
deploymentSettings.json
This file is used by the automated ALM pipeline to inject environment-specific connection references and environment variable values during solution import.
{
"ConnectionReferences": [
{
"ConnectionReferenceLogicalName": "contoso_sharedcommondataserviceforapps_9f66d",
"ConnectionId": "9f66d1d455f3474ebf24e4fa2c04cea2",
"ConnectorId": "/providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps"
}
],
"EnvironmentVariables": [
{
"SchemaName": "contoso_EnterpriseApiEndpoint",
"Value": "https://api.test.contoso.com/v1"
},
{
"SchemaName": "contoso_EnableAdvancedLogging",
"Value": "true"
}
]
}
5. Configuration & Environment Setup
To support a multi-solution, multi-developer environment topology, you must configure environment variables, connection references, and Azure resources systematically.
Environment Variables Schema Definitions
Environment variables in Dataverse are split into two distinct tables:
- Environment Variable Definition (
environmentvariabledefinition): Stores the metadata, schema name, data type, and default value. This component is solution-aware and is transported across environments as a managed asset. - Environment Variable Value (
environmentvariablevalue): Stores the actual environment-specific value. This table is not solution-aware. It is treated as transactional data and must never be exported in your managed solution. Instead, values are injected during deployment via thedeploymentSettings.jsonfile.
Supported Data Types and Schema Attributes
String(Text)Number(Decimal)JSONBoolean(Two Options)DataSource(Used to store SharePoint Site URLs, SQL Server connection strings, etc.)Secret(Integrates directly with Azure Key Vault)
Azure Key Vault Integration for Secret Environment Variables
For sensitive data (API keys, client secrets, connection strings), you must use the Secret data type, which references secrets stored securely in Azure Key Vault.
Prerequisites
- An active Azure Key Vault.
- The Dataverse Service Principal (
Microsoft.PowerPlatform) must be granted the Key Vault Secrets User role in the Key Vault's Access Control (IAM).
Bicep Snippet: Provisioning Key Vault and Granting Permissions
This Bicep template provisions an Azure Key Vault and configures the access policy for the Power Platform service principal.
param location string = resourceGroup().location
param keyVaultName string = 'kv-contoso-enterprise'
param powerPlatformServicePrincipalObjectId string // Retrieve from Entra ID for 'Microsoft.PowerPlatform'
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: keyVaultName
location: location
properties: {
sku: {
family: 'A'
name: 'standard'
}
tenantId: subscription().tenantId
enableSoftDelete: true
softDeleteRetentionInDays: 90
enableRbacAuthorization: true // Enforce Azure RBAC
}
}
// Grant Key Vault Secrets User role to Power Platform Service Principal
resource rbacAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(keyVault.id, powerPlatformServicePrincipalObjectId, 'KeyVaultSecretsUser')
scope: keyVault
properties: {
// Key Vault Secrets User Role ID: 46330157-b7c1-4be3-a58a-777606b47e16
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '46330157-b7c1-4be3-a58a-777606b47e16')
principalId: powerPlatformServicePrincipalObjectId
principalType: 'ServicePrincipal'
}
}
output keyVaultUri string = keyVault.properties.vaultUri
Referencing the Secret in Dataverse
When creating the Environment Variable in the Maker Portal:
- Select Data Type: Secret.
- Provide the Azure Subscription ID, Resource Group Name, Key Vault Name, and the Secret Name exactly as defined in Azure.
6. Security & Permission Matrix
To enforce the principle of least privilege, architects must define a granular security matrix. This matrix covers platform-level roles, custom security roles, and Azure-side permissions.
Security Role and Privilege Matrix
| Principal | Permission / Privilege | Scope | Reason |
|---|---|---|---|
| Environment Maker | Create/Modify Solutions, Tables, Flows, Apps | Environment | Required for developers to build and package components in the Development environment. |
| System Customizer | Full customization privileges on all tables | Environment | Granted to developers in Dev environments; allows schema modifications. |
Custom Security Role: Contoso Core User | Read: contoso_projectWrite: contoso_projectAppend/AppendTo: contoso_project | Business Unit | Granted to standard business users to interact with Project records within their business unit. |
Custom Security Role: Contoso HR Manager | Read/Write/Delete: contoso_projectRead/Write: contoso_hrapprover column | Organization | Granted to HR managers to assign and approve projects globally. |
| Application User (Service Principal) | Read/Write: All custom tables in solution | Organization | Used by the CI/CD pipeline to import solutions and execute automated tests. |
Field Security Profile: Project Code Protection | Read: Allowed Update: Denied Create: Denied | User-Specific | Restricts modification of the contoso_projectcode column to the automated integration system only. |
| Azure AD App Registration | user_impersonation scope on Dataverse API | Tenant | Allows external client applications to authenticate and call Dataverse APIs on behalf of a user. |
7. Pipeline Execution Internals
When developing custom plugins, understanding the exact execution mechanics of the Dataverse Event Execution Pipeline is critical for ensuring performance and transactional consistency.
The Event Execution Pipeline Stages
Every data operation in Dataverse (Create, Update, Delete, Retrieve, etc.) passes through a multi-stage pipeline.
[Incoming Request]
|
v
+--------------------------------------------------+
| Stage 10: Pre-Validation | <-- Runs outside database transaction.
| - Ideal for basic validation & security checks. |
+--------------------------------------------------+
|
v
+--------------------------------------------------+
| Stage 20: Pre-Operation | <-- Database transaction starts.
| - Ideal for modifying target attributes. |
+--------------------------------------------------+
|
v
+--------------------------------------------------+
| Stage 30: Main Operation | <-- Platform executes core SQL operation.
| - Internal system logic only. |
+--------------------------------------------------+
|
v
+--------------------------------------------------+
| Stage 40: Post-Operation | <-- Database transaction still active.
| - Ideal for creating related records. |
+--------------------------------------------------+
|
v
[Database Transaction Committed]
|
v
+--------------------------------------------------+
| Asynchronous Registered Plugins | <-- Runs in background queue.
| - Ideal for non-blocking integrations. |
+--------------------------------------------------+
Stage 10: Pre-Validation (Synchronous)
- Transaction Boundary: Runs outside the database transaction.
- Behavior: Because it runs before the SQL transaction is initiated, database locks are not held. This is the optimal stage for heavy validation logic (such as regex checks or external API validation).
- Rollback: If a plugin throws an
InvalidPluginExecutionExceptionin this stage, the operation is cancelled immediately, and no database transaction is ever opened.
Stage 20: Pre-Operation (Synchronous)
- Transaction Boundary: Runs inside the database transaction.
- Behavior: The SQL transaction is open, and database locks are active. Changes made to the target entity attributes in this stage are written directly to the database during the Main Operation without requiring a separate update call.
- Rollback: If an exception is thrown, the entire transaction is rolled back.
Stage 30: Main Operation (System)
- Behavior: The platform executes the core database operation (e.g., SQL
INSERTorUPDATE). Custom plugins cannot be registered in this stage.
Stage 40: Post-Operation (Synchronous)
- Transaction Boundary: Runs inside the database transaction.
- Behavior: The core database operation has completed, and the record's unique ID is fully populated. This stage is used to perform secondary operations (such as creating child records or updating related tables).
- Rollback: If an exception is thrown, the core operation and all secondary operations are rolled back.
Callout Restrictions in Sandbox Mode
All custom plugins and custom workflow activities run within an isolated, high-security execution environment called the Sandbox Service. The sandbox enforces strict security boundaries:
- Network Restrictions: Outbound network calls are restricted to standard ports: 80 (HTTP) and 443 (HTTPS). Any attempt to connect via other ports (e.g., SQL Port 1433, SMTP Port 25) will be blocked.
- IP Resolution: DNS resolution is allowed, but direct IP address routing is blocked.
- Local Resources: Access to the local registry, local file system, and event logs is completely prohibited.
- Execution Timeout: Any synchronous step has a hard limit of 2 minutes (120 seconds). If the plugin does not complete execution within this window, the Sandbox worker process is terminated, and a timeout exception is thrown, rolling back the transaction.
Depth and Loop-Guard Limits
To prevent infinite loops (e.g., Plugin A updates Table X, which triggers Plugin B, which updates Table X, triggering Plugin A again), Dataverse implements a strict Depth Guard.
- The Limit: The maximum execution depth is 8.
- How it works: Every time a plugin triggers another operation within the same execution chain, the
Depthproperty of theIPluginExecutionContextis incremented by 1. If the depth reaches 9, the platform terminates the execution immediately and throws an error:MaxDepthExceeded. - Prevention Strategy: Developers must check the
context.Depthproperty at the beginning of the plugin execution and exit early if it exceeds a safe threshold.
if (context.Depth > 1)
{
tracingService.Trace("Depth is {0}. Exiting to prevent infinite loop.", context.Depth);
return;
}
8. Error Handling & Retry Patterns
Robust error handling is mandatory for enterprise-grade solutions. Developers must handle platform-specific exceptions and implement resilient retry patterns for external integrations.
Common Solution and Plugin Error Codes
| Error Code (Hex) | Error Name | Root Cause | Resolution |
|---|---|---|---|
0x8004801D | ImportMissingDependenciesError | The solution being imported depends on components that do not exist in the target environment. | Export and import the required base solution (e.g., the Hub solution) first. |
0x80048071 | ImportNewPluginTypesError | An update to a plugin assembly removed existing plugin types that are still referenced by active steps. | Increment the major/minor version of the plugin assembly, or delete the obsolete steps before importing. |
0x80048040 | ImportSolutionManagedToUnmanagedMismatch | The solution is already installed in the target environment as unmanaged, but the import package is managed. | Delete the unmanaged solution record in the target environment (note: this does not delete components), then import as managed. |
-2147220970 | MessageSizeExceeded | The payload size sent to the Sandbox service exceeds the platform limit of 116.85 MB. | Optimize queries to retrieve only required columns; avoid retrieving large binary/attachment columns in bulk. |
-2146893812 | IsvCodeReducedOpenTransactionCount | A custom plugin caught a database exception from an IOrganizationService call and swallowed it, attempting to continue. | Never swallow database exceptions. If an IOrganizationService call fails, you must throw an InvalidPluginExecutionException to roll back the transaction. |
Resilient Outbound Integration: Retry Pattern with Exponential Back-off
When a plugin must call an external API (e.g., an Azure Function) from a Post-Operation Asynchronous step, developers should implement a retry pattern with exponential back-off to handle transient network failures.
using System;
using System.Net.Http;
using System.Text;
using System.Threading;
using Microsoft.Xrm.Sdk;
namespace ContosoPlugins
{
public class AsyncExternalCallPlugin : IPlugin
{
private const int MaxRetries = 3;
private const int InitialDelayMilliseconds = 1000;
public void Execute(IServiceProvider serviceProvider)
{
IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
ITracingService tracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
// Ensure this is running asynchronously
if (context.IsExecutingOffline || context.Mode == 0) // 0 = Synchronous
{
tracingService.Trace("This plugin must only be run in Asynchronous mode. Exiting.");
return;
}
string payload = "{\"projectId\":\"" + context.PrimaryEntityId.ToString() + "\"}";
string endpoint = "https://api.contoso.com/v1/projects";
ExecuteWithRetry(endpoint, payload, tracingService);
}
private void ExecuteWithRetry(string url, string jsonPayload, ITracingService tracing)
{
int retryCount = 0;
int delay = InitialDelayMilliseconds;
using (HttpClient client = new HttpClient())
{
client.Timeout = TimeSpan.FromSeconds(15); // Keep timeout low within the plugin execution window
while (true)
{
try
{
tracing.Trace($"Attempting external call. Attempt {retryCount + 1} of {MaxRetries}");
HttpContent content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
HttpResponseMessage response = client.PostAsync(url, content).GetAwaiter().GetResult();
if (response.IsSuccessStatusCode)
{
tracing.Trace("External call succeeded.");
return;
}
tracing.Trace($"Server returned status code: {response.StatusCode}");
if ((int)response.StatusCode >= 500 || (int)response.StatusCode == 429)
{
// Transient error, eligible for retry
throw new HttpRequestException("Transient server error.");
}
else
{
// Non-transient error (e.g., 400 Bad Request, 401 Unauthorized) - Do not retry
throw new InvalidPluginExecutionException($"Non-transient error occurred: {response.StatusCode}");
}
}
catch (Exception ex) when (ex is HttpRequestException || ex is System.Threading.Tasks.TaskCanceledException)
{
retryCount++;
if (retryCount >= MaxRetries)
{
tracing.Trace("Max retries reached. Failing operation.");
throw new InvalidPluginExecutionException("Failed to connect to external service after multiple attempts.", ex);
}
tracing.Trace($"Transient error encountered: {ex.Message}. Retrying in {delay}ms...");
Thread.Sleep(delay);
delay *= 2; // Exponential back-off
}
}
}
}
}
}
9. Performance Optimisation & Limits
Architects must design solutions to operate efficiently within the platform's physical and logical boundaries.
Platform Limits & Caps
- Canvas App Delegation Limits: By default, non-delegable queries in Canvas Apps are limited to retrieving 500 records (can be increased to a maximum of 2,000 in app settings). If a query is non-delegable (e.g., using complex string manipulation or collection operations), Dataverse will only evaluate the first 500/2,000 records locally on the device, leading to incomplete data results.
- Service Protection API Limits: To ensure environment stability, Dataverse enforces throttling per user account, per web server:
- Request Limit: 6,000 requests within a sliding 5-minute window.
- Execution Time Limit: 20 minutes (1,200 seconds) of combined execution time within a sliding 5-minute window.
- Concurrency Limit: 52 concurrent requests. If exceeded, the platform returns HTTP Error 429 (Too Many Requests) with a
Retry-Afterheader.
- Payload Size Limits: The maximum size of a single web service request payload is 116.85 MB.
Optimization Strategies
1. High-Throughput Batching with ExecuteMultipleRequest
When migrating or updating data in bulk, do not send individual requests sequentially. Use ExecuteMultipleRequest to batch up to 1,000 operations into a single API call. This reduces network round-trips significantly.
// Example of ExecuteMultipleRequest configuration
ExecuteMultipleRequest requestWithResults = new ExecuteMultipleRequest()
{
Settings = new ExecuteMultipleSettings()
{
ContinueOnError = true, // Prevent the entire batch from failing if one record fails
ReturnResponses = true
},
Requests = new OrganizationRequestCollection() // Add up to 1000 Create/Update requests here
};
2. Transactional Batching with ExecuteTransactionRequest
If a batch of operations must execute atomically (either all succeed or all roll back), use ExecuteTransactionRequest. Note that this holds database locks for the duration of the transaction, so keep batch sizes small (typically under 100 records) to prevent blocking.
3. Query Optimization & Column Selection
Never use new ColumnSet(true) in plugins or integrations. This executes a SELECT * query, retrieving every column (including heavy lookup and description fields), which degrades SQL performance and increases payload sizes. Always specify only the exact columns required.
// Correct approach: Explicit column selection
QueryExpression query = new QueryExpression("contoso_project")
{
ColumnSet = new ColumnSet("contoso_projectid", "contoso_projectcode")
};
4. Asynchronous Offloading
If an operation does not require immediate, real-time feedback to the user, register the plugin step as Asynchronous. Asynchronous steps are queued and executed by the Asynchronous Processing Service, completely removing the execution overhead from the user's synchronous thread.
10. ALM & Deployment Checklist
This section provides an operational checklist and automation assets for moving solutions through a standard enterprise pipeline.
+-----------------+ Pull Request +------------------+ Release Trigger +-----------------+
| Dev Branch | ---------------------> | Main Branch | ------------------------> | Production |
| (Source Code) | | (Build Artifact) | | (Managed App) |
+-----------------+ +------------------+ +-----------------+
Ordered Deployment Checklist
- Freeze Customizations: Ensure all active development in the Development environment is complete and all customizations are published.
- Run Solution Checker: Execute the Power Apps Solution Checker on the unmanaged solution to identify performance, security, and supportability violations before export.
- Export Unmanaged Solution: Export the solution as unmanaged to act as the source control backup.
- Unpack Solution: Use the PAC CLI or SolutionPackager to unpack the solution zip file into human-readable YAML/XML files.
- Commit to Git: Commit the unpacked source files to your Git repository.
- Build Managed Solution: In the build pipeline, pack the source files and export the solution as managed. This managed zip file is your immutable build artifact.
- Prepare Deployment Settings: Generate and populate the
deploymentSettings.jsonfile with the target environment's connection IDs and environment variable values. - Import Managed Solution: Import the managed solution into the target environment (Test/Prod) using the deployment settings file.
- Publish Customizations: (Automated during import) Ensure all imported components are active.
- Verify Flow Activation: Confirm that all imported Power Automate flows are turned on and associated with the correct connection references.
Azure DevOps YAML Pipeline Snippet
This pipeline automates the process of packing a solution from source control, setting connection variables, and importing it into a target environment.
trigger:
- main
variables:
solutionName: 'ContosoCoreHub'
serviceConnection: 'Dataverse-Prod-SPN'
environmentUrl: 'https://contoso-prod.crm.dynamics.com'
pool:
vmImage: 'windows-latest'
steps:
# Step 1: Install Power Platform Build Tools
- task: microsoft-IsvExpTools.PowerPlatform-BuildTools.tool-installer.PowerPlatformToolInstaller@2
displayName: 'Power Platform Tool Installer'
inputs:
DefaultVersion: true
# Step 2: Pack the Solution from Source Control
- task: microsoft-IsvExpTools.PowerPlatform-BuildTools.pack-solution.PowerPlatformPackSolution@2
displayName: 'Pack Solution as Managed'
inputs:
SolutionSourceFolder: '$(Build.SourcesDirectory)/solutions/$(solutionName)'
SolutionOutputFile: '$(Build.ArtifactStagingDirectory)/$(solutionName)_managed.zip'
SolutionType: 'Managed'
# Step 3: Import the Managed Solution to Production
- task: microsoft-IsvExpTools.PowerPlatform-BuildTools.import-solution.PowerPlatformImportSolution@2
displayName: 'Import Solution to Production'
inputs:
authenticationType: 'PowerPlatformSPN'
PowerPlatformSPN: '$(serviceConnection)'
SolutionInputFile: '$(Build.ArtifactStagingDirectory)/$(solutionName)_managed.zip'
AsyncOperation: true
MaxAsyncWaitTime: '120'
UseDeploymentSettingsFile: true
DeploymentSettingsFile: '$(Build.SourcesDirectory)/config/prod-deploymentSettings.json'
OverwriteUnmanagedCustomizations: true
11. Common Pitfalls & Troubleshooting Guide
1. Active Unmanaged Layers in Downstream Environments
- Symptom: A managed solution import succeeds in the Test or Production environment, but the changes (e.g., a modified form or updated flow) do not appear to end-users.
- Root Cause: An unmanaged customization was made directly in the target environment. This created an "Active" unmanaged layer that sits above the imported managed layer, blocking the updates.
- Diagnostic Steps: Navigate to the component in the Maker Portal, select See solution layers. If an "Unmanaged" layer is visible at the top of the stack, this is the blocker.
- Fix: Select the unmanaged layer in the Solution Layers screen and select Remove active customizations. This deletes the unmanaged override and allows the managed layer to take effect.
2. Missing Dependencies on Solution Import
- Symptom: Solution import fails with error code
0x8004801D(Some dependencies are missing). - Root Cause: The solution references components (e.g., a table, column, or security role) that exist in the Development environment but were not included in the solution package and do not exist in the target environment.
- Diagnostic Steps: Download the import log zip file, open the
log.xmlfile, and locate the<MissingDependencies>node to identify the exact missing component IDs. - Fix: Add the missing components to the solution in the Development environment, or ensure the base solution containing those components is deployed to the target environment first.
3. Circular Solution Dependencies
- Symptom: Solution A cannot be imported because it depends on Solution B. Solution B cannot be imported because it depends on Solution A.
- Root Cause: Poor architectural boundaries. Components in Solution A reference components in Solution B, and vice versa.
- Diagnostic Steps: Analyze the missing dependency logs for both solutions to map the cross-references.
- Fix: Refactor the architecture. Move the shared components that are causing the circular reference into a third, lower-level "Base" or "Hub" solution that both Solution A and Solution B can safely depend on.
4. Plugin Assembly Versioning Mismatch
- Symptom: Solution import fails with error
0x80048071(Existing plug-in types have been removed). - Root Cause: A developer modified a plugin class name or deleted a plugin class in the assembly, but the corresponding plugin steps are still registered in the target environment.
- Diagnostic Steps: Compare the assembly class list in the target environment (via PRT) with the classes defined in the incoming assembly.
- Fix: Re-add the missing class to the assembly (even as an empty stub), import the solution, manually delete the associated steps in the target environment, and then perform a clean import with the class removed.
5. Loss of Connection References in Canvas Apps
- Symptom: After importing a solution, a Canvas App fails to load data, throwing connection errors.
- Root Cause: The Canvas App was exported with direct, hard-coded connections instead of utilizing Connection References.
- Diagnostic Steps: Open the app in edit mode in the target environment and check the data sources. If they show warning icons and prompt for credentials, connection references were bypassed.
- Fix: In the Development environment, delete the direct connections from the app, add Connection References to the solution, bind the app's data sources to those Connection References, and re-export.
6. Flow Activation Failures Post-Import
- Symptom: Managed solution imports successfully, but cloud flows remain in a "Disabled" state.
- Root Cause: The connection references utilized by the flow are not associated with a valid, authenticated connection in the target environment, or the flow owner lacks the required security privileges.
- Diagnostic Steps: Open the flow details page in the target environment. Look for the warning banner indicating missing connections.
- Fix: Authenticate the connections bound to the connection references in the target environment, then turn the flow on. Ensure this is automated in your pipeline using a deployment settings file.
7. Environment Variable Caching Issues
- Symptom: An environment variable value is updated in the target environment, but plugins or flows continue to use the old value.
- Root Cause: Dataverse caches environment variable values for up to 1 hour to optimize performance.
- Diagnostic Steps: Check the
environmentvariablevaluetable directly via Web API to confirm the value is updated in the database. - Fix: To force a cache refresh, perform a "Publish All Customizations" operation, or recycle the Sandbox worker process by making a minor modification to a plugin registration step.
8. Publisher Prefix Mismatches
- Symptom: A developer cannot add a custom column to a table, receiving an error that the prefix does not match.
- Root Cause: The unmanaged solution was created under a different publisher than the one that owns the target table.
- Diagnostic Steps: Check the unique name of the table (e.g.,
contoso_project) and compare its prefix with the prefix of the active solution's publisher. - Fix: Ensure all solutions in the environment chain utilize the same Solution Publisher (
contoso) to allow seamless extension.
9. PCF Control Fails to Render in Model-Driven App
- Symptom: A custom PCF control displays as a blank space or a standard text box on a form.
- Root Cause: The PCF control was built in "Debug" mode, or the control resources failed to load due to browser caching.
- Diagnostic Steps: Open browser developer tools (F12) and check the console for script loading errors.
- Fix: Rebuild the PCF control in "Release" mode (
msbuild /p:configuration=Release), update the solution version, import, and perform a hard browser refresh (Ctrl+F5).
10. Sandbox Worker Process Crashes
- Symptom: Plugin execution fails with error "The plug-in execution failed because the Sandbox Worker process crashed."
- Root Cause: The plugin code is leaking memory (e.g., storing data in static variables) or has exceeded the physical memory limits allocated to the sandbox container.
- Diagnostic Steps: Review the plugin code for any static collections or infinite loops.
- Fix: Ensure all plugin implementations are completely stateless. Do not use static variables to store request-specific data.
12. Exam Focus: Key Facts & Edge Cases
To pass the PL-400 exam, candidates must memorize the following platform limits, behaviors, and API signatures:
- Plugin Execution Timeout: Exactly 2 minutes (120 seconds). This is a hard limit and cannot be configured or increased.
- Plugin Depth Limit: Exactly 8. The 9th execution in a single chain will trigger a
MaxDepthExceededexception and roll back the transaction. - Sandbox Port Restrictions: Outbound network calls from plugins are restricted to ports 80 and 443 only.
- Plugin Stage Numbers:
Pre-Validation= 10Pre-Operation= 20Post-Operation= 40
- Transaction Rollback Behavior: Any exception thrown in synchronous stages (10, 20, or 40) rolls back the entire database transaction. Exceptions thrown in Asynchronous steps do not roll back the transaction.
- Swallowing Exceptions: Swallowing database exceptions in a plugin (using
try-catchwithout re-throwing) causes error-2146893812(ISV code reduced open transaction count). You must always throw anInvalidPluginExecutionException. - Solution Packager Formats: SolutionPackager supports both the legacy XML format and the modern YAML format (introduced with Git integration).
- Table Segmentation Rule: When extending an existing table in a spoke solution, you must not select "Include all objects". You must only select the specific columns, forms, or views you modified.
- Publisher Prefix Length: A customization prefix must be between 2 and 8 characters long, consist of alphanumeric characters, start with a letter, and cannot start with
mscrm. - Environment Variable Tables: Definitions are stored in
environmentvariabledefinition(solution-aware). Values are stored inenvironmentvariablevalue(not solution-aware). - PCF Lifecycle Methods:
init: Called once to initialize the control and build the UI.updateView: Called whenever the bound data or environment context changes.getOutputs: Called by the framework to retrieve modified data from the control.destroy: Called when the control is removed from the DOM to clean up event listeners.