Power Platform · Dynamics 365 · Mobile
A contract renewal process at a UK/IE Microsoft partner ran as a guided wizard in the Dynamics 365 web client, and separately as a hand-ported copy inside an offline mobile CRM app. The two had been drifting apart for months. I retired the copy and made a single engine run unchanged on both — so every fix now reaches the field engineers for free.
Field staff needed to run account reviews on site, often with no signal, so the renewal flow had been ported by hand into the offline mobile app. Two implementations of the same business process then evolved independently.
The web version kept gaining logic: collapsing and upserting service lines, distinguishing a renewed line from a newly added one, re-asserting negotiated pricing against a catalogue workflow that would otherwise overwrite it, handling discounts and contributions, clawback, currency by territory, and a snapshot note attached to the record. The mobile copy was frozen several revisions behind and had none of it.
The failure was quiet, which made it worse. Reviews submitted from a device produced records that looked plausible but carried the wrong pricing, the wrong classification and missing context. Nothing errored. It only surfaced when the two sets of records were compared directly.
I wrote a throwaway diagnostic that read back records created from a device and compared them, field by field, against records created from the web for an equivalent review.
Three recently shipped web features were provably absent from every device-created record. The mobile app was not running a stale build of the shared engine — it was running an entirely separate implementation that had never had those features at all.
The instinct was to patch the port. That was the trap. I patched two of the three gaps successfully, and they worked. The third — re-asserting a negotiated price against the catalogue workflow — was a problem the web engine had already spent thousands of lines solving. Porting that solution by hand would have meant porting the next one too, and the one after.
Retire the port. Make the mobile app load and run the real engine.
The obstacle was that the shared engine is written against the web client's client-side API, which does not exist inside the mobile app's HTML host. There was already a compatibility shim standing in for parts of that API, but it stubbed out the one call the engine uses to open the wizard and receive the user's answers — the seam the whole flow hinges on.
Make the shim honest. The stubbed navigation call became a real implementation: it hosts the wizard page in an iframe, waits for the user to submit, and resolves with the result in exactly the shape the engine expects. From the engine's point of view nothing changed.
Make the launcher thin. The mobile entry point was rewritten from a full reimplementation down to roughly 130 lines that do only what a launcher should: load the bridge, load the shim, load the unmodified engine, resolve which record the user launched from, and call the engine's public entry point. Everything the old port did — record creation, line writing, pricing, financial calculation — was deleted.
Both clients now consume the same deployed engine artefact. In the browser it runs against the native client-side API. On a device, a thin launcher loads the platform bridge, the compatibility shim, and then the engine itself — the shim presents the client API surface the engine was written against, including a real wizard host, so the engine cannot tell the two environments apart. Writes converge on the same records either way; the mobile side reaches them through offline sync.
The mobile-specific surface that remains is deliberately small: the shim, which translates the engine's queries and navigation into what the offline host can actually do, and a launcher small enough to read in one sitting. The engine itself carries all the business logic and is never edited for mobile.
The interesting problems were all in the seam, not in the business logic.
The mobile app now runs the same engine as the web client, loaded rather than copied. Pricing re-assertion, line classification, financial roll-ups and the snapshot note all behave identically on a device with no signal as they do in a browser.
The maintenance shape changed completely. Previously every web fix started a clock on a hand-port that might or might not happen, and the gap between the two only ever widened. Now a fix ships once and reaches both clients on the next publish. The mobile-specific surface I still own is a thin compatibility shim and a launcher small enough to read in one sitting.
The cheapest mobile port is the one you delete.