Showing posts with label DataversePlugins. Show all posts
Showing posts with label DataversePlugins. Show all posts

Sunday, May 17, 2026

Power Platform ALM & DevOps (PL-400) Complete Guide

Power Platform ALM & DevOps (PL-400) — Complete Guide

Technical Design · Dataverse Plugins · PCF Controls · Custom Connectors · ALM · Pipelines · Power Platform CLI · Azure DevOps · Scenarios · Cheat Sheet

Top Hashtags: #PowerPlatformALM, #PL400, #PowerPlatformDeveloper, #PowerPlatform, #DataversePlugins, #PCFControls, #PowerPlatformCLI, #ALMPowerPlatform, #LowCode, #MicrosoftDeveloper


Table of Contents

  1. Exam Overview & Technical Design (10–15%)
  2. Build Power Platform Solutions (10–15%)
  3. Extend the User Experience (10–15%)
  4. Extend the Platform — Plugins & APIs (40–45%)
  5. Power Platform ALM & DevOps
  6. Develop Integrations (5–10%)
  7. Scenario-Based Questions
  8. Cheat Sheet — Quick Reference

1. Exam Overview & Technical Design (10–15%)

PL-400 Exam at a Glance

The PL-400 Microsoft Power Platform Developer exam validates expertise in designing, developing, testing, and troubleshooting Power Platform solution components using extension points. Candidates should be proficient in Microsoft Power Platform services, C#, JavaScript, TypeScript, JSON, HTML, RESTful Web APIs, and Azure.

Skill Domain Exam Weight
Create a technical design 10–15%
Build Power Platform Solutions 10–15%
Implement Power Apps improvements 5–10%
Extend the user experience 10–15%
Extend the platform 40–45% ← LARGEST
Develop integrations 5–10%

Key fact: Extend the platform is 40–45% of the exam. Prioritise: Dataverse plugins (C#), custom connectors, Power Platform CLI, ALM, and Dataverse Web API.


How do you decide between low-code and pro-code in Power Platform?

Decision framework: Low-code vs Pro-code

Always try low-code first (cheaper, faster, maintainable by citizen devs):
→ Standard Power Apps (canvas or model-driven) for the UI
→ Power Automate cloud flows for automation
→ Dataverse built-in business rules for simple field validation
→ Dataverse calculated/rollup columns for derived values

When to escalate to pro-code:
→ Complex server-side business logic requiring ACID transactions
  → Use: Dataverse C# Plugin
→ Performance-critical real-time validation across multiple tables
  → Use: Dataverse C# Plugin (synchronous pre-operation)
→ Custom UI control behaviour beyond standard controls
  → Use: PCF (Power Apps Component Framework) control
→ Connect to a service with no existing connector
  → Use: Custom Connector (OpenAPI/Swagger definition)
→ Long-running or complex operations with Azure infrastructure
  → Use: Azure Function called from plugin or Power Automate
→ Batch data processing or complex ETL
  → Use: Azure Function or Azure Logic App

Architecture decision checklist:
1. Can it be done with out-of-the-box Power Platform? → Do it low-code
2. Needs server-side logic run on create/update/delete? → C# Plugin
3. Needs a custom UI component? → PCF Control
4. Needs external system not in connector gallery? → Custom Connector
5. Needs Azure compute, ML, or AI? → Azure Function + connector
6. Needs human approval + complex branching? → Power Automate flow

What is the Power Platform solution structure and types?

Solutions:
→ Container for customisations and components that can be transported
  between environments (Dev → Test → Production)
→ Everything deployable must be inside a solution

Solution types:
Unmanaged solution:
→ Used in DEVELOPMENT environments only
→ All components editable
→ No version enforcement
→ Contains references to components (not copies)
→ Can be exported as unmanaged (.zip) for source control

Managed solution:
→ Deployed to TEST and PRODUCTION environments
→ Components are READ-ONLY (cannot be edited directly)
→ Version enforced — only newer version can replace it
→ Uninstalling removes all components cleanly
→ Created by exporting an unmanaged solution as managed

Key decision: NEVER develop in managed solution environments
             NEVER deploy unmanaged solutions to production

Solution components (what can be inside a solution):
Tables (entities), columns (fields), relationships
Views, forms, charts, dashboards
Cloud flows, canvas apps, model-driven apps
Security roles, environment variables, connection references
Plugins, custom workflow activities, web resources
PCF controls, custom connectors, dataflows

Environment variables:
→ Store environment-specific configuration (URLs, keys, thresholds)
→ Different values per environment (Dev/Test/Prod) without code change
→ Types: string, number, boolean, JSON, data source
→ Referenced in: flows, plugins, canvas apps, cloud flows

Connection references:
→ Abstractly reference a connector (instead of hardcoding a connection)
→ When solution deployed: admin configures the actual connection
→ Enables the same flow to connect to different accounts per environment
→ Always use connection references instead of direct connections in flows

2. Build Power Platform Solutions (10–15%)

How do you configure Dataverse tables and relationships for complex solutions?

Dataverse table types:
Standard table:    most tables — PK is a GUID, full Dataverse features
Activity table:    special type for activities (email, task, appointment)
                   enables activity timeline, sender/recipient model
Elastic table:     designed for high-volume, time-series or IoT data
                   uses Cosmos DB underneath — horizontal scale
Virtual table:     maps to external data source (SQL, OData, etc.)
                   appears as Dataverse table, queries external system live

Relationship types:
One-to-many (1:N): one parent row → many child rows
                   creates a lookup column on the child table
                   e.g., Account (1) → Contacts (N)

Many-to-many (N:N): via an intersect (junction) table
                    e.g., Contacts ↔ Marketing Lists
                    Use: relate records without a hierarchy

Polymorphic lookup: lookup that can point to multiple table types
                    e.g., Regarding field on Activity — can be Account,
                    Contact, Lead, or Opportunity

Column types:
Simple: Text, Number, Date, Choice (picklist), Lookup, File/Image
Calculated: value computed from formula (no triggers, re-computed on read)
Rollup: aggregate from child records (sum, count, avg, min, max)
        runs on a schedule (hourly) — not real-time
Formula: Power Fx expressions (Dataverse-native, new)
Auto-number: automatically incremented unique identifiers

Business rules (no-code server + client logic):
→ Set field values, show/hide fields, validate values, lock fields
→ Scope: entity (server-side — always runs) or form (client-side only)
→ Cannot: query other tables, send emails, perform HTTP calls
→ For complex logic: use plugins instead

What is Power Fx and how is it used in Power Apps?

Power Fx:
→ Open-source, declarative formula language based on Excel-style syntax
→ Used in: Canvas Apps, Model-driven app command bars, Dataverse formulas
→ Reactive: formulas automatically recalculate when data changes

Key Power Fx patterns for PL-400:

Collections and local data:
// Create a collection:
ClearCollect(colProducts,
  Filter(Products, Status.Value = "Active"))

// Add a record:
Collect(colProducts, {Name: "Widget", Price: 9.99})

// Update in collection:
UpdateIf(colProducts, ProductId = varSelectedId,
  {Price: 12.99})

Delegation (CRITICAL concept):
→ Delegation = Dataverse handles the filter/sort on the server
→ Non-delegable = Power Apps downloads ALL records (max 2000) then filters
→ Always use delegable functions for large datasets

Delegable functions with Dataverse:
  Filter(Products, Status.Value = "Active")  ← DELEGABLE (server-filters)
  Filter(Products, Text(Price) = "9.99")    ← NOT DELEGABLE (Text() blocks delegation)

Workaround for non-delegable:
  Set(varProducts, Filter(Products, Status.Value = "Active"))  // 2000 limit
  Filter(varProducts, Price > 5)  // then filter collection client-side

Named formulas (performance):
// Named formula — evaluated once, cached:
ProductCount = CountRows(Filter(Products, Status = "Active"))

With() — local variables for readability:
With({total: Sum(OrderLines, Quantity * UnitPrice),
       tax: Sum(OrderLines, Quantity * UnitPrice) * 0.20},
  total + tax)

3. Extend the User Experience (10–15%)

What is the Power Apps Component Framework (PCF) and when do you use it?

PCF (Power Apps Component Framework):
→ Framework for building custom reusable UI components for Power Apps
→ Replaces HTML web resources for custom UI in model-driven apps
→ Available in: Model-driven apps AND canvas apps
→ Built with: TypeScript, React (optional), CSS, third-party libraries

Two types of PCF controls:
Field controls: replace a single field on a form
  → e.g., custom date picker, colour picker, rating stars, signature pad
Dataset controls: replace a grid/subgrid view
  → e.g., custom chart, calendar view, map view, Kanban board

PCF control lifecycle:
init(context, notifyOutputChanged, state, container):
  → Called once when control loads
  → Receive context (current field value, user info, client info)
  → Set up the component

updateView(context):
  → Called whenever field value OR model changes
  → Re-render the component with new data

getOutputs():
  → Called when notifyOutputChanged() is triggered
  → Return the new value to write back to Dataverse

destroy():
  → Called when control removed from DOM — cleanup

PCF TypeScript example (field control — custom star rating):
import { IInputs, IOutputs } from "./generated/ManifestTypes";

export class StarRating implements ComponentFramework.StandardControl<IInputs, IOutputs> {
  private _value: number;
  private _notifyOutputChanged: () => void;
  private _container: HTMLDivElement;

  init(context: ComponentFramework.Context<IInputs>,
       notifyOutputChanged: () => void,
       state: ComponentFramework.Dictionary,
       container: HTMLDivElement): void {
    this._notifyOutputChanged = notifyOutputChanged;
    this._container = container;
    this._value = context.parameters.rating.raw ?? 0;
    this.renderStars();
  }

  updateView(context: ComponentFramework.Context<IInputs>): void {
    this._value = context.parameters.rating.raw ?? 0;
    this.renderStars();
  }

  getOutputs(): IOutputs {
    return { rating: this._value };
  }

  private renderStars(): void {
    this._container.innerHTML = '';
    for (let i = 1; i <= 5; i++) {
      const star = document.createElement('span');
      star.textContent = i <= this._value ? '★' : '☆';
      star.onclick = () => { this._value = i; this._notifyOutputChanged(); };
      this._container.appendChild(star);
    }
  }

  destroy(): void { /* cleanup */ }
}

PCF CLI commands:
pac pcf init --namespace Contoso --name StarRating --template field
npm install
npm run build
pac pcf push --publisher-prefix contoso  # push to dev environment for testing

What are client-side scripts (JavaScript) in model-driven apps?

Client-side scripts (JavaScript) in model-driven apps:
→ Customise form behaviour without server-side code
→ Registered on: form events, field events, grid events, ribbon buttons

Key form events:
OnLoad:   fires when form loads (initialise, set defaults, hide/show)
OnSave:   fires when user saves (validate, prevent save, show warnings)
OnChange: fires when a specific field value changes

Xrm object model — the JavaScript API for model-driven apps:
// Get a field value:
const accountName = formContext.getAttribute("name").getValue();

// Set a field value:
formContext.getAttribute("creditlimit").setValue(50000);

// Show/hide a field:
formContext.getControl("telephone1").setVisible(false);

// Make a field required:
formContext.getAttribute("emailaddress1")
  .setRequiredLevel("required");

// Display a form notification:
formContext.ui.setFormNotification(
  "Credit limit exceeded!",
  "WARNING",   // INFO | WARNING | ERROR
  "creditWarning"
);

// Prevent save with validation error:
function validateForm(executionContext) {
  const formContext = executionContext.getFormContext();
  const email = formContext.getAttribute("emailaddress1").getValue();
  if (email && !email.includes("@")) {
    executionContext.getEventArgs().preventDefault();
    formContext.ui.setFormNotification(
      "Invalid email address", "ERROR", "emailError");
  }
}

// Call Dataverse Web API from client script:
Xrm.WebApi.retrieveMultipleRecords("account",
  "?$select=name,creditlimit&$filter=statecode eq 0&$top=10")
  .then(result => {
    result.entities.forEach(account => {
      console.log(account.name, account.creditlimit);
    });
  });

// Open a related record:
Xrm.Navigation.openForm({
  entityName: "contact",
  entityId: contactId
});

4. Extend the Platform — Plugins & APIs (40–45%)

What is a Dataverse Plugin and how does it work?

Dataverse Plugin:
→ C# class library that executes server-side business logic
→ Triggered by Dataverse events (create, update, delete, retrieve, etc.)
→ Runs within the Dataverse platform — no external hosting needed
→ Enforced regardless of HOW data is modified (Portal, API, App, import)

Plugin architecture:
public class AccountValidationPlugin : IPlugin
{
  public void Execute(IServiceProvider serviceProvider)
  {
    // Get plugin execution context:
    var context = (IPluginExecutionContext)
      serviceProvider.GetService(typeof(IPluginExecutionContext));

    // Get organisation service for Dataverse operations:
    var serviceFactory = (IOrganizationServiceFactory)
      serviceProvider.GetService(typeof(IOrganizationServiceFactory));
    var service = serviceFactory.CreateOrganizationService(context.UserId);

    // Get tracing service for logging:
    var tracingService = (ITracingService)
      serviceProvider.GetService(typeof(ITracingService));

    // Only run on Account Create:
    if (context.MessageName != "Create" ||
        context.PrimaryEntityName != "account") return;

    // Get the target entity (the record being created):
    var target = (Entity)context.InputParameters["Target"];

    // Business logic:
    if (target.Contains("creditlimit") &&
        target.GetAttributeValue<Money>("creditlimit").Value > 1000000)
    {
      throw new InvalidPluginExecutionException(
        "Credit limit cannot exceed £1,000,000. Please contact Finance.");
    }

    tracingService.Trace("Account validation passed");
  }
}

What are plugin pipeline stages and what happens in each?

Plugin execution pipeline (for each Dataverse message):

PRE-VALIDATION (stage 10):
→ Fires BEFORE the database transaction begins
→ Can query external systems without transaction overhead
→ Can prevent the operation with an exception
→ Use for: input validation, external system checks
→ No access to pre-entity image (record doesn't exist yet for Create)

PRE-OPERATION (stage 20):
→ Fires WITHIN the database transaction (BEFORE write to DB)
→ Can modify the Target entity before it's saved
→ Changes to Target automatically saved — no explicit update needed
→ Use for: set default field values, enrich the record before save
→ Pre-entity image: available for Update (the record as it was before)

MAIN OPERATION (stage 30):
→ The actual Dataverse platform operation (Core operation)
→ Cannot register custom plugins at this stage
→ Platform writes data to the database here

POST-OPERATION (stage 40):
→ Fires WITHIN the database transaction (AFTER write to DB)
→ Record is now in the database
→ Can trigger follow-on operations (create related records, send emails)
→ Post-entity image: available (record as it is now after save)
→ Changes require explicit service.Update() calls
→ Use for: creating related records, triggering workflows, notifications

Execution mode:
Synchronous: runs in the same thread as the user operation
             failure rolls back the entire transaction
             use for: validation, data enrichment (pre-operation)
Asynchronous: runs in background queue (separate thread)
              failure does NOT roll back the user operation
              use for: send emails, call external APIs, non-critical operations

Plugin images (snapshots of the record):
Pre-image:  snapshot of the record BEFORE the operation
Post-image: snapshot of the record AFTER the operation
Both must be registered in Plugin Registration Tool (specify column names)

What are Custom Workflow Activities?

Custom Workflow Activities:
→ C# classes that extend Power Automate / Classic Workflows
→ Appear as steps that can be added in Power Automate (classic) or
  used in low-code flows
→ Use when: need reusable server-side C# logic callable from flows

Structure:
public class CalculateDiscount : CodeActivity
{
  // Input parameters from the flow:
  [Input("Order Amount")]
  [RequiredArgument]
  public InArgument<decimal> OrderAmount { get; set; }

  [Input("Customer Tier")]
  public InArgument<string> CustomerTier { get; set; }

  // Output parameters returned to the flow:
  [Output("Discount Percentage")]
  public OutArgument<decimal> DiscountPercentage { get; set; }

  protected override void Execute(CodeActivityContext context)
  {
    var amount = OrderAmount.Get(context);
    var tier = CustomerTier.Get(context) ?? "Standard";

    decimal discount = tier switch {
      "Gold"     => amount > 10000 ? 0.15m : 0.10m,
      "Silver"   => 0.05m,
      _          => 0m
    };

    DiscountPercentage.Set(context, discount);
  }
}

Plugin vs Custom Workflow Activity:
Plugin:                    Triggered by Dataverse events (create/update/delete)
Custom Workflow Activity:  Called from Power Automate or classic workflows
                           Reusable across multiple flows
                           User provides input, receives output

What is the Dataverse Web API and how do you use it?

Dataverse Web API:
→ RESTful OData v4 API for all Dataverse operations
→ Endpoint: https://{org}.api.crm11.dynamics.com/api/data/v9.2/
→ Authentication: OAuth 2.0 bearer token (Entra ID)
→ Available from: external apps, Azure Functions, Logic Apps, plugins

CRUD operations:

CREATE (POST):
POST /api/data/v9.2/accounts
Content-Type: application/json
{
  "name": "Contoso Ltd",
  "creditlimit": 50000,
  "telephone1": "0207 123 4567",
  "primarycontactid@odata.bind": "/contacts(00000000-0000-0000-0000-000000000001)"
}
Response: 201 Created + OData-EntityId header with new record GUID

READ (GET with OData):
GET /api/data/v9.2/accounts
  ?$select=name,creditlimit,telephone1
  &$filter=creditlimit gt 10000 and statecode eq 0
  &$orderby=name asc
  &$top=50
  &$expand=primarycontactid($select=fullname,emailaddress1)

UPDATE (PATCH — partial update):
PATCH /api/data/v9.2/accounts({accountId})
Content-Type: application/json
{
  "creditlimit": 75000,
  "telephone1": "0207 999 8888"
}

DELETE:
DELETE /api/data/v9.2/accounts({accountId})

Upsert (PATCH with If-None-Match: *):
PATCH /api/data/v9.2/accounts({alternateKey})
If-None-Match: *   ← create if not exists, update if exists
{ ... }

Batch operations (multiple requests in one HTTP call):
POST /api/data/v9.2/$batch
Content-Type: multipart/mixed; boundary=batch_boundary
→ Reduces round trips for bulk operations
→ Supports change sets (atomic — all succeed or all fail)

Execute actions:
POST /api/data/v9.2/WinOpportunity
{
  "OpportunityClose": {
    "subject": "Won deal",
    "opportunityid@odata.bind": "/opportunities({opportunityId})"
  },
  "Status": 3
}

5. Power Platform ALM & DevOps

What is Power Platform ALM and what are the key components?

ALM (Application Lifecycle Management) for Power Platform:
→ The practices and tools for managing the full lifecycle of Power Platform
  solutions from development through test to production

Core ALM components:
Environments:     Dev → Test/UAT → Production
                  Each environment is isolated with its own data
Solutions:        the packaging unit — transport customisations between envs
Source control:   Git repository stores solution source files
CI/CD pipelines:  automate build, test, deploy using Azure DevOps or GitHub

Environment strategy (recommended):
Developer (individual):   each developer has their own personal dev env
                          unmanaged solution, develop and unit test here
Test/QA:                  shared managed solution deployment, QA testing
UAT/Pre-prod:             user acceptance testing with business users
Production:               live environment — only managed solutions deployed

Key principles:
→ Never develop in Test or Production environments
→ Always deploy MANAGED solutions to non-dev environments
→ Every customisation lives inside a solution (nothing ungrouped)
→ Source control is the source of truth — not any environment
→ Automated deployment pipelines — no manual exports/imports

What is the Power Platform CLI and what can you do with it?

Power Platform CLI (pac CLI):
→ Command-line tool for Power Platform development and ALM
→ Install: dotnet tool install -g Microsoft.PowerApps.CLI
→ Or: via npm, VS Code extension, or Power Platform Tools installer

Key commands:

Authentication:
pac auth create --url https://org.crm.dynamics.com  # add environment
pac auth list                                        # list connections
pac auth select --index 1                            # switch environment

Solution management:
pac solution list                                    # list all solutions in env
pac solution export --path ./exported --name MySolution --managed false
pac solution import --path ./MySolution.zip
pac solution clone --name MySolution --outputDirectory ./src
# Clone: creates local folder structure with each component in separate file

Environment management:
pac env list                                         # list environments
pac env select --environment "Dev Environment"
pac env create --name "My-Dev-Env" --type Developer --region unitedstates

PCF development:
pac pcf init --namespace Contoso --name MyControl --template field --run-npm-install
pac pcf push --publisher-prefix contoso              # push to environment
pac pcf version --updateType patch                   # bump version

Plugin development:
pac plugin init                                      # scaffold plugin project
pac plugin push                                      # push assembly to env

Canvas app:
pac canvas pack --sources ./CanvasApp --msapp ./output/MyApp.msapp
pac canvas unpack --msapp ./MyApp.msapp --sources ./unpacked  # for source control

Solution unpack (for source control — YAML format):
pac solution unpack --zipFile MySolution.zip --folder ./src --processCanvasApps
# Creates: Entities, Flows, Workflows, WebResources folders
# Each component in separate file → meaningful Git diffs

Solution pack (from source control → zip for deployment):
pac solution pack --zipFile MySolution_managed.zip --folder ./src --managed

How do you implement CI/CD for Power Platform with Azure DevOps?

Power Platform Build Tools (Azure DevOps):
→ Microsoft-provided extension: "Power Platform Build Tools"
→ Provides DevOps tasks for: export, import, publish, check solution

Complete CI/CD pipeline pattern:

CI Pipeline (Build) — triggered on PR to main branch:
1. Power Platform: Who Am I            # validate connection
2. Power Platform: Export Solution     # export unmanaged from Dev env
3. Power Platform: Unpack Solution     # unpack to source files
4. Power Platform: Check Solution      # run Solution Checker (code quality)
5. Power Platform: Pack Solution       # pack as MANAGED for deployment
6. Publish Artifact                    # publish zip for release pipeline

CD Pipeline (Release) — triggered on merge to main:
Stage 1 — Deploy to Test:
  Power Platform: Import Solution      # import managed solution to Test
  Power Platform: Publish Customizations
  Run automated tests (EasyRepro / PAC test)

Stage 2 — Deploy to UAT (manual approval gate):
  Approval required from business stakeholder
  Power Platform: Import Solution      # import managed to UAT

Stage 3 — Deploy to Production (manual approval):
  Approval required from release manager
  Power Platform: Import Solution      # import managed to Production

Azure DevOps YAML pipeline example:
trigger:
  branches:
    include: [main]

pool:
  vmImage: 'windows-latest'

steps:
- task: PowerPlatformToolInstaller@2
  displayName: 'Install Power Platform Build Tools'

- task: PowerPlatformWhoAmi@2
  displayName: 'Authenticate to Dev'
  inputs:
    authenticationType: 'PowerPlatformSPN'
    PowerPlatformSPN: 'PP-Dev-SPN'   # service connection

- task: PowerPlatformExportSolution@2
  displayName: 'Export Solution'
  inputs:
    authenticationType: 'PowerPlatformSPN'
    PowerPlatformSPN: 'PP-Dev-SPN'
    SolutionName: 'ContosoSolution'
    SolutionOutputFile: '$(Build.ArtifactStagingDirectory)/ContosoSolution.zip'
    Managed: false

- task: PowerPlatformUnpackSolution@2
  displayName: 'Unpack Solution'
  inputs:
    SolutionInputFile: '$(Build.ArtifactStagingDirectory)/ContosoSolution.zip'
    SolutionTargetFolder: '$(Build.SourcesDirectory)/src/solution'

- task: PowerPlatformChecker@2
  displayName: 'Solution Checker'
  inputs:
    authenticationType: 'PowerPlatformSPN'
    PowerPlatformSPN: 'PP-Dev-SPN'
    FilesToAnalyze: '$(Build.ArtifactStagingDirectory)/ContosoSolution.zip'
    RuleSet: '0ad12346-e108-40b8-a956-9a373e9909ff'  # AppSource ruleset
    ErrorLevel: High
    FailOnPowerAppsCheckerAnalysisError: true

What are Power Platform Pipelines (in-product ALM)?

Power Platform Pipelines (Fabric/Managed Pipelines):
→ Microsoft-native ALM solution BUILT INTO Power Platform (no Azure DevOps needed)
→ Configured entirely in the Power Platform Admin Centre / Pipelines app
→ Designed for: citizen developer ALM, simpler governance without DevOps expertise

Pipeline concepts:
Host environment:   dedicated environment that hosts the pipeline configuration
                    + deployment history + logs
Linked environments: Dev → Test → Production environments connected to the pipeline
Stage gates:         manual approvals before deployment to each stage

Features:
→ One-click deployment from within the Maker experience (no DevOps knowledge needed)
→ Pre-deployment and post-deployment steps (run cloud flows)
→ Deployment notes and audit trail
→ Environment variable configuration per stage
→ Connection reference configuration per stage
→ Deployment history with rollback capability

When to use Pipelines vs Azure DevOps:
Power Platform Pipelines:
  → Simpler orgs, citizen developer-led ALM
  → No DevOps team or expertise
  → Quick setup, managed by Power Platform admins
  → Limited customisation and branching support

Azure DevOps / GitHub Actions:
  → Complex orgs, pro-dev teams
  → Need automated testing, branch strategies, complex approval gates
  → Full control over pipeline steps, scripts, and tooling
  → Integrates with broader DevOps ecosystem (work items, boards, repos)

6. Develop Integrations (5–10%)

What are Custom Connectors and how do you build one?

Custom Connector:
→ Wrapper around a REST or SOAP API that makes it usable in
  Power Automate, Power Apps, and Logic Apps
→ Defined using OpenAPI (Swagger) specification
→ Supports: authentication (API key, OAuth 2.0, Basic, Windows)

Building a custom connector:
Step 1 — Define the API:
→ Import an existing OpenAPI (Swagger) .json or .yaml file
→ Or: enter Postman collection URL
→ Or: build manually in the custom connector wizard

Step 2 — Configure authentication:
No auth:      public APIs, development
API key:      pass key in header or query string
OAuth 2.0:    full OAuth flow — user signs in to the external service
Generic OAuth: for non-standard OAuth flows

Step 3 — Define actions and triggers:
Action:    user calls this to perform an operation (POST order, GET customer)
Trigger:   external system notifies Power Automate of an event
  Polling trigger: Power Automate polls your API every N seconds
  Webhook trigger: external system sends event to Power Automate's webhook URL

Step 4 — Test and share:
→ Test each operation in the connector wizard
→ Share with specific users or the whole organisation
→ Submit to Microsoft AppSource for public listing (ISV scenario)

Custom connector policies (inline code transformations):
→ Policy templates: convert XML to JSON, set header, route request
→ Use for: API quirks without modifying the backend API
   e.g., external API uses XML response — policy converts to JSON
         external API URL changes per customer — route policy selects URL

Certification (certified connectors):
→ Submit to Microsoft for certification review
→ Certified connectors available to all Power Platform users
→ Requirements: public documentation, test accounts, SLA

What are Virtual Tables in Dataverse?

Virtual Tables (Virtual Entities):
→ Dataverse tables that map to EXTERNAL data sources
→ Data stays in the external system — Dataverse is a logical view
→ CRUD operations pass through to the external system in real time
→ Appear and behave like regular Dataverse tables (queries, forms, views)

How they work:
User opens a canvas app → retrieves Virtual Table records
→ Dataverse calls the Virtual Table Data Provider
→ Data Provider calls the external system (SQL, OData, REST API)
→ External system returns data → Dataverse returns to the user
→ No data copy, no sync, always live

Virtual Table providers:
OData v4 provider:  built-in — connect to any OData v4 endpoint
Custom provider:    C# plugin registered as a data provider
                    handles: retrieve, retrieve multiple, create, update, delete

Custom virtual table data provider (C#):
public class ExternalProductsPlugin : IPlugin
{
  public void Execute(IServiceProvider serviceProvider)
  {
    var context = serviceProvider.GetService<IPluginExecutionContext>();

    if (context.MessageName == "RetrieveMultiple")
    {
      // Call external API (e.g., legacy ERP system):
      var products = CallERPSystemAPI();

      // Convert to EntityCollection:
      var entityCollection = new EntityCollection();
      foreach (var product in products)
      {
        var entity = new Entity("contoso_product");
        entity["contoso_name"] = product.Name;
        entity["contoso_price"] = product.Price;
        entityCollection.Entities.Add(entity);
      }
      context.OutputParameters["BusinessEntityCollection"] = entityCollection;
    }
  }
}

When to use virtual tables:
→ External data that must appear in Power Apps without ETL
→ Read-only reference data from a legacy system
→ Data too large to copy into Dataverse (compliance, licensing)
→ Real-time inventory, pricing from ERP — must not have sync lag

Limitations:
→ No offline access (requires live connection to external system)
→ No Dataverse-native features (rollup columns, duplicate detection)
→ Performance depends on external system response time
→ No audit history (Dataverse doesn't own the data)

7. Scenario-Based Questions

Scenario: A business rule is causing performance issues on a large Account table. How do you fix it?

Problem analysis:
→ Business rules with entity scope run server-side on every record operation
→ Complex business rules with many conditions slow down create/update operations
→ Business rules cannot be indexed or optimised by the database

Diagnosis steps:
1. Review business rules: Dataverse table → Business Rules → check scope
   Entity-scoped rules run on every create/update from ANY source
2. Check if rule fires unnecessarily: is it checking conditions that
   rarely change? e.g., checking account name on every address update
3. Profile with XrmToolBox → Plugin Trace Log: measure execution time

Solutions:
Option A — Convert to Pre-Operation Plugin (best performance):
→ Plugin only fires on specific messages you configure
→ Can check context.InputParameters to abort early if not relevant
→ Can use pre-image to only run if relevant fields actually changed:

var preImage = context.PreEntityImages["PreImage"];
var oldCreditLimit = preImage.GetAttributeValue<Money>("creditlimit")?.Value ?? 0;
var newCreditLimit = ((Entity)context.InputParameters["Target"])
                      .GetAttributeValue<Money>("creditlimit")?.Value ?? 0;
if (oldCreditLimit == newCreditLimit) return; // skip if not changed

Option B — Scope to Form only (not entity):
→ Changes business rule scope from Entity to Form
→ Rule only fires client-side when user is on the form
→ Not enforced for API/import operations — use only for UX enhancement

Option C — Replace with Calculated/Formula column:
→ If rule is setting a derived value, use a calculated column instead
→ Evaluated on read, not on write — zero write overhead

Scenario: Design a plugin that auto-creates a follow-up Task when an Opportunity is marked as Won.

public class OpportunityWonPlugin : IPlugin
{
  public void Execute(IServiceProvider serviceProvider)
  {
    var context = (IPluginExecutionContext)
      serviceProvider.GetService(typeof(IPluginExecutionContext));
    var serviceFactory = (IOrganizationServiceFactory)
      serviceProvider.GetService(typeof(IOrganizationServiceFactory));
    var service = serviceFactory.CreateOrganizationService(context.UserId);
    var tracingService = (ITracingService)
      serviceProvider.GetService(typeof(ITracingService));

    // Only on Opportunity Update:
    if (context.MessageName != "Update" ||
        context.PrimaryEntityName != "opportunity") return;

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

    // Only if statecode changed to Won (statecode = 1):
    if (!target.Contains("statecode")) return;
    var stateCode = target.GetAttributeValue<OptionSetValue>("statecode")?.Value;
    if (stateCode != 1) return;  // 1 = Won

    // Get the Opportunity for owner details:
    var opportunity = service.Retrieve("opportunity",
      context.PrimaryEntityId,
      new ColumnSet("name", "ownerid", "estimatedclosedate"));

    tracingService.Trace($"Creating follow-up task for Won Opportunity: {opportunity["name"]}");

    // Create a follow-up Task (async plugin — won't block the user):
    var task = new Entity("task");
    task["subject"] = $"Follow-up: {opportunity["name"]} - Won!";
    task["description"] = "Send welcome pack and onboarding information";
    task["regardingobjectid"] = new EntityReference("opportunity", context.PrimaryEntityId);
    task["ownerid"] = opportunity["ownerid"];  // assign to opp owner
    task["scheduledend"] = DateTime.UtcNow.AddDays(3);  // due in 3 days
    task["prioritycode"] = new OptionSetValue(2);  // High priority

    var taskId = service.Create(task);
    tracingService.Trace($"Task created: {taskId}");
  }
}

Plugin registration:
Entity:   opportunity
Message:  Update
Stage:    Post-Operation (40)   ← after save, record is in DB
Mode:     Asynchronous           ← non-blocking, failure won't rollback opp win
Images:   none required (statecode is in Target)

Scenario: How do you implement a full ALM pipeline for a Power Platform solution from scratch?

Full ALM implementation steps:

1. Environment setup (Power Platform Admin Centre):
   Developer environment:  personal, each developer has their own
   Test environment:       shared, F-licence or Managed Environment
   Production:             Managed Environment, DLP policies enforced

2. Solution setup (Developer environment):
   → Create publisher: prefix "ctr", publisher name "Contoso"
   → Create solution: "ContosoSales" (associate with publisher)
   → ALL customisations inside this solution only
   → Set up Environment Variables for connection strings
   → Use Connection References for all flows

3. Azure DevOps setup:
   → Create repository: ContosoSales
   → Install "Power Platform Build Tools" extension from Marketplace
   → Create service connections (SPN):
     PP-Dev-SPN:  service principal with Maker role on Dev env
     PP-Test-SPN: service principal with Maker role on Test env
     PP-Prod-SPN: service principal with Maker role on Prod env

4. CI pipeline (builds/PRs):
   → Export solution (unmanaged) from Dev
   → Unpack to source files (pac solution unpack)
   → Run Solution Checker (fail on Critical errors)
   → Pack as Managed
   → Publish artifact (managed zip)

5. CD pipeline (deployments):
   → Import managed to Test (automatic)
   → Configure environment variables + connection references
   → Run smoke tests (EasyRepro UI tests or manual test checklist)
   → Approval gate: QA lead approves
   → Import managed to Production
   → Approval gate: Release manager approves

6. Developer daily workflow:
   git checkout -b feature/TICKET-123-account-validation
   # develop in personal dev environment
   # export + unpack + commit
   git push origin feature/TICKET-123-account-validation
   # raise PR → CI pipeline runs → solution checker passes → peer review
   # merge to main → CD pipeline deploys to Test

Scenario: A custom connector is failing authentication to a third-party API. How do you debug?

Systematic debugging approach:

Step 1 — Test the API directly (Postman or curl):
→ Call the API directly with the same credentials to confirm the API works
→ If direct call fails: the API itself has an issue (key expired, endpoint changed)
→ If direct call succeeds: the connector configuration is the issue

Step 2 — Check connector authentication configuration:
→ Power Automate → Custom Connectors → [connector] → Security tab
→ Verify: auth type, client ID, client secret, token URL, scopes

Step 3 — Check Power Automate run history:
→ Open failed flow run → expand the action → check error body
→ Common errors:
  401 Unauthorized:   wrong credentials, expired token, missing scope
  403 Forbidden:      correct auth but insufficient permissions
  400 Bad Request:    malformed request body — check connector definition

Step 4 — Test in connector wizard:
→ Custom Connectors → [connector] → Test tab
→ Create a new connection → test each operation individually
→ See raw request/response in test results

Step 5 — Check token refresh:
→ OAuth connectors: check if refresh token has expired (user must re-auth)
→ API key: check if key was rotated in the external system
→ Solution: update the connection in Power Automate with new credentials

Step 6 — Check environment variable / connection reference:
→ If connection reference used: verify the connection reference is configured
  correctly in the target environment
→ Power Platform Admin Centre → Connections → verify active connections

8. Cheat Sheet — Quick Reference

PL-400 Exam Domain Priorities

Domain                          Weight    Focus Areas
Extend the platform             40-45%    C# plugins, Custom connectors,
                                          Dataverse Web API, Virtual tables,
                                          Power Platform CLI, ALM
Create a technical design       10-15%    Low-code vs pro-code decision,
                                          Architecture, Environment variables
Extend the user experience      10-15%    PCF controls (TypeScript),
                                          Client-side JavaScript (Xrm API)
Build Power Platform solutions  10-15%    Solutions, Dataverse tables,
                                          Power Fx, Delegation
Develop integrations            5-10%     Custom connectors, Virtual tables,
                                          Azure Functions, OData
Implement Power Apps improvements 5-10%  Performance, delegation, formula optimisation

Plugin Pipeline Quick Reference

Stage    When                    Use for                    Transaction
10 Pre-  Before transaction      External validation,       No
   Valid                         external system checks
20 Pre-  In transaction,         Set defaults, enrich       Yes
   Op    before DB write         record before save
30 Main  The actual operation    Not available for plugins  Yes
40 Post- In transaction,         Create related records,    Yes
   Op    after DB write          trigger integrations, notify

Execution modes:
Sync:  blocks user, failure rolls back → use for validation
Async: background queue, failure does NOT roll back → use for notifications

Plugin images:
Pre-image (update/delete):  record state BEFORE this operation
Post-image (create/update): record state AFTER this operation
Register in Plugin Registration Tool with specific column names

Dataverse Web API Quick Reference

Base URL: https://{org}.api.crm11.dynamics.com/api/data/v9.2/

OData operations:
GET    /accounts?$select=name&$filter=...    → retrieve multiple
GET    /accounts({id})                        → retrieve single
POST   /accounts                              → create
PATCH  /accounts({id})                        → update (partial)
DELETE /accounts({id})                        → delete

OData query options:
$select    choose columns    $select=name,creditlimit
$filter    filter rows       $filter=creditlimit gt 50000
$orderby   sort              $orderby=name asc
$top       limit rows        $top=100
$expand    join related      $expand=primarycontactid($select=fullname)
$count     count results     $count=true

Association:
POST /accounts({id})/contact_customer_accounts/$ref
{ "@odata.id": "/contacts({contactId})" }

Unbound action example:
POST /WinOpportunity
{ "OpportunityClose": {...}, "Status": 3 }

ALM Quick Reference

Solution types:
Unmanaged: development environment only — editable
Managed:   non-dev environments — read-only, clean uninstall

Environment strategy:
Developer (personal) → Test (shared) → UAT → Production

Power Platform CLI key commands:
pac auth create --url {envUrl}
pac solution clone --name {solution}
pac solution pack --zipFile ./out.zip --managed
pac solution import --path ./out.zip
pac pcf init, push, version
pac canvas pack, unpack

Azure DevOps tasks:
PowerPlatformToolInstaller    → install tools
PowerPlatformWhoAmi           → validate connection
PowerPlatformExportSolution   → export from source env
PowerPlatformUnpackSolution   → unpack to source files
PowerPlatformChecker          → run solution checker
PowerPlatformImportSolution   → deploy to target env
PowerPlatformPublishCustomizations → publish after import

Pipeline pattern: Export → Unpack → Check → Pack → Import

PCF Control Lifecycle

Method          When called                       Use for
init()          Control loads (once)              Setup, subscribe to events
updateView()    Value or context changes           Re-render the control
getOutputs()    After notifyOutputChanged()        Return new value to Dataverse
destroy()       Control removed from DOM           Cleanup, unsubscribe

Key context properties:
context.parameters.{fieldName}.raw        → current field value
context.parameters.{fieldName}.formatted  → formatted display value
context.userSettings.userName             → current user name
context.client.isOffline                  → offline mode?
context.mode.isControlDisabled            → read-only?
context.factory.requestRender()           → request re-render

Top 10 Tips

  1. Extend the platform = 40–45% of PL-400 — this single domain is almost half the exam. Master: C# plugins (all pipeline stages), Dataverse Web API (OData), custom connectors, Power Platform CLI, and ALM. Everything else is secondary to this domain.

  2. Plugin stage selection is heavily tested — Pre-Operation (stage 20) to modify the Target before save (no explicit Update needed). Post-Operation (stage 40) to create related records (explicit Update needed). Pre-Validation (stage 10) for external system checks without a transaction. Know which stage to use for which scenario.

  3. Sync vs Async plugins = validation vs notification — synchronous plugins block the user and roll back on failure (use for validation). Asynchronous plugins run in background and don't roll back the triggering operation (use for sending emails, calling external APIs).

  4. Always try low-code first — the exam tests your ability to decide when pro-code is justified. If a Business Rule or Power Automate flow can solve the problem, that's the right answer. Only escalate to plugins when genuinely needed.

  5. Connection references and environment variables are the ALM foundation — flows must use Connection References (not direct connections) and variables must use Environment Variables (not hardcoded values). Without these, solutions break when moved between environments.

  6. pac solution unpack enables meaningful Git diffs — without unpacking, a solution is a binary ZIP file that can't be diff'd or reviewed. Unpacking creates YAML/XML files per component — pull requests show exactly what changed.

  7. PCF controls replace HTML web resources — HTML web resources are legacy. PCF controls are the modern extension point for custom UI, with proper TypeScript support, lifecycle management, and canvas app compatibility.

  8. Virtual tables = no data copy, always live — data stays in the external system and Dataverse proxies queries through. No sync lag, no duplication, but no offline access and no Dataverse-native features (rollup columns, duplicate detection).

  9. Power Platform Pipelines vs Azure DevOps — Pipelines for simpler orgs and citizen developer ALM (built into Power Platform, no DevOps expertise needed). Azure DevOps for complex orgs needing branch strategies, automated testing, and full DevOps integration.

  10. Solution Checker is mandatory before production — Solution Checker (run via Azure DevOps task or pac solution check) detects performance and reliability issues in plugins, JavaScript, and flows. Failing on Critical issues as a pipeline gate is the enterprise best practice for preventing broken deployments.



Thursday, May 7, 2026

Microsoft Dataverse Advanced Complete Guide

Microsoft Dataverse Advanced — Complete Guide

Plugins · PCF Controls · Web API · Business Rules · Security · Performance · ALM · Scenarios · Cheat Sheet


Table of Contents

  1. Core Concepts — Advanced Dataverse
  2. Plugins — Deep Dive
  3. PCF Controls — Deep Dive
  4. Dataverse Web API
  5. Business Logic & Automation
  6. Security & ALM
  7. Scenario-Based Questions
  8. Cheat Sheet — Quick Reference

1. Core Concepts — Advanced Dataverse

What is Microsoft Dataverse and what makes it enterprise-grade?

Microsoft Dataverse is the cloud-based data platform underpinning the entire Power Platform and Dynamics 365. It provides a structured, governed data store with built-in business logic, security, and extensibility.

What makes Dataverse enterprise-grade:

Relational data model:
→ Tables (entities), Columns (attributes), Relationships
→ Standard tables: Account, Contact, Lead, Opportunity, Case, etc.
→ Custom tables: create your own business-specific tables
→ Virtual tables: surface external data as Dataverse tables (no copy)

Business logic layers:
→ Column-level: data type, format, required, default value
→ Business rules: no-code field validation and show/hide logic
→ Plugins: C# server-side event handlers (full .NET access)
→ Custom APIs: define your own messages and operations
→ Workflows (deprecated) / Power Automate: process automation

Security model:
→ Role-based: security roles define table/column/record access
→ Business units: hierarchical org structure for data isolation
→ Field security profiles: restrict specific column access
→ Row-level security: owner, sharing, team-based record access

Platform services:
→ Audit logging: track every record change
→ Duplicate detection: prevent duplicate records
→ Change tracking: delta sync for external integration
→ File/Image columns: native binary storage
→ Calculated/Rollup columns: formula-based values

What is the Dataverse event pipeline?

The event pipeline is the ordered sequence in which Dataverse processes operations. Understanding it is essential for plugin and custom API development.

Dataverse Event Pipeline (for synchronous plugins):

1. Pre-Validation stage (Stage 10):
   → Runs BEFORE input validation
   → Outside the database transaction
   → Can stop the operation before it begins
   → Use for: permission checks, prerequisite validation
   → Rollback: NOT rolled back if later steps fail

2. Pre-Operation stage (Stage 20):
   → Runs AFTER input validation, BEFORE database write
   → Inside the database transaction
   → Target entity in context: BEFORE values (can modify Target)
   → Use for: default values, data enrichment, pre-write validation
   → Rollback: YES — rolled back if main operation fails

3. Main Operation:
   → The actual database write (Create/Update/Delete/etc.)
   → Dataverse performs the core operation

4. Post-Operation stage (Stage 40):
   → Runs AFTER database write
   → Inside the database transaction (synchronous)
   → Target entity has the AFTER values (incl. new record ID)
   → Use for: related record creation, notifications, downstream updates
   → Rollback: YES (synchronous) / NO (asynchronous)

Synchronous vs Asynchronous:
Synchronous: executes inline, user waits, part of transaction
Asynchronous: queued for later execution, user continues, NOT in transaction

Pre-Image vs Post-Image:
Pre-Image:  snapshot of record BEFORE the operation (registered separately)
Post-Image: snapshot of record AFTER the operation (registered separately)
Use: compare old vs new values in Update plugins

What are Custom APIs in Dataverse?

Custom APIs allow you to define your own messages (operations) in Dataverse — creating reusable, callable actions that can be invoked from Power Automate, Power Apps, PCF, Web API, or Copilot Studio.

Custom API vs Custom Actions (deprecated approach):
Custom Actions (older): defined in solution, limited capabilities
Custom API (modern):    richer definition, request/response params,
                        plugin association, discoverable via metadata

Custom API components:
Name:             unique message name (e.g., contoso_CalculateDiscount)
Binding type:     Global (no entity) / Entity / Entity Collection
Request params:   input parameters (string, int, entity, etc.)
Response props:   output values returned to caller
Plugin type:      optional C# plugin implementing the logic
Enabled for:      Workflow / Power Automate / Plug-in Step / Custom Processing

Calling Custom API:
// Web API:
POST https://contoso.crm.dynamics.com/api/data/v9.2/contoso_CalculateDiscount
{
  "OrderAmount": 5000,
  "CustomerTier": "Gold"
}

// Power Automate:
Action: "Perform an unbound action" OR "Perform a bound action"
  → Select custom API from action dropdown

// C# SDK:
var request = new OrganizationRequest("contoso_CalculateDiscount");
request["OrderAmount"] = 5000m;
request["CustomerTier"] = "Gold";
var response = service.Execute(request);
decimal discount = (decimal)response["DiscountPercentage"];

2. Plugins — Deep Dive

What is a Dataverse Plugin and what can it do?

A Dataverse Plugin is a C# class implementing IPlugin that executes server-side business logic in response to Dataverse events (Create, Update, Delete, Retrieve, Associate, custom messages).

using Microsoft.Xrm.Sdk;
using System;

public class AccountPostCreatePlugin : IPlugin
{
    public void Execute(IServiceProvider serviceProvider)
    {
        // 1. Get services from the service provider
        var context = (IPluginExecutionContext)
            serviceProvider.GetService(typeof(IPluginExecutionContext));
        var serviceFactory = (IOrganizationServiceFactory)
            serviceProvider.GetService(typeof(IOrganizationServiceFactory));
        var tracingService = (ITracingService)
            serviceProvider.GetService(typeof(ITracingService));

        // 2. Validate context — defensive programming
        if (context.InputParameters.Contains("Target") &&
            context.InputParameters["Target"] is Entity)
        {
            var target = (Entity)context.InputParameters["Target"];

            // 3. Validate entity type
            if (target.LogicalName != "account")
                return;

            try
            {
                tracingService.Trace("AccountPostCreatePlugin: starting");

                // 4. Get the Org service (running as system or calling user)
                var service = serviceFactory
                    .CreateOrganizationService(context.UserId);

                // 5. Business logic — create a default Contact for new Account
                var defaultContact = new Entity("contact");
                defaultContact["firstname"] = "Primary";
                defaultContact["lastname"] = "Contact";
                defaultContact["parentcustomerid"] =
                    new EntityReference("account", target.Id);

                service.Create(defaultContact);

                tracingService.Trace("Default contact created successfully");
            }
            catch (Exception ex)
            {
                throw new InvalidPluginExecutionException(
                    $"AccountPostCreatePlugin failed: {ex.Message}", ex);
            }
        }
    }
}

What are the key Plugin Registration settings?

Plugin Registration Tool (PRT) settings:

Message:     Dataverse operation that triggers the plugin
  Common: Create, Update, Delete, Retrieve, RetrieveMultiple,
          Associate, Disassociate, Assign, SetState, Merge,
          GrantAccess, ModifyAccess, RevokeAccess

Primary Entity: which table the message applies to
  e.g., account, contact, incident, custom_table

Stage:       when in the pipeline the plugin runs
  10 = Pre-Validation (outside transaction)
  20 = Pre-Operation (inside transaction, before write)
  40 = Post-Operation (inside transaction for sync,
                       outside for async)

Execution Mode:
  Synchronous: runs inline, user waits, in transaction
  Asynchronous: queued, user continues, out of transaction

Filtering Attributes (for Update):
  Specify which columns trigger the plugin
  e.g., only trigger when "revenue" or "accountcategorycode" changes
  Without filtering: plugin fires on ANY field update (expensive!)

Rank: order of execution when multiple plugins on same message/stage
  Lower rank = executes first (rank 1 before rank 2)

Secure/Unsecure Configuration:
  Config strings passed to plugin constructor at registration
  Secure: encrypted, only visible to admins
  Unsecure: visible in solution, use for non-sensitive config

Images:
  Pre-Image: snapshot before the operation (register separately)
  Post-Image: snapshot after the operation
  Use: compare old vs new values in Update plugins

What are best practices for Plugin development?

1. Always check context defensively:
if (!context.InputParameters.Contains("Target")) return;
if (context.MessageName != "Create") return;
if (context.PrimaryEntityName != "account") return;

2. Use Filtering Attributes on Update:
Register with specific columns — don't fire on every field update
"revenue,accountcategorycode,primarycontactid"

3. Never use heavyweight operations in Pre-Validation:
Pre-Validation is outside transaction → expensive operations waste time
Move heavyweight logic to Post-Operation async

4. Use Tracing extensively:
tracingService.Trace("Entering: {0}", nameof(MyPlugin));
tracingService.Trace("Target ID: {0}", target.Id);
// Trace logs appear in plugin execution exception details
// Essential for debugging — plugins have no debugger in prod

5. Throw InvalidPluginExecutionException for business errors:
throw new InvalidPluginExecutionException(
    "Cannot close case: missing resolution description");
// Shows as user-friendly error in the UI

6. Never query all columns — use ColumnSet:
var account = service.Retrieve("account", accountId,
    new ColumnSet("name", "revenue", "primarycontactid"));
// NOT: new ColumnSet(true) — fetches all columns (expensive)

7. Avoid N+1 queries — use RetrieveMultiple with FetchXML:
var query = new FetchExpression("<fetch>...</fetch>");
var results = service.RetrieveMultiple(query);
// NOT: loop + individual Retrieve calls

8. Plugin Depth limit:
Context.Depth tracks recursive calls
if (context.Depth > 1) return; // Prevent infinite loops
// Plugin A creates record → Plugin B fires → loops back to A

9. Use Dependency Injection for testability:
Constructor injection: pass IOrganizationService for unit testing
Avoid direct new() instantiation of service objects

10. Register in Solutions for ALM:
Use Plugin Registration Tool to register steps as solution components
Never register directly to production — always promote via solution

What is Plugin Profiler and how do you debug plugins?

Local debugging with Plugin Profiler:

1. Install Plugin Registration Tool (PRT)
2. In PRT: Install Profiler → deploys profiler plugin to environment
3. Select your plugin step → "Start Profiling"
4. Execute the operation in the app (triggers plugin)
5. Profiler captures: execution context snapshot to clipboard/file
6. In Visual Studio: attach debugger to "PluginRegistration.exe"
7. PRT: Replay Profile → loads context into local plugin execution
8. Breakpoints hit in Visual Studio → debug with real production data

Tracing for production debugging:
tracingService.Trace("Processing account: {0}", accountId);
→ Trace appears in: Plugin Execution Exception detail
→ Enable plugin trace logs: Settings → Administration →
  System Settings → Customization → Enable logging to plug-in trace log
→ View traces: Settings → Plug-in Trace Log

Unit testing plugins:
// Mock IOrganizationService using Moq:
var mockService = new Mock<IOrganizationService>();
mockService.Setup(s => s.Retrieve("account", It.IsAny<Guid>(),
    It.IsAny<ColumnSet>()))
    .Returns(new Entity("account") { Id = Guid.NewGuid() });

var plugin = new MyPlugin(mockService.Object);
// Test plugin logic without Dataverse connection

3. PCF Controls — Deep Dive

What is a PCF Control and when do you use one?

PCF (PowerApps Component Framework) controls are custom UI components built with TypeScript/JavaScript that replace or augment the standard fields and views in model-driven apps, canvas apps, and Power Pages.

When to use PCF:
→ Standard Dataverse controls don't meet UX requirements
→ Need third-party library integration (charts, maps, editors)
→ Complex inline editing or validation logic
→ Custom visualisation of data (progress bars, star ratings, etc.)
→ Reusable branded UI components across multiple apps

PCF control types:
Field control:
→ Replaces the rendering of a single field on a form
→ Gets/sets the column value
→ Examples: colour picker, rich text editor, star rating,
            formatted phone number display

Dataset control:
→ Replaces an entire subgrid or view (list of records)
→ Gets the collection of records from the dataset
→ Examples: custom calendar view, Kanban board,
            map view of address records, custom grid

PCF vs Canvas App component:
PCF:         TypeScript + React/vanilla JS, compiled, runs natively
Canvas comp: Power Fx + Power Apps controls, low-code, easier
Use PCF:     complex rendering, performance-critical, third-party libs
Use canvas:  maker-level complexity, simple reusable components

What is the PCF Control lifecycle?

// PCF Control implementing StandardControl interface
import { IInputs, IOutputs } from "./generated/ManifestTypes";

export class StarRatingControl
    implements ComponentFramework.StandardControl<IInputs, IOutputs> {

    private _container: HTMLDivElement;
    private _context: ComponentFramework.Context<IInputs>;
    private _notifyOutputChanged: () => void;
    private _currentRating: number;

    // init: called ONCE when control is loaded
    public init(
        context: ComponentFramework.Context<IInputs>,
        notifyOutputChanged: () => void,
        state: ComponentFramework.Dictionary,
        container: HTMLDivElement
    ): void {
        this._context = context;
        this._notifyOutputChanged = notifyOutputChanged;
        this._container = container;

        // Get initial value from bound column
        this._currentRating = context.parameters.rating.raw ?? 0;

        // Render initial UI
        this.renderControl();
    }

    // updateView: called when bound column value changes externally
    // OR when form refreshes
    public updateView(
        context: ComponentFramework.Context<IInputs>
    ): void {
        this._context = context;
        // Check if value changed from outside
        if (context.parameters.rating.raw !== this._currentRating) {
            this._currentRating = context.parameters.rating.raw ?? 0;
            this.renderControl();
        }
    }

    // getOutputs: called when notifyOutputChanged() was called
    // Returns new values to write back to bound column
    public getOutputs(): IOutputs {
        return {
            rating: this._currentRating
        };
    }

    // destroy: called when control is removed from DOM
    public destroy(): void {
        // Clean up event listeners, third-party libraries
        this._container.innerHTML = "";
    }

    private renderControl(): void {
        this._container.innerHTML = "";
        for (let i = 1; i <= 5; i++) {
            const star = document.createElement("span");
            star.textContent = i <= this._currentRating ? "★" : "☆";
            star.style.cursor = "pointer";
            star.style.fontSize = "24px";
            star.addEventListener("click", () => {
                this._currentRating = i;
                this._notifyOutputChanged(); // tells PCF: value changed
                this.renderControl();
            });
            this._container.appendChild(star);
        }
    }
}

What is the PCF manifest file?

<!-- ControlManifest.Input.xml — defines the control's contract -->
<?xml version="1.0" encoding="utf-8" ?>
<manifest>
  <control namespace="Contoso.Controls" constructor="StarRatingControl"
           version="1.0.0" display-name-key="StarRating_Display_Key"
           description-key="StarRating_Desc_Key" control-type="standard">

    <!-- Property: binding to a Dataverse column -->
    <property name="rating"
              display-name-key="Rating_Display_Key"
              description-key="Rating_Desc_Key"
              of-type="Whole.None"
              usage="bound"
              required="true" />

    <!-- Input-only property: configuration value -->
    <property name="maxStars"
              display-name-key="MaxStars_Display_Key"
              of-type="Whole.None"
              usage="input"
              required="false" />

    <!-- Resources: JS bundle and CSS -->
    <resources>
      <code path="index.ts" order="1" />
      <css path="css/StarRating.css" order="1" />
    </resources>

    <!-- Feature usage: declares needed platform features -->
    <feature-usage>
      <uses-feature name="Device.captureAudio" required="false" />
      <uses-feature name="WebAPI" required="false" />
    </feature-usage>
  </control>
</manifest>

What are PCF WebAPI and Navigation capabilities?

// PCF provides context.webAPI for Dataverse operations:
public async init(context, notifyOutputChanged, state, container) {

    // CREATE a record:
    const result = await context.webAPI.createRecord("account", {
        name: "Contoso Ltd",
        revenue: 1000000
    });
    console.log("Created account ID:", result.id);

    // RETRIEVE a record:
    const account = await context.webAPI.retrieveRecord(
        "account",
        accountId,
        "?$select=name,revenue,primarycontactid"
    );

    // RETRIEVE MULTIPLE (with OData filter):
    const accounts = await context.webAPI.retrieveMultipleRecords(
        "account",
        "?$select=name,revenue&$filter=revenue gt 100000&$top=10"
    );
    accounts.entities.forEach(a => console.log(a.name));

    // UPDATE a record:
    await context.webAPI.updateRecord("account", accountId, {
        revenue: 2000000
    });

    // DELETE a record:
    await context.webAPI.deleteRecord("account", accountId);
}

// Navigation: open forms, URLs:
// Open a record form:
context.navigation.openForm({
    entityName: "account",
    entityId: accountId
});

// Open a URL in a dialog:
context.navigation.openUrl("https://contoso.com");

// Device capabilities:
// Take photo (mobile):
const image = await context.device.captureImage({ allowEdit: true });

PCF Build and Deployment

# Prerequisites: Node.js, Power Platform CLI, VS Code

# Create new PCF project:
pac pcf init --namespace Contoso.Controls --name StarRatingControl --template field

# Install dependencies:
npm install

# Build (TypeScript compilation):
npm run build

# Start test harness (hot reload in browser):
npm start

# Build for production:
npm run build -- --mode production

# Push to Dataverse environment directly (dev only):
pac pcf push --publisher-prefix contosodev

# Package in solution for ALM deployment:
# 1. Create solution project:
pac solution init --publisher-name Contoso --publisher-prefix contoso

# 2. Add PCF reference to solution:
pac solution add-reference --path ../StarRatingControl

# 3. Build solution:
dotnet build

# 4. Deploy managed solution:
pac solution import --path ./bin/Release/Solution.zip

4. Dataverse Web API

What is the Dataverse Web API and how is it structured?

Dataverse Web API:
→ RESTful OData v4 API for all Dataverse operations
→ Base URL: https://{org}.crm.dynamics.com/api/data/v9.2/
→ Authentication: OAuth 2.0 Bearer token (Entra ID)
→ Content-Type: application/json

CRUD operations:
CREATE (POST):
POST /api/data/v9.2/accounts
{
  "name": "Contoso Ltd",
  "revenue": 1000000,
  "primarycontactid@odata.bind": "/contacts(guid)"
}
Returns: 204 No Content, OData-EntityId header = new record URL

RETRIEVE (GET):
GET /api/data/v9.2/accounts(guid)?$select=name,revenue
GET /api/data/v9.2/accounts?$select=name&$filter=revenue gt 500000&$top=10

UPDATE (PATCH):
PATCH /api/data/v9.2/accounts(guid)
{ "revenue": 2000000 }
Returns: 204 No Content

DELETE (DELETE):
DELETE /api/data/v9.2/accounts(guid)
Returns: 204 No Content

UPSERT (PATCH with If-None-Match):
PATCH /api/data/v9.2/accounts(guid)
If-Match: *          → update only (fail if not exists)
If-None-Match: *     → create only (fail if exists)
(no header)          → upsert: create if not exists, update if exists

What are the key OData query options in the Dataverse Web API?

$select — specify columns to return:
GET /accounts?$select=name,revenue,statecode

$filter — filter records:
GET /accounts?$filter=revenue gt 1000000
GET /accounts?$filter=statecode eq 0 and contains(name,'Contoso')
GET /contacts?$filter=parentcustomerid/accountid eq {guid}

$orderby — sort results:
GET /accounts?$orderby=revenue desc,name asc

$top — limit results:
GET /accounts?$top=50

$skip — pagination offset:
GET /accounts?$top=10&$skip=20  ← page 3

$expand — include related entity inline:
GET /accounts?$expand=primarycontactid($select=fullname,emailaddress1)
GET /incidents?$expand=customerid_account($select=name,revenue)

$count — include total count:
GET /accounts?$count=true

$apply — aggregations (OData aggregation):
GET /opportunities?$apply=aggregate(estimatedvalue with sum as TotalValue)

Lookup / EntityReference filter:
GET /incidents?$filter=customerid_account/accountid eq {guid}
GET /contacts?$filter=_parentcustomerid_value eq {guid}

Alternative key (e.g., by email):
GET /contacts(emailaddress1='alice@contoso.com')

How do you execute Custom APIs and Functions via Web API?

Unbound functions (no specific record):
GET /api/data/v9.2/WhoAmI()
Response: { "UserId": "guid", "BusinessUnitId": "guid", "OrganizationId": "guid" }

GET /api/data/v9.2/RetrieveVersion()
Response: { "Version": "9.2.24031.00140" }

Bound function (on a specific record):
GET /api/data/v9.2/accounts(guid)/Microsoft.Dynamics.CRM.RetrievePrincipalAccess
  (Target=@target)?@target={"@odata.id":"accounts(guid)"}

Unbound actions:
POST /api/data/v9.2/SendEmail
{ "EmailId": "guid", "IssueSend": true }

Custom API (unbound action):
POST /api/data/v9.2/contoso_CalculateDiscount
{
  "OrderAmount": 5000,
  "CustomerTier": "Gold"
}
Response: { "DiscountPercentage": 15.0 }

Custom API (bound to entity):
POST /api/data/v9.2/accounts(guid)/Microsoft.Dynamics.CRM.contoso_ApproveAccount
{}

Batch requests ($batch):
POST /api/data/v9.2/$batch
Content-Type: multipart/mixed;boundary=batch_boundary

--batch_boundary
Content-Type: multipart/mixed;boundary=changeset_boundary

--changeset_boundary
Content-Type: application/http
Content-Transfer-Encoding: binary

POST /api/data/v9.2/accounts HTTP/1.1
Content-Type: application/json
{ "name": "Contoso Ltd" }

--changeset_boundary--
--batch_boundary--

What is Change Tracking in the Dataverse Web API?

Change tracking enables delta sync — retrieve only records
changed since the last sync, without fetching everything.

Enable change tracking:
→ Table Settings → "Change Tracking" = On
→ Required for delta sync and Dataverse integration patterns

Initial full sync:
GET /api/data/v9.2/accounts?$select=name,revenue
Response headers:
  Preference-Applied: odata.track-changes
  OData-DeltaLink: /api/data/v9.2/accounts?$deltatoken=xyz123

Store the DeltaLink token.

Incremental sync (only changes since last token):
GET /api/data/v9.2/accounts?$deltatoken=xyz123
Response:
{
  "value": [
    { "accountid": "guid1", "name": "Updated Name" },     // updated
    { "accountid": "guid2", "@removed": { "reason": "deleted" } } // deleted
  ],
  "@odata.deltaLink": "...?$deltatoken=abc456"  // new token
}

Store new deltaLink for next sync.

Use cases:
→ Sync Dataverse data to external data warehouse
→ Trigger downstream processes on record changes
→ Replicate to Azure SQL or Fabric via ADF / Logic Apps
→ Mobile app offline sync (sync changes when reconnected)

5. Business Logic & Automation

What are Business Rules in Dataverse and what can they do?

Business Rules: no-code/low-code logic attached to a table
Applied: client-side (model-driven forms) and/or server-side

Business Rule capabilities:
→ Show/Hide fields: conditionally show/hide columns
→ Enable/Disable fields: make fields read-only conditionally
→ Set field value: pre-populate or calculate field value
→ Set required/not required: dynamic required fields
→ Set default value: set a column's default on new records
→ Validate data: show error message when condition is met
→ Recommend value: suggest a value (user can override)

Scope options:
Entity:           server-side — applies to ALL interfaces (forms, API, plugin)
All Forms:        client-side — applies to all forms in the app
Specific Form:    client-side — applies to one specific form only

Example Business Rule:
IF Status = "On Hold" AND HoldReason is empty
THEN:
  Show error: "Please provide a Hold Reason"
  Set HoldReason = required

Best practices:
→ Use Entity scope for validation that should ALWAYS apply
→ Use Form scope for UX-only changes (show/hide)
→ Avoid complex multi-condition rules — use plugins instead
→ Test: rules with Entity scope fire via API too — test both UI and API

Limitations:
→ Cannot query related records (no lookups across tables)
→ Cannot call external services
→ Cannot create/update other records
→ For complex logic: use plugins or Power Automate

What are Dataverse Calculated and Rollup columns?

Calculated Columns:
→ Value computed from formula at retrieval time
→ No stored value — calculated each time the record is fetched
→ Formula types: date/time arithmetic, string concatenation,
                 numeric calculations, conditional (if/else)

Examples:
FullName = FirstName + " " + LastName
DaysUntilExpiry = DaysFromToday(ExpiryDate)
AnnualRevenue_USD = Revenue * ExchangeRate
Priority_Label = IF(Priority = 1, "High", IF(Priority = 2, "Medium", "Low"))

Limitations:
→ Cannot reference related table columns directly in formula
→ Cannot be used in Power Automate filter conditions efficiently
→ Not stored → cannot index for query performance

Rollup Columns:
→ Aggregation over related child records
→ Calculated on a schedule (every 12 hours default, manual trigger available)
→ Result stored in the parent record

Examples:
Account.TotalOpportunityValue = SUM(Opportunity.EstimatedValue)
  WHERE Opportunity.StatusCode = Open

Account.NumberOfCases = COUNT(Case.CaseId)
  WHERE Case.StateCode = Active

Contact.LastInteractionDate = MAX(Activity.ActualEnd)

Performance note:
→ Rollup columns are stored — efficient to query and index
→ 12-hour refresh lag — not suitable for real-time dashboards
→ Trigger immediate recalculation: CalculateRollupField message

What are Virtual Tables in Dataverse?

Virtual Tables (Virtual Entities):
→ Tables that appear in Dataverse but data lives in an external source
→ No data duplication — queries passed through to external system
→ Appear to apps, Power Automate, and API consumers as native tables

How they work:
1. Register a Data Provider plugin (custom C# code)
2. Plugin receives Retrieve/RetrieveMultiple requests
3. Plugin queries the external system (REST API, SQL, SAP)
4. Plugin returns results as Entity collection
5. Dataverse returns results as if they were native records

Use cases:
→ Read data from SAP without replicating to Dataverse
→ Surface Azure SQL data in model-driven apps
→ Integrate external REST API data as a Dataverse table
→ Display SharePoint list data in a model-driven subgrid

Limitations:
→ No native create/update/delete (unless provider implements it)
→ No offline capability
→ No relationships with standard Dataverse tables (limited)
→ No Dataverse search indexing
→ No audit logging
→ Performance depends on the external system

Virtual connector providers (out-of-box):
→ SharePoint connector provider (virtual SharePoint tables)
→ SQL Server provider
→ Excel Online provider

6. Security & ALM

What is the Dataverse security model in depth?

Security layers:

1. Authentication: Entra ID OAuth — who can access the environment

2. Licensing: user must have appropriate Power Apps or D365 licence

3. Security Roles (what operations on which tables):
   Each role has privileges for each table:
   Privilege levels:
   None (0):         no access
   User (1):         own records only
   Business Unit (2): records in user's BU + sub-BUs
   Parent BU (3):    user's BU + parent BUs (rarely used)
   Organisation (4): ALL records in the tenant

   Operations per table:
   Create, Read, Write, Delete, Append, AppendTo, Assign, Share

   Column Security Profiles (field-level security):
   → Restrict Read/Update/Create of specific columns
   → Apply to: Social Security Number, Salary, sensitive fields
   → Overrides security role access for those columns
   → Assign profiles to users or teams

4. Business Units (org hierarchy for data isolation):
   Root BU → Regional BU → Departmental BU
   Users in a BU inherit access to records in their BU's scope
   Move user to different BU = changes their data access scope

5. Record sharing (extending access beyond roles):
   → Share a specific record with a user/team
   → Grants access beyond what their security role allows
   → Use sparingly — creates complex access patterns
   → Prefer: reassign to shared team instead

6. Teams:
   Owner team: can own records (records assigned to team)
   Access team: shares access to specific records (no ownership)
   Entra ID group team: membership managed via Entra ID group

What are Dataverse ALM best practices?

Solution types:
Unmanaged solution:
→ Development environment ONLY
→ Fully editable, no restrictions
→ Components can be modified or deleted

Managed solution:
→ Target environments (Test, UAT, Production)
→ Read-only — cannot edit managed components
→ Uninstall = removes all managed components (clean)
→ Layering: multiple managed solutions can coexist

Solution layering:
Base solution (ISV or Microsoft base)
  → Customisation layer 1 (your org solution)
  → Customisation layer 2 (environment-specific patches)

Active layer: what the environment actually uses

ALM workflow:
Dev environment:
→ Develop in unmanaged solution
→ pac solution export --managed (creates managed zip)

CI/CD pipeline:
→ pac solution export → Git commit
→ PR review → merge
→ pac solution import to Test → UAT → Production

Solution segmentation:
→ Separate solutions by domain: CRM, Finance, HR
→ Core solution: shared base (tables, security roles)
→ Feature solutions: depend on core, add features
→ Reduces merge conflicts, enables independent deployment

Environment variables:
→ Use for config that differs between environments
→ API endpoints, SharePoint URLs, email addresses
→ Set per-environment without solution change
→ Types: String, Number, Boolean, JSON, Secret (Key Vault)

7. Scenario-Based Questions

Scenario: Prevent duplicate account creation based on phone number.

Approach: Pre-Operation plugin on Account Create

public class AccountPreCreateDuplicateCheck : IPlugin
{
    public void Execute(IServiceProvider serviceProvider)
    {
        var context = (IPluginExecutionContext)
            serviceProvider.GetService(typeof(IPluginExecutionContext));
        var factory = (IOrganizationServiceFactory)
            serviceProvider.GetService(typeof(IOrganizationServiceFactory));
        var tracing = (ITracingService)
            serviceProvider.GetService(typeof(ITracingService));

        if (context.MessageName != "Create" ||
            context.PrimaryEntityName != "account") return;

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

        if (!target.Contains("telephone1")) return;

        var phone = target.GetAttributeValue<string>("telephone1");
        if (string.IsNullOrWhiteSpace(phone)) return;

        var service = factory.CreateOrganizationService(context.UserId);

        // Query for existing accounts with same phone
        var query = new QueryExpression("account")
        {
            ColumnSet = new ColumnSet("accountid", "name"),
            TopCount = 1
        };
        query.Criteria.AddCondition("telephone1",
            ConditionOperator.Equal, phone);
        query.Criteria.AddCondition("statecode",
            ConditionOperator.Equal, 0); // Active only

        var results = service.RetrieveMultiple(query);

        if (results.Entities.Count > 0)
        {
            var existing = results.Entities[0];
            throw new InvalidPluginExecutionException(
                $"An active account with phone {phone} already exists: " +
                $"'{existing["name"]}'. Please use the existing account.");
        }
    }
}

Registration: Message=Create, Entity=account, Stage=Pre-Operation (20), Mode=Synchronous


Scenario: Design a PCF control that displays a progress bar for opportunity probability.

// ProgressBarControl.ts
export class ProgressBarControl
    implements ComponentFramework.StandardControl<IInputs, IOutputs> {

    private _container: HTMLDivElement;
    private _bar: HTMLDivElement;
    private _label: HTMLSpanElement;

    public init(context, notifyOutputChanged, state, container): void {
        this._container = container;

        // Create wrapper
        const wrapper = document.createElement("div");
        wrapper.style.cssText = "display:flex;align-items:center;gap:8px;width:100%";

        // Create progress bar track
        const track = document.createElement("div");
        track.style.cssText = "flex:1;height:8px;background:#e0e0e0;border-radius:4px;overflow:hidden";

        // Create progress fill
        this._bar = document.createElement("div");
        this._bar.style.cssText = "height:100%;border-radius:4px;transition:width 0.3s";

        // Create label
        this._label = document.createElement("span");
        this._label.style.cssText = "min-width:36px;font-size:12px;font-weight:600";

        track.appendChild(this._bar);
        wrapper.appendChild(track);
        wrapper.appendChild(this._label);
        this._container.appendChild(wrapper);

        this.updateView(context);
    }

    public updateView(context): void {
        const value = context.parameters.probability.raw ?? 0;
        const clamped = Math.max(0, Math.min(100, value));

        this._bar.style.width = `${clamped}%`;
        this._label.textContent = `${clamped}%`;

        // Colour coding: green > 70%, amber > 40%, red <= 40%
        this._bar.style.backgroundColor =
            clamped > 70 ? "#107C10" :
            clamped > 40 ? "#FFB900" : "#C50F1F";
    }

    public getOutputs(): IOutputs { return {}; }
    public destroy(): void { this._container.innerHTML = ""; }
}

Scenario: Implement a Web API call to create a Case linked to an Account from an external application.

// External app creating a Dataverse Case via Web API

async function createCase(accessToken, accountId, caseData) {
    const orgUrl = "https://contoso.crm.dynamics.com";

    const response = await fetch(
        `${orgUrl}/api/data/v9.2/incidents`,
        {
            method: "POST",
            headers: {
                "Authorization": `Bearer ${accessToken}`,
                "Content-Type": "application/json",
                "OData-MaxVersion": "4.0",
                "OData-Version": "4.0",
                "Prefer": "return=representation"  // Return created record
            },
            body: JSON.stringify({
                "title": caseData.title,
                "description": caseData.description,
                "prioritycode": caseData.priority,  // 1=High, 2=Normal, 3=Low
                "casetypecode": 1,  // Question
                // Link to Account:
                "customerid_account@odata.bind":
                    `/accounts(${accountId})`,
                // Optionally link to Contact:
                "primarycontactid@odata.bind":
                    `/contacts(${caseData.contactId})`
            })
        }
    );

    if (!response.ok) {
        const error = await response.json();
        throw new Error(
            `Failed to create case: ${error.error?.message}`);
    }

    const created = await response.json();
    console.log("Created case ID:", created.incidentid);
    console.log("Case number:", created.ticketnumber);
    return created;
}

Scenario: How do you implement cross-table validation that cannot be done with Business Rules?

Requirement: When closing an Opportunity as Won, validate that a signed Contract exists linked to the opportunity.

Approach: Pre-Operation plugin on Opportunity Update (filtering on statecode)

// Plugin registered on: Update, opportunity, Pre-Operation
// Filtering attributes: statecode
public class OpportunityWinValidation : IPlugin
{
    public void Execute(IServiceProvider serviceProvider)
    {
        var context = (IPluginExecutionContext)
            serviceProvider.GetService(typeof(IPluginExecutionContext));
        var factory = (IOrganizationServiceFactory)
            serviceProvider.GetService(typeof(IOrganizationServiceFactory));

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

        // Only fire when statecode changes to 1 (Won)
        if (!target.Contains("statecode")) return;
        var newState = target.GetAttributeValue<OptionSetValue>("statecode");
        if (newState?.Value != 1) return;  // 1 = Won

        var service = factory.CreateOrganizationService(context.UserId);

        // Check for signed contract linked to this opportunity
        var query = new QueryExpression("contoso_contract")
        {
            ColumnSet = new ColumnSet("contoso_contractid"),
            TopCount = 1
        };
        query.Criteria.AddCondition("contoso_opportunityid",
            ConditionOperator.Equal, context.PrimaryEntityId);
        query.Criteria.AddCondition("contoso_status",
            ConditionOperator.Equal, 2); // 2 = Signed

        var contracts = service.RetrieveMultiple(query);

        if (contracts.Entities.Count == 0)
        {
            throw new InvalidPluginExecutionException(
                "Cannot mark opportunity as Won: no signed contract found. " +
                "Please attach a signed contract before closing.");
        }
    }
}

8. Cheat Sheet — Quick Reference

Plugin Stage Summary

Stage 10 (Pre-Validation):
  → Outside transaction
  → Before input validation
  → Use: permission checks, external validation
  → NOT rolled back on failure

Stage 20 (Pre-Operation):
  → Inside transaction
  → After validation, BEFORE database write
  → Can modify Target (set defaults, enrich data)
  → Rolled back on failure
  → Use: default values, data enrichment, business validation

Main Operation:
  → Actual database write

Stage 40 (Post-Operation Sync):
  → Inside transaction
  → AFTER database write
  → Target.Id available (new record GUID)
  → Rolled back on failure
  → Use: create related records, update related data

Stage 40 (Post-Operation Async):
  → Outside transaction
  → User interaction not blocked
  → NOT rolled back — fire and forget
  → Use: notifications, background processing, non-critical updates

PCF Lifecycle Summary

init():        called once on load — set up DOM, get initial value
updateView():  called when bound column changes externally or form refreshes
getOutputs():  called after notifyOutputChanged() — return new values
destroy():     called on removal — clean up listeners and libraries

Key API:
context.parameters.{name}.raw         → get current value
context.parameters.{name}.formatted    → get formatted display value
this._notifyOutputChanged()            → signal value changed
context.webAPI.createRecord(table, data)    → create record
context.webAPI.retrieveRecord(table, id, query) → retrieve record
context.webAPI.retrieveMultipleRecords(table, query) → list records
context.navigation.openForm({entityName, entityId}) → open record

Web API Quick Reference

Base URL: https://{org}.crm.dynamics.com/api/data/v9.2/

CRUD:
POST   /{tablePluralName}                    → Create
GET    /{tablePluralName}(guid)?$select=...  → Retrieve
PATCH  /{tablePluralName}(guid)              → Update
DELETE /{tablePluralName}(guid)              → Delete

OData query options:
$select     → columns to return
$filter     → filter conditions
$orderby    → sort order
$top        → limit count
$skip       → offset (pagination)
$expand     → include related entity
$count=true → include total count

Lookup binding in POST/PATCH:
"parentcustomerid@odata.bind": "/accounts(guid)"

Lookup filter in GET:
$filter=_parentcustomerid_value eq {guid}
$filter=parentcustomerid/accountid eq {guid}

Change tracking (delta sync):
Prefer: odata.track-changes header on initial request
Returns OData-DeltaLink → use for incremental sync

Batch:
POST /$batch with multipart/mixed content type
Up to 1,000 operations per batch

Business Rule vs Plugin Decision

Use Business Rule when:
→ Simple show/hide or enable/disable on a form
→ Basic field validation with fixed message
→ Setting default values on new records
→ Non-developer makers need to maintain the logic
→ Client-side only behaviour acceptable

Use Plugin when:
→ Logic requires querying other tables
→ Cross-record validation (e.g., check related records exist)
→ Need to create/update/delete other records
→ External service call needed
→ Complex conditional logic beyond Business Rule capability
→ Must fire on API operations (not just form save)
→ Performance: reusable across all operations

Use Power Automate when:
→ Async background processing (no user wait)
→ Multi-system integration (non-Dataverse services)
→ Long-running processes
→ Notifications (email, Teams)
→ Scheduled processing
→ Non-developer maintainability required

Top 10 Tips

  1. Know the event pipeline stages — Pre-Validation (outside transaction), Pre-Operation (inside, before write), Post-Operation Sync (inside, after write), Post-Operation Async (outside). The most tested plugin concept in every .

  2. Filtering attributes on Update plugins — always register Update plugins with specific filtering attributes. Without them, the plugin fires on every field update — massive performance impact on busy environments.

  3. InvalidPluginExecutionException for user-facing errors — throwing this shows a friendly error in the UI. Throwing any other exception shows an ugly system error. Know this difference for any UX-related plugin question.

  4. PCF getOutputs + notifyOutputChanged are paired — when your control's value changes, call notifyOutputChanged() to signal Dataverse. Dataverse then calls getOutputs() to retrieve the new value. Never set the value directly without this flow.

  5. Web API OData-bind for lookups — "parentcustomerid@odata.bind": "/accounts(guid)" is the correct syntax for setting lookup values in POST/PATCH. Knowing this notation separates developers who have actually used the Web API.

  6. Pre-Operation to set defaults, Post-Operation to create related records — set defaults in Pre-Op (Target can be modified before write). Create related records in Post-Op (the new record ID is available in Target.Id).

  7. Plugin depth tracking prevents infinite loops — when Plugin A creates a record that triggers Plugin B which modifies the original record triggering Plugin A again. Check context.Depth > 1 and return early.

  8. Managed solutions for all non-dev environments — unmanaged solutions in production are a governance disaster (cannot track changes, cannot cleanly uninstall). Always import managed solutions to Test/UAT/Prod.

  9. Environment variables for cross-environment config — never hardcode URLs, API keys, or environment-specific values in plugins or solutions. Use Dataverse environment variables and read them at runtime.

  10. Virtual tables for zero-copy external data — surface SAP, SQL, or REST API data as Dataverse tables without replication. The data provider plugin intercepts Retrieve/RetrieveMultiple calls and queries the external system. Know this pattern for any enterprise integration architecture discussion.



Featured Post

Microsoft 365 Copilot icon in SharePoint Online Management Shell

Applies to: SharePoint Online, Microsoft 365 Copilot, SharePoint Online Management Shell, Microsoft Graph PowerShell Level: Intermediate ...

Popular posts