Work / Wealthbox to Microsoft 365 contact sync
A sync that is not allowed to lie
- C#
- .NET 8
- Azure Functions
- Microsoft Graph
A financial advisory firm keeps its contacts in a CRM, and every advisor there lives in Outlook all day. Those two facts had been reconciled by people retyping things for years.
So, a contact sync. How hard can it be? It is a loop and two API calls.
It is a loop and two API calls right up until you ask the second question, which is what happens when one record is bad. Because one record will be bad. Somebody will have typed a phone number into an email field, and the run that hits it at two in the morning has a choice to make about the four thousand records behind it.
The wrong answer is to stop. A sync that halts on the first bad row means one typo silently freezes every advisor’s contacts until somebody notices, and nobody notices quickly. The other wrong answer is to swallow everything and carry on, because then the sync reports success forever while quietly doing less and less.
What it does instead is classify the failure. Something transient, a timeout or a throttle, gets retried. Something genuinely malformed gets set aside into a dead letter with the reason attached, and the run continues past it. The bad record is visible, it is nameable, and it is one record rather than the whole night’s work.
Direction matters too, and I made it a rule rather than a habit. The CRM is the system of record. Outlook is a projection of it. The sync only ever writes one way. That sounds obvious until somebody asks whether an advisor editing a contact in Outlook should flow back, and the honest answer for a firm under SEC rules is no, absolutely not, because then you have two systems that both think they are right and an auditor who would like to know which one is.
The shape is ports and adapters. There is a domain model in the middle, a canonical person and organization borrowed from schema.org rather than invented, and adapters on either side that know about one vendor each. The Azure Functions host on top is deliberately thin, a timer and a manual trigger and nothing else. That split is what let me test the orchestration, the batching, the retry and the failure classifier without a single live credential.
One constraint that shaped more than you would think: no personally identifiable information in any log line, fixture or commit. When something goes wrong you get an identifier and a classification, never the contact. It makes debugging harder. It is also the only version of this that should exist.