When multiple people work on the same Power Platform solution without shared conventions, things get messy fast. Flows named “Update Account Flow”, “Flow – Notification v2”, and “My Flow (3)” tell you nothing without opening each one. One developer uses Business Rules, another writes JavaScript for the same thing. And at some point someone makes a manual change in production because “it’s just a quick fix.”
This post covers standards that have worked well across several projects: naming conventions, customization priorities, ALM, and deployment rules. Less overhead when someone new joins, fewer surprises in production, and a solution that doesn’t turn into a guessing game six months in.
Leave it better than you found it
Try to leave a solution in better shape than when you started. If you’re already working in a flow and the name doesn’t follow the convention, rename it while you’re there. If a JavaScript is doing something a Business Rule could handle and you’re touching that form anyway, clean it up.
The key word is while you’re there. Don’t set aside a day to audit the entire solution. Just improve what you’re already touching, as part of your normal work. A cleanup hunt outside your current scope creates noise. Extra PRs to review, extra deployments, time nobody budgeted for. But renaming the flow you just edited? That costs nothing. Small improvements compound.
None of this is set in stone. If your team has already agreed on different conventions, follow those. The point is consistency. Pick a standard together and improve it over time based on what actually works for you.
Naming conventions
If every flow, workflow, and business rule follows a consistent pattern, you can tell what it does from the name alone. No clicking required. The real value isn’t the time saved on each lookup. It’s the mental overhead you don’t have to carry. Consistency and predictability make a solution easier to maintain, easier to onboard someone new to, and easier to reason about. You’re not translating, not guessing, not holding a mental map of what each flow does. You can focus on the actual problem.
Power Automate flows
Entity|[Flag>]Operation(Field): Short description

A few examples:
| Name | What you know without opening it |
|---|---|
Contact|CU(emailaddress1,telephone1): Update communication preferences | Runs on Contact create/update when emailaddress1 or telephone1 changes |
Opportunity|U(estimatedclosedate): Notify owner of timeline change | Runs on Opportunity update when estimatedclosedate changes |
Case|C(): Set default priority and notify queue | Runs on Case create. Sets priority and sends a notification. |
Operations follow CRUDA order (Create, Read, Update, Delete, Assign), so it’s always CU, never UC. Flags like CF (child flow) or OD (on-demand) go before the operation when needed.
A note on multi-language environments. If your environment has multiple languages installed, anything translatable (flows, workflows, forms, views, fields, business rules) can end up with different names per language. Two developers working on the same component can see different things depending on which language they have configured. To keep things consistent, you have to actively manage the translations.
For user-facing components like forms, views, and fields, translations should reflect the language. A field should be “Omsetning” in Norwegian and “Revenue” in English. That’s the whole point of having translations.
For backend components that users never see, like flows, workflows, and business rules, keep the name identical across all available languages. There’s no value in translating “Account|CU(revenue): Recalculate rating” into Norwegian. Keeping it consistent across languages means anyone editing it sees the same thing regardless of their configured language. Less confusion, fewer “why is this named differently now” moments.
One more thing worth mentioning. When naming fields in the convention itself, use the logical name, not the display name. A field called “Omsetning” in Norwegian is “Revenue” in English, but the logical name revenue is the same everywhere. That makes the convention reliable regardless of which language someone has configured.
Workflows (classic real-time and background)
Same format, but with flags for the execution mode:
Contact|RT>CU(firstname,lastname): Set full name Account|B>U(revenue): Recalculate account rating
RT> means real-time, runs synchronously before the save completes. B> means background, runs async after the save. That distinction matters when you’re debugging timing issues or wondering why a field wasn’t set when you expected it to be.
Workflow examples
| Workflow name | What it tells you |
|---|---|
Contact|RT>CU(firstname,lastname): Set full name | Real-time, synchronous. Concatenates name before save. |
Account|B>U(revenue): Recalculate account rating | Background, async. Recalculates rating after revenue changes. |
Case|RT>C: Set case title from subject and customer | Real-time on create. Generates title before save. |
Opportunity|B>U(estimatedvalue): Notify manager | Background on update. Sends notification after save. |
Workflow flags
| Flag | Meaning |
|---|---|
| RT | Real-time — synchronous, runs before save |
| B | Background — asynchronous, runs after save |
| CW | Child workflow (called by another workflow) |
| OD | On-demand (manually triggered) |
Business rules
Simpler format, since business rules don’t have operation-level triggers:
Condition(Data/Yes/No): Action description
Example: ParentAccount(Data): Lock industry and territory. When a Contact has a parent Account, those fields become read-only.
Tables and columns
Display names in the user’s language (Norwegian for most of my projects), schema names in English lowercase. Lookups follow pp_(tablename)id. Users see what makes sense to them, developers and support see what makes sense for the data model.
| Element | Convention | Example |
|---|---|---|
| Display name | User language | “Kontaktperson” / “Contact” |
| Schema name | English lowercase | pp_invoicedate |
| Lookup (single) | pp_(tablename)id | pp_accountid |
| Lookup (multiple) | pp_(source)_(relation)id | pp_opportunity_contactid |
Environment variables
Don’t hardcode values that change between environments. URLs, email addresses, feature flags, record GUIDs. These belong in environment variables. They’re set per environment and travel with the solution.
Naming: pp_DescriptiveName, so publisher prefix followed by PascalCase. No extra underscores.
| Variable | Example value (dev) | Example value (prod) |
|---|---|---|
pp_NotificationEmail | dev-alerts@company.com | support@company.com |
pp_SharePointSiteUrl | https://company.sharepoint.com/sites/crm-dev | https://company.sharepoint.com/sites/crm |
pp_FeatureFlagNewPricing | Yes | No |
If it needs to be different between dev, test, and production, it’s an environment variable.
Connection references
Same principle as environment variables. Connection references let you swap connections between environments without editing flows. Name them by connector and purpose:
| Name | Connector |
|---|---|
pp_DataverseCommonConnection | Microsoft Dataverse |
pp_SharePointCommonConnection | SharePoint |
pp_OutlookCommonConnection | Office 365 Outlook |
One connection reference per connector per purpose. Don’t share the same reference across unrelated flows if they might need different service accounts down the line.
Clean up connection references that no flow depends on. They’re easy to miss, since nothing breaks when they exist unused. But each one typically has a live connection behind it, with credentials and permissions that nobody is actively reviewing. That’s noise in the solution and a small but real security concern. If a reference isn’t used, remove it.
Environment names
Name environments consistently, both the display name and the URL:
| Environment | URL | Purpose |
|---|---|---|
[Project] - Dev | project-dev.crm4.dynamics.com | Development. Unmanaged solutions. |
[Project] - Validation | project-validation.crm4.dynamics.com | Build pipeline validates here before merge. |
[Project] - Test | project-test.crm4.dynamics.com | UAT. Managed solutions. Stakeholder testing. |
[Project] - Prod | project.crm4.dynamics.com | Production. Managed. Pipeline-deployed only. |
Pick a short project name and reuse it across all URLs. Production gets the clean URL without a suffix. When you’re switching between browser tabs or scanning pipeline logs, project-dev vs. project tells you where you are immediately. Avoid generic names like org12345678, they help nobody.
This is one way to set it up, not the only way. Some customers do fine with just dev and prod. Others need more, with separate environments for training, hotfix branches, or UAT that runs in parallel with test. Scale the environment strategy to the actual needs, not to a template.
Forms, tabs, and sections
Form element names aren’t just labels, they’re API references. When your JavaScript calls formContext.ui.tabs.get(“tab_financial”), the internal name is what the code depends on. If someone renames a tab in the designer without checking the codebase, things break without an error message.
Use consistent internal names (the Name field in the designer, not the Label):
| Element | Display label | Internal name | Convention |
|---|---|---|---|
| Form | Account – Sales Overview | (set by system) | [Entity] - [Purpose/Role] |
| Tab | Customer Details | tab_customerdetails | tab_[descriptive] |
| Tab | Financial | tab_financial | tab_[descriptive] |
| Section | Address Information | section_addressinformation | section_[descriptive] |
| Section | Billing | section_billing | section_[descriptive] |
The tab_ and section_ prefixes make it obvious in code what you’re working with. They also make it easy to search the codebase. Search for section_ and you find every section reference.
Keep tab and section names independent. section_billing is better than tab_financial_section_billing. Sections are accessed through their parent tab anyway:
formContext.ui.tabs.get("tab_financial").sections.get("section_billing")
The context is already there from .sections.get(), so repeating the tab name in the section is redundant. And if a section moves to a different tab, the name still works.
The one exception. If two tabs genuinely have a section with the same purpose, like a “Details” section on both the General and Financial tabs. Then prefix: section_general_details and section_financial_details. But if that comes up often, the sections probably need better names.
When your code references a tab like this:
function showFinancialTab(formContext) {
const tab = formContext.ui.tabs.get("tab_financial");
if (tab) {
tab.setVisible(true);
}
}
…any developer reading it knows what tab_financial is. And the person editing the form knows not to rename it without checking the JavaScript (hopefully).
For standard entities, clone the default form, hide the original, and customize the clone. Keep the original around, it’s useful for comparing after upgrades.
JavaScript standards
JavaScript in Dynamics 365 accumulates. Without structure, you end up with loose functions scattered across files that nobody wants to touch. A few conventions prevent that.
Namespace and structure
Every JS file uses a namespace and the .call() pattern. Public functions are exposed via this, private functions stay internal. The global scope stays clean, and it’s obvious which functions are meant for form events.
The boilerplate. Every entity-specific file starts like this:
var PP = window.PP || {};
PP.Account = PP.Account || {};
(function () {
"use strict";
// Constants
const TAB_FINANCIAL = "tab_financial";
const FIELD_REVENUE = "revenue";
const FIELD_RATING = "pp_accountrating";
const FIELD_EMPLOYEES = "numberofemployees";
/**
* Calculates and sets account rating based on revenue and employee count.
* Registered on: Account main form > OnLoad
* @param {object} executionContext - The execution context passed by the form.
*/
this.calculateAndSetAccountRating = function (executionContext) {
const formContext = executionContext.getFormContext();
updateAccountRating(formContext);
};
/**
* Recalculates account rating and locks the rating field.
* Registered on: Account main form > Revenue field > OnChange
* @param {object} executionContext - The execution context passed by the form.
*/
this.recalculateRatingOnRevenueChange = function (executionContext) {
const formContext = executionContext.getFormContext();
setFieldReadOnly(formContext, FIELD_RATING, true);
updateAccountRating(formContext);
};
// Private functions — not accessible from form events
function updateAccountRating(formContext) {
const revenue = formContext.getAttribute(FIELD_REVENUE).getValue();
const employees = formContext.getAttribute(FIELD_EMPLOYEES).getValue();
const rating = calculateRating(revenue, employees);
formContext.getAttribute(FIELD_RATING).setValue(rating);
showTabIfHighValue(formContext, revenue);
}
function calculateRating(revenue, employeeCount) {
if (revenue > 1000000 && employeeCount > 50) return "A";
if (revenue > 500000) return "B";
return "C";
}
function setFieldReadOnly(formContext, fieldName, isReadOnly) {
const control = formContext.getControl(fieldName);
if (control) {
control.setDisabled(isReadOnly);
}
}
function showTabIfHighValue(formContext, revenue) {
const tab = formContext.ui.tabs.get(TAB_FINANCIAL);
if (tab) {
tab.setVisible(revenue > 500000);
}
}
}).call(PP.Account);
In the form designer, register PP.Account.calculateAndSetAccountRating on OnLoad and PP.Account.recalculateRatingOnRevenueChange on Revenue’s OnChange. Anyone scanning the event list sees what each function does, no need to open the file.
A few things worth calling out:
Constants at the top. Field names, tab names, and magic strings defined once. If a field gets renamed, you change it in one place. You can also see at a glance which form elements the script touches, including the tab_financial from the forms section above.
Public functions stay thin. They grab the form context and hand off to a private function that does the work. Both calculateAndSetAccountRating and recalculateRatingOnRevenueChange call the same updateAccountRating helper, so logic lives in one place, not duplicated across event handlers.
JSDoc on public functions. What it does, when it fires, what it expects. Private helpers get a brief comment at most. Full JSDoc on internal functions just adds noise.
"use strict" always. Catches undeclared variables and other easy mistakes.
If you’re new to client scripting in model-driven apps, Microsoft’s walkthrough Write your first client script in model-driven apps is a good place to start.
File naming
One file per entity, named to match the namespace:
| File | Namespace | Contains |
|---|---|---|
pp_AccountMainLibrary.js | PP.Account | All Account form logic |
pp_ContactMainLibrary.js | PP.Contact | All Contact form logic |
pp_OpportunityMainLibrary.js | PP.Opportunity | All Opportunity form logic |
pp_CommonLibrary.js | PP.Common | Shared utility functions |
Keep pp_CommonLibrary.js lean. If a function is only used by one entity, it belongs in that entity’s file.
Error handling in flows is not optional
Every Power Automate flow should have error handling. A flow that fails silently is a problem nobody knows about until a user reports something didn’t happen, and by then the trail is cold.
The pattern. Wrap your logic in a Try scope. Add a Catch scope that runs when Try fails. Optionally a Finally scope for things that should happen regardless, like logging. At minimum, the Catch scope should send a notification or log a record so someone knows the flow broke and why.
This goes for every flow, not just the big ones. A two-step flow that fails without error handling is just as invisible as a twenty-step one. I’ve written a walkthrough of this pattern in How to Use Try, Catch and Finally Scopes in Power Automate.
Choosing the right tool
It’s tempting to reach for Plugins or JavaScript out of habit. But the higher you stay on this list, the less you’ll need to maintain:

A field can be made read-only with configuration. No JavaScript needed. A Business Rule handles most field-level logic. Only move further down when the level above genuinely can’t do the job.
I’ve written more about the pros and cons of each level in Model-driven App Components – Pros and Cons.
Configuration rules worth writing down
A few things that have bitten me (or someone on the team) at least once:
Forms. Never modify standard forms. Clone them, hide the original, customize the clone. Vendor updates can overwrite your changes without warning and create dependency errors.
Option sets. Don’t rename default values. Create new ones. Display names revert during solution upgrades, and nobody understands why the labels suddenly changed.
Status fields. Keep Status, Status Reason, Created By/On, and Modified By/On visible on every form. First thing you need when troubleshooting, and users benefit from seeing them too. When making changes to a Status Reason field make sure Status field is also included in the solution otherwise it does not work correctly in the target environment.
ALM and deployment
Application Lifecycle Management ties the rest together. Without it, you’re making changes in dev and crossing your fingers for production. With ALM you get structured deployments, equal environments, and a trail of every change.
The approach below is one example of how to structure it, not a prescription. What fits depends on the customer, team size, and risk tolerance. Adapt the setup to what’s actually needed.

The rules I try to stick to. Unmanaged in dev. Managed in test and production. All changes through DevOps pipelines or Power Platform Pipelines, no manual changes in test or production. Take these with a grain of salt. In theory they’re absolutes. In practice, something will eventually force you to touch production directly, like a critical bug nobody can wait a pipeline cycle for. The goal isn’t to never break the rule. It’s to break it knowingly, fix things properly afterwards, and not let manual changes become the new normal.
Most projects do fine with a single solution. Feature-based solutions can come later when complexity warrants it. Don’t overcomplicate things early.
Branch protection
The main branch should be protected. No direct pushes. All changes go through pull requests, so someone reviews what’s going on before it reaches the build. Easy to forget when setting up a new repo, but it’s a basic safeguard.
Traceability through work items
Link Azure DevOps work items to every pull request and release. When something breaks in production, you can trace it back to the exact user story or bug. When a stakeholder asks “what’s in this release?”, you have an answer instead of a shrug.
Every export from dev creates a PR with work item IDs attached. The build validates. The release deploys with approval gates before test and production. Full traceability from requirement to deployment.
Service principals
Use Service Principals for the connections between Azure DevOps and each Dataverse environment. With Workload Identity Federation you don’t need to manage or renew secrets. No more pipeline failures at 8 AM on a Monday because a credential expired over the weekend.
Reference data with consistent GUIDs
Environment variables solve the “this URL differs between dev and prod” problem. But they don’t help with Business Rules and classic workflows, since those can’t read environment variables. If a Business Rule checks a lookup against a specific record, that record needs the same GUID in every environment. Otherwise the rule works in dev, breaks in test, and nobody knows why.
The fix is to migrate reference data with consistent GUIDs across environments. The Configuration Migration Tool is one way to do it. It’s a downloadable utility that exports records with their GUIDs intact, and it can be wrapped in a PowerShell step in your DevOps pipeline so it runs automatically on deployment.
Worth planning for upfront if you know you’ll have Business Rules or real-time workflows depending on reference data. Retrofitting this after records already exist in production is painful.
Conclusion
This post may be updated over time as new things come up. These standards aren’t set in stone. Adapt them to your team. The format matters less than having one that everyone sticks to.


