← Back to Portfolio

One Renewal Engine, Two Clients

Power Platform · Dynamics 365 · Mobile

Executive Summary

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.

The Problem

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.

Diagnosis

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 bug was not the missing feature. The bug was the fork.

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.

The Decision

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.

Mobile Load Order
1 · Platform bridge Loaded
2 · Compatibility shim — defines the web client API surface, including a real wizard host Loaded
3 · The renewal engine — byte-for-byte identical to the web version Unmodified
4 · Resolve the launching record Context
5 · Call the engine's entry point Hand off
Launcher: ~130 lines — the engine runs its own flow, unaware it is not on the web

Architecture

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.

Dynamics 365 web client Client-side API native wizard dialog Offline mobile CRM 1 · Launcher — ~130 lines 2 · Platform bridge 3 · Compatibility shim client API surface + wizard host Renewal engine one deployed artefact identical on both clients Dataverse Service records renewals + snapshot note loads + runs loaded, not copied single write path 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.

What Made the Shim Hard

The interesting problems were all in the seam, not in the business logic.

  • The offline query layer never returned primary keys, unlike the online API the engine was written against. Every downstream lookup keyed on those IDs silently built an empty map, and the writes that depended on it quietly ran against undefined identifiers — rescued only by a fallback path that partially masked the fault. The shim now always requests the key explicitly and backfills it into every result.
  • Filter comparisons were being evaluated against display values rather than the underlying identifiers, so a lookup filter compared a human-readable name against a GUID and matched nothing. Fixed by normalising and routing lookup comparisons in the shim's filter evaluation.
  • Reads immediately after a write have to go online-first. Serving them from the offline cache returns pre-write state, so the engine's own re-find step saw stale data.
  • String escaping differed between the two query dialects, which broke any record whose name contained an apostrophe.
  • Fields must be explicitly present in the mobile sync configuration or they are dropped silently rather than erroring. Running the real engine meant it read more fields than the port ever had, so the sync model had to be widened to match, including the target entities behind every lookup.

Technologies

Dynamics 365 Power Platform Resco Mobile CRM JavaScript Model-driven apps Dataverse Web API OData / FetchXML Offline-first

Results & Impact

1 Shared engine
2 Client platforms
0 Forked codebases
~130 Lines in the mobile launcher

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.

Get in Touch

Get in Touch →