Microsoft Power Platform is usually sold as low-code: drag controls onto a canvas, wire a flow, ship an app by Friday. That description is accurate and also the reason so many Power Platform estates become unmanageable. Underneath the designers there is a real distributed system with a multi-tenant database (Dataverse), an integration layer of hundreds of connectors, an identity model borrowed from Entra ID, rate limits that behave like any other SaaS API, and an application lifecycle built around a packaging format called a solution.
This article treats the platform the way an engineer would treat any runtime. It explains what each part is for, how data and identity flow through it, how to build and deploy a small but realistic application, how to call the same data from ordinary code, and where things break. The goal is that you can design a Power Platform workload that survives its author leaving the company, its first throttling incident and its first audit.
The platform is a family of products sharing one substrate. Power Apps builds two kinds of application: canvas apps, where you lay out every control and write formulas in Power Fx, and model-driven apps, which are generated from a Dataverse data model and give you forms, views and business process flows almost for free. Power Automate runs cloud flows (event or schedule triggered workflows that call connectors) and desktop flows (robotic process automation that drives Windows user interfaces where no API exists). Copilot Studio builds conversational agents. Power BI is the analytics product and Power Pages builds external-facing websites over Dataverse.
What they share matters more than what separates them. All of them live inside an environment, all of them authenticate users through Microsoft Entra ID, all of them reach data either in Dataverse or through connectors, and all of them can be packaged into solutions for deployment. When you design a workload, you are really making four decisions: which environment it lives in, where its data lives, which connectors it is allowed to use, and how it moves from development to production. The product choice usually follows from those.
An environment is the unit of isolation. It has a region, an optional Dataverse database, an optional security group that restricts who can be a member, and a type: production, sandbox, developer, trial, or the special default environment that every tenant gets and every licensed user can create things in. Apps, flows, connections and agents belong to exactly one environment and cannot see resources in another one except through connectors.
Dataverse is the platform's database. It stores data in tables with typed columns, relationships and choice lists, and wraps them in a security model of business units, security roles and record ownership by user or team. A role grants privileges such as read, write or delete at a depth of user, business unit, parent-child business unit or organisation, so the same table can expose a salesperson's own records to them and the whole region's records to a manager without application code. Server-side logic runs as plug-ins (.NET code in a sandbox, synchronous or asynchronous, registered on messages like Create and Update) or as low-code business rules and flows.
Dataverse also exposes everything through an OData v4 Web API. The practical rule: if a workload needs relational data, row-level security, auditing or more than a few thousand records, put it in Dataverse rather than in a SharePoint list or Excel file, which make poor systems of record.
A connector is a typed wrapper around an API: an OpenAPI description plus authentication metadata. Microsoft and partners publish hundreds of them, split into standard connectors (for example SharePoint, Outlook, Teams) and premium connectors (for example SQL Server, HTTP, Dataverse in many licensing contexts, and every custom connector), and premium usage changes what each user must be licensed for. A custom connector is one you define from your own OpenAPI document, which is how a flow calls your Azure Function or internal REST service. Services that sit on a private network are reached through the on-premises data gateway, an agent you install inside the network that relays requests outbound.
The concept engineers most often miss is the connection: an authenticated instance of a connector, owned by someone. When a maker builds a flow with the SharePoint connector, the flow runs with that maker's SharePoint credentials unless designed otherwise. If they leave and their account is disabled, every flow running on their connections fails. Production workloads should run on connections owned by a service account or, where the connector supports it, a service principal, and solutions should use connection references so the binding to a concrete connection is made per environment at import time rather than baked into the flow definition.
Canvas apps differ: most connectors run with the signed-in user's own credentials, so sharing an app does not share its data.
Governance is where a hobby estate becomes an operable one. Data loss prevention (DLP) policies classify connectors into three groups: Business, Non-business and Blocked. An app or flow may combine connectors from the Business group or from the Non-business group, but not across them, and may not use Blocked connectors at all. Policies apply at tenant scope or to specific environments, and several policies can apply at once with the most restrictive outcome winning. Some connectors also support endpoint filtering and action-level control, for example allowing the HTTP connector only to specific hosts. When a new policy makes an existing flow non-compliant, the flow is suspended, which is good for safety and bad if nobody tested the policy first.
Managed Environments is a premium set of governance features switched on per environment: limits on sharing canvas apps, usage insights, solution checker enforcement on import, and the ability to target the environment with platform pipelines. Pair it with an environment strategy: a locked-down default environment for personal productivity, and dedicated development, test and production environments per workload.
Take a concrete workload: employees submit expenses, managers approve them, approved claims are posted to a finance API. In Dataverse create an Expense table with Title, Amount (currency), Submitted By (lookup to user), Manager (lookup to user), Stage (choice: Draft, Submitted, Approved, Rejected, Posted) and Finance Reference (text). Grant employees a role with user-level read and write on Expense and managers a role with business-unit-level read.
The canvas app gives employees a form. Its submit button writes a row with Power Fx:
Patch(
Expenses,
Defaults(Expenses),
{
Title: txtTitle.Text,
Amount: Value(txtAmount.Text),
Manager: LookUp(Users, 'Primary Email' = txtManager.Text),
Stage: 'Stage (Expenses)'.Submitted
}
);
Notify("Expense submitted", NotificationType.Success)A cloud flow uses the Dataverse trigger When a row is added, modified or deleted, filtered to the Stage column so it does not fire on unrelated edits, and with a trigger condition so it only runs when Stage is Submitted. It starts an approval assigned to the manager, waits, and updates the row to Approved or Rejected. A second flow fires when Stage becomes Approved, calls the finance API through a custom connector whose OpenAPI document points at an Azure Function, writes the returned reference into Finance Reference and sets Stage to Posted.
Three details make this robust. First, the posting flow is idempotent: the Function receives the Dataverse row ID and refuses to post the same ID twice, because flows retry and a timeout after a successful call would otherwise double-post. Second, the Stage column is a state machine and each flow acts on one transition, so no flow can retrigger itself. Third, every component (table, app, both flows, the custom connector and its connection reference, an environment variable holding the Function base URL) lives in one solution, so the whole thing moves between environments as a unit.
One more canvas-app trap belongs here: delegation. A formula like Filter(Expenses, Amount > 500) is translated into a server-side query when the data source supports that operation. When it cannot be delegated, the app downloads only the first 500 rows (configurable up to 2,000) and filters locally, silently returning incomplete results. Treat delegation warnings in the editor as bugs.
Because Dataverse speaks OData, ordinary code can read and write the same tables. Register an app in Entra ID, add it to the environment as an application user with a security role, and use the client credentials flow. The scope is the environment URL followed by /.default.
import time, msal, requests
ORG = "https://contoso.crm.dynamics.com"
app = msal.ConfidentialClientApplication(
CLIENT_ID, authority=f"https://login.microsoftonline.com/{TENANT_ID}",
client_credential=CLIENT_SECRET)
token = app.acquire_token_for_client(scopes=[f"{ORG}/.default"])["access_token"]
s = requests.Session()
s.headers.update({"Authorization": f"Bearer {token}",
"Accept": "application/json",
"OData-Version": "4.0",
"Prefer": "odata.maxpagesize=500"})
def get(url):
while True:
r = s.get(url)
if r.status_code == 429: # service protection limit
time.sleep(int(r.headers.get("Retry-After", "5")))
continue
r.raise_for_status()
return r.json()
url = f"{ORG}/api/data/v9.2/accounts?$select=name,revenue&$filter=revenue gt 1000000"
while url:
page = get(url)
for row in page["value"]:
print(row["name"], row["revenue"])
url = page.get("@odata.nextLink") # server-driven pagingThe retry loop is not optional. Dataverse enforces service protection limits per user, evaluated independently on each web server serving the environment. The documented defaults are 6,000 requests and 1,200 seconds of combined execution time within a 300-second sliding window, and 52 or more concurrent requests. Exceeding them returns HTTP 429 with a Retry-After header. Plug-in operations triggered by your request do not count as extra requests, but their execution time is charged to the request that triggered them. Start at a modest rate, raise parallelism until 429s appear, and let Retry-After pace you; large batches mostly turn a request-count problem into an execution-time problem. Daily per-licence request entitlements are a separate, licensing matter.
A solution is a package of components with a publisher and a prefix. Development happens in an unmanaged solution in a development environment; downstream environments receive a managed solution, which cannot be edited in place and can be cleanly upgraded or uninstalled. Components from multiple managed solutions stack in layers, and an unmanaged change made directly in production sits on top of every managed layer and silently masks later upgrades. That is why production must be read-only for humans.
The Power Platform CLI turns this into a source-controlled pipeline. Export, unpack into readable files, commit, and in CI pack and import with a deployment settings file that binds connection references and environment variables for the target environment:
# developer loop: capture the dev solution into source control
pac auth create --environment https://contoso-dev.crm.dynamics.com
pac solution export --name ExpenseApp --path out/ExpenseApp.zip
pac solution unpack --zipfile out/ExpenseApp.zip --folder src/ExpenseApp
# CI: build the managed artifact and a settings template
pac solution export --name ExpenseApp --managed --path out/ExpenseApp_managed.zip
pac solution create-settings --solution-zip out/ExpenseApp_managed.zip --settings-file test.settings.json
# CD: deploy to test with environment-specific bindings
pac auth create --environment https://contoso-test.crm.dynamics.com
pac solution import --path out/ExpenseApp_managed.zip --settings-file test.settings.json --asyncIf you do not want to run your own CI, pipelines in Power Platform provide the same flow inside the service: an admin defines stages from development to test to production, targets are Managed Environments, and makers request deployments that admins can gate with approvals. Dataverse also has native Git integration that commits solutions in a YAML source format. Either way, version the solution and keep per-environment settings files in the repository.
Most incidents in Power Platform estates come from a short list.
Power Platform is strongest for internal, form-and-workflow applications over business data, where time to value and maintainability by a mixed team matter more than custom user experience or extreme throughput. It is weakest for high-volume transaction processing, complex algorithms, low-latency public APIs and anything that needs fine control over the runtime. The healthy pattern is fusion: low-code for the user interface, data model and orchestration, pro-code behind custom connectors for heavy logic.
| Need | Low-code fit | Prefer pro-code when |
|---|---|---|
| Internal CRUD app with approvals | Excellent: model-driven or canvas plus flows | UX must be pixel-perfect or offline-first at scale |
| Integration between SaaS systems | Good: connectors and cloud flows | Volumes reach millions of events per day or need ordering guarantees |
| Business logic | Fine for rules and simple calculations | Logic is algorithmic, needs unit tests and code review discipline |
Price premium connectors and Dataverse capacity before building: licensing is a design input.
pac solution export, unpack and import --settings-file in CI, or configure platform pipelines with approvals.