DEV Community

Abel
Abel

Posted on • Originally published at Medium on

OmniSync: Integrating Salesforce with Microsoft Fabric and Dynamics 365 (Part 3)

A real-world walkthrough of custom objects, outbound messages, CDC, and Logic Apps used to sync Salesforce with Fabric and Dynamics 365.

Posts in this Series

Introduction

This is Part 3 of the OmniSync series, where we deep dive into how Salesforce was integrated with Microsoft Fabric and Dynamics 365 for near real-time data synchronization.

While previous parts focused on the overall architecture and Fabric implementation, this post explains Salesforce specific challenges and solutions including:

  • How custom objects and fields were used to fit a retail oriented model
  • What tricks were needed to work around Salesforce limitations
  • How Outbound Messages, Change Data Capture, and Logic Apps were combined to trigger and route changes
  • And finally, how this all connects to the rest of the OmniSync ecosystem

Salesforce and Entity Mapping

OmniSync uses Salesforce as the retail-facing front end, but instead of relying on the highly specialized vertical like Consumer Goods Cloud, it was opted for a more flexible approach: starting from the Standard Salesforce model and tailoring it to fit retail and integration needs.

Since this is a Proof of Concept (PoC), the focus was on simplicity and interoperability, particularly with Dynamics 365 and Microsoft Fabric. That meant reshaping and mapping a core set of entities without introducing unnecessary complexity.

Core Entities and Mapping

A lightweight model centered around real-world retail flows was defined:

  • Accounts : Representing customer companies
  • Products : Standard Salesforce products, enhanced with custom attributes
  • Orders and Order Lines : Standard Salesforce products that reflect actual retail sales orders
  • Retail Stores : Stores for physical locations
  • Geography : Used in addresses in Standard Salesforce objects and expanded on Fabric
  • Currencies and Categories: Mapped internally for simplicity

Changes and Tradeoffs

  • A brand new custom Retail Store object (Retail_Store__c) was created to represent physical store locations
  • Store Assortments (which products are available in which stores) were excluded from this version and treated as out of scope
  • Orders were created directly without going through Opportunities or Leads to simplify the sales flow, although you can use that business workflow if you want anyway
  • Products were enhanced with attributes like Units of Measure (Size, Weight), Brand , Color , and lifecycle fields such as Available For Sale Date
  • Only a Standard Price Book was used, which includes both the selling price and a cost price field
  • Product Categories relied on the existing Product Family structure, without using subcategories like those defined in Fabric
  • Discounts , promotions, and complex pricing logic were excluded for simplicity
  • Although the ecosystem supports multiple currencies, we standardized everything on EUR to avoid conversion logic
  • Many standard Salesforce fields were left optional or unused , and excluded from synchronization logic

This approach helped streamline the implementation and keep the data model focused. It also ensured that only relevant fields were tracked and synchronized between systems, reducing noise and increasing maintainability.

Custom Fields

To support the integration logic and ensure proper mapping across systems, we added several custom fields to key Salesforce entities. Most of these fields help enforce unique codes, synchronization statuses, or carry metadata not available in the standard schema.

These fields are used mainly for mapping and syncing with external systems especially in cases where a specific code or identifier must be sent.

Field Overview per Entity

Account

  • Email: Reference for communication
  • SyncStatus__c: Sync conflict state

Product

  • Size__c, SizeUnitOfMeasure__c, SizeUnitOfMeasureId__c: Physical dimension details and its unit of measure (name+identifier)
  • Weight__c, WeightUnitOfMeasure__c, WeightUnitOfMeasureId__c: Weight and its unit of measure (name+identifier)
  • Brand__c, Class__c, ClassId__c, Color__c, ColorId__c: Classification data used for categorization(name+identifier)
  • AvailableForSaleDate__c, StopForSaleDate__c: Lifecycle Sale dates indicators
  • SyncStatus__c: Sync conflict state

Price Book

  • CostPrice__c: Price at which the product was bought

Order

  • SyncStatus__c: Sync conflict state
  • RetailStore__c: Connects the order with a physical store

Order Line (Order Product)

  • OrderItemLineNumber__c: Autonumeric line item number

Retail Store (Retail_Store__c custom object)

  • StoreCode__c: A unique identifier for the retail store used for system integration
  • Name: The name of the retail store as displayed to users
  • Description: A free-text field providing additional information about the store
  • EmployeeCount: Total number of employees currently working at the store location
  • Fax: The store’s fax number
  • Phone: The store’s main contact phone number
  • StoreType__c: (Picklist) Categorization of the store type (e.g., Store, Catalog, Online, Reseller)
  • StoreTypeId__c: Identifier for the StoreType__c field
  • Account__c: (Lookup) A reference to the Account associated with this store.
  • Address: The store’s physical address, including street, city, state, postal code, and country
  • SyncStatus__c: Sync conflict state

Triggers

We use Salesforce triggers in two main ways, depending on the use case:

Custom Code Triggers (Apex)

Apex triggers enable you to perform custom actions before or after events to records in Salesforce, such as insertions, updates, or deletions. Just like database systems support triggers, Apex provides trigger support for managing records.

These are used for critical integration logic where visual tools aren’t enough. For example:

Populating External System References: Triggers automatically fill in fields like external IDs which could be colors, unit of measures, store types needed by downstream systems like Dynamics 365 and Fabric.

Deletion Workaround: Since deletions on Flows are not supported on SalesForce (more on this later) we use that as workaround.

Declarative (No-Code) Triggers via Flows

For simpler automation (like when a record is inserted in an entity), we use Salesforce Flows. These let us configure behavior visually without writing Apex code, making maintenance easier when business rules change.

Together, these approaches allow for a flexible and maintainable integration logic mixing low-code with code when necessary.

The following is an example of a trigger that populates the ID value when a color is selected from the map option field:

trigger ColorTrigger on Product2 (before insert,before Update) {
map<string,integer> oppMap = new map<string,integer>();
 oppMap.put('Azure', 1);
 oppMap.put('Black', 2);
 oppMap.put('Blue', 3);
 oppMap.put('Brown', 4);
 oppMap.put('Purple', 5);
 oppMap.put('Red', 6);
 oppMap.put('Silver', 7);
 oppMap.put('White', 8);
 oppMap.put('Orange', 9);
 oppMap.put('Pink', 10);
 oppMap.put('Grey', 11);
 oppMap.put('Silver Grey', 12);
 oppMap.put('Yellow', 13);
 oppMap.put('Green', 14);
 oppMap.put('Gold', 15);
 oppMap.put('Transparent', 16);

 for(Product2 prod: trigger.new ) {
    if(prod.Color__c != null) {
       prod.ColorId__c= oppMap.get(prod.Color__c);
     }
  }
}
Enter fullscreen mode Exit fullscreen mode

UI Customization: Lightning Pages & Field Visibility

Since OmniSync also serves as a learning exercise in customizing Salesforce for real-world use, we made a few UI enhancements to improve usability and align with integration needs

Lightning Record Pages

The default page for Retail Store object (Retail_Store__c) was customized using with Lightning Record Pages using the Lightning App Builder :

Lightning Record Pages are configurable layouts in Salesforce that define how records are displayed to users. Built using Lightning App Builder , they let admins arrange components, tabs, fields, and related lists without writing code — making it easy to tailor the UI for different roles, devices, and business scenarios.

  • A header to highlight the Retail Store code and its name (on the read-only header).
  • A detail section with the Retail Store Information fields
  • A detail section with Address Information fields
  • Integration related fields such as SyncStatus__c was added as the first visible field to see on the top in case there is a Synchronization conflict.

Below is an example of the Retail Store screen, which was built entirely from scratch using Lightning App Builder.

And its results when published:

Conditional Field Visibility

Salesforce allows low-code visibility rules to control which fields appear under specific conditions. No Apex or Flow needed.

For example:

  • The SyncStatus__c field was made visible only when there was a Conflict value on that and added a warning icon to that.

This visual shows an image showing how the field becomes visible when a conflict is detected during synchronization:

Even with just low-code tools, Salesforce provided enough flexibility to create a user-friendly and sync-aware UI.

Flows: Automation Without Code

Salesforce Flows are powerful, declarative tools that let you automate business logic and integrations with minimal to no code. Designed using Flow Builder , they offer a drag-and-drop interface for building everything from simple field updates to complex multi-step logic.

In OmniSync, all automation was implemented using Record-Triggered Flows , these are flows that run automatically when a record is created, updated, or deleted.

Here are the key Flow-based use cases implemented:

In-App Notifications

When a sync fails or a conflict is detected on any entity, a notification is automatically sent to the relevant user. This was handled entirely via Record-Triggered Flow and Salesforce’s notification framework, no Apex or email alerts needed.

And the result of this custom notification can be seen below. Alongside the alert, the same Flow also updates the StatusSync__c field to reflect the latest sync status, in case notifications are ignored or missed.

Line Number Calculation

For child objects like product order items, we used a Record-Triggered Flow to auto-assign incremental Line Numbers. This ensures consistency and makes integration with external systems cleaner and more deterministic.

And the result of this custom notification can be seen below. Alongside the alert, the same Flow also updates the StatusSync__c field to reflect the latest sync status — in case notifications are ignored or missed.

Outbound Messages

Used to synchronize entities with external systems in real time, using XML-based SOAP messages. These were implemented entirely through Record-Triggered Flows, eliminating the need for custom Apex.

Each time a record is inserted, updated, or deleted , the Flow automatically sends an outbound message containing key business data to an external system acting as a Webhook.

These flows not only automated key parts of the data lifecycle but also ensured the UI remained responsive, synced, and reliable, all without a single line of Apex.

Outbound Messages (Webhooks via SOAP)

Salesforce provides several integration mechanisms, and one of the most useful for real-time scenarios is Outbound Messaging, which uses SOAP-based webhooks.

In OmniSync, we use Outbound Messages to detect changes (insert/update) on key entities like RetailStore__c. When such changes occur:

  1. An Outbound Message is configured to include the necessary fields.
  2. A Salesforce Flow picks up the change and sends the message via a SOAP notification.
  3. This notification is received by a Logic App endpoint , which handles the next steps in the integration pipeline.

This method is particularly useful when Change Data Capture (CDC) isn’t available for certain standard or custom objects. While not technically streaming, this approach is near real-time, reliable, and easy to configure, no code required.

It provides a practical balance between simplicity and speed for triggering external workflows on data changes.

As shown in the figure below the RetailStoreChanges outbound message which happens when a record on the RetailStore is inserted or updated. Also the flow triggered from this outbound message can be seen on the previous section.

And a partial schema of the SOAP notification for this outbound message:

<schema elementFormDefault="qualified" targetNamespace="http://soap.sforce.com/2005/09/outbound"
          xmlns:xsd="http://www.w3.org/2001/XMLSchema"
          xmlns:ent="urn:enterprise.soap.sforce.com"
          xmlns:ens="urn:sobject.enterprise.soap.sforce.com"
          xmlns:tns="http://soap.sforce.com/2005/09/outbound"
          xmlns="http://www.w3.org/2001/XMLSchema">
  <import namespace="urn:enterprise.soap.sforce.com" schemaLocation="ID.xsd"/>
  <import namespace="urn:sobject.enterprise.soap.sforce.com" schemaLocation="RetailStore.xsd"/>
    <!-- <element name="notifications"> -->
  <element name="notifications" type="tns:NotificationsType"/>
      <complexType name="NotificationsType">
        <sequence>
          <element name="OrganizationId" type="ent:ID"/>
          <element name="ActionId" type="ent:ID"/>
          <element name="SessionId" type="xsd:string" nillable="true"/>
          <element name="EnterpriseUrl" type="xsd:string"/>
          <element name="PartnerUrl" type="xsd:string"/>
          <element name="Notification" maxOccurs="100" type="tns:RetailStoreNotification"/>
        </sequence>
      </complexType>
    <!-- </element> -->
    <complexType name="RetailStoreNotification">
      <sequence>
        <element name="Id" type="ent:ID"/>
        <element name="sObject" type="ens:RetailStore"/>
      </sequence>
    </complexType>
    <!-- <element name="notificationsResponse"> -->
      <complexType name="notificationsResponse">
        <sequence>
          <element name="Ack" type="xsd:boolean"/>
        </sequence>
      </complexType>
    <!-- </element> -->
</schema>
Enter fullscreen mode Exit fullscreen mode

The Delete Workaround

This snippet shows a piece of that code where the custom object is a RetailStoreDeletedEvent__c created on that situation:

trigger AfterDeleteRetailStore on RetailStore (after delete) 
{
    List<RetailStoreDeletedEvent __c > co = new List<RetailStoreDeletedEvent__ c >();
    for(RetailStore o : Trigger.old)
    {
        RetailStoreDeletedEvent __c c = new RetailStoreDeletedEvent__ c();
        c.Name = o.Name;
        c.DeletedId__c = o.Id;

        co.add(c);
    }

    insert co;
}
Enter fullscreen mode Exit fullscreen mode

And its associated flow:

Change Data Capture (CDC)

While outbound messages worked well for basic events, OmniSync also uses Salesforce Change Data Capture (CDC) for streaming change events , including inserts, updates, and deletes in near real time.

CDC events are published to a PushTopic based streaming API , which is accessed via Salesforce’s Pub/Sub API and consumed downstream using the CDC Ingestor App.

Change Data Capture (CDC) via Pub/Sub API

To capture real-time changes in Salesforce data, OmniSync leverages Change Data Capture (CDC) a native Salesforce feature that publishes events when records are created, updated, deleted, or undeleted.

In earlier implementations, external clients had to rely on the Streaming API to subscribe to CDC events. However, OmniSync uses the more modern Pub/Sub API , which provides several advantages:

  • Built on gRPC and HTTP/2 , making it more efficient and scalable than traditional Streaming API.
  • Delivers binary event messages , reducing payload size and improving speed.
  • Supports multiple languages (Java, Go, Node.js, etc.), making it easier to integrate into modern backend systems.
  • Reliable message delivery with built-in acknowledgment and replay support.

🧩 How It Works in OmniSync

  • Insert, Update, and Delete events are published for key objects (like Orders, Accounts, and Products)
  • The CDC Ingestor App , which is an Azure Container App implemented on Javascript receives these events and pushes them into Azure Event Grid
  • From there, Logic Apps which are subscribed as Webhooks and filtered by subject, execute the transformations logic syncing to the downstream systems.

Here’s a partial example of a JSON CDC changed object:

{
  "replayId": 211583,
  "payload": {
    "ChangeEventHeader": {
      "entityName": "Account",
      "recordIds": [
        "001WU00000vBSOLYA4"
      ],
      "changeType": "UPDATE",
      "changeOrigin": "",
      "transactionKey": "0000dece-b24f-fd96-1649-167b8ad0cbfa",
      "sequenceNumber": 1,
      "commitTimestamp": 1746061521000,
      "commitNumber": 0,
      "commitUser": "005WU000009oVebYAE",
      "nulledFields": [],
      "diffFields": [],
      "changedFields": []
    },
    "Name": null,
    "Type": null,
    "ParentId": null,
    "BillingAddress": null,
    "ShippingAddress": {
      "Street": "Belleveu Main Street",
      "City": "Belleveu",
      "State": null,
      "PostalCode": "23232",
      "Country": "USA",
      "Latitude": 37.5624,
      "Longitude": -77.4467,
      "GeocodeAccuracy": "Zip"
    },
    "Phone": null,
    "Fax": null,
    "AccountNumber": "CS424",
    "Website": null,
    "Sic": null,
  ......
}
Enter fullscreen mode Exit fullscreen mode

Event Enrichment

To make the CDC data more useful Salesforce was configured to include extra fields in CDC payloads since for Updates only the changed fields were sent, and to avoid extra calls on the Logic Apps integration layer to find lookups some fields were additionally sent to overcome this need.

The REST API call can be seen following as an example of how to create a subscription named OmniSync_Channel_chn_AccountChangeEvent on the OmniSync_Channel__chn channel for the Account entity, with additional fields included to enrich the event message.

Delete Handling with CDC

Unlike Outbound Messages, CDC supports deletes natively , which made it the preferred approach for full bi-directional sync.

Logic Apps

Logic Apps serve as the core integration engine in OmniSync, bridging Salesforce, Dynamics 365, and Microsoft Fabric. They handle data transformation , validation , and routing , ensuring that only clean and properly formatted data reaches the destination systems.

We use two main categories of Logic Apps based on their direction and purpose:

  • Salesforce to Dynamics 365: Handles the creation, update, or deletion of retail-related entities like Orders, Products, and Accounts in D365.
  • Salesforce to Fabric (Lakehouse): Pushes changes from Salesforce into Microsoft Fabric via Event Hub, powering near real-time analytics.

Each Logic App is triggered by a different integration pattern depending on the nature of the Salesforce event:

  • SOAP Webhook (Outbound Message) — Triggered by Salesforce outbound notifications for objects like RetailStore, which send data to a Logic App endpoint via SOAP.
  • Event Grid Webhook  — Used for handling CDC events or platform events forwarded through Azure Event Grid.
  • Polling via Salesforce Connector  — A fallback or scheduled mechanism to periodically check for new or updated data when webhooks are not available or reliable.

Let’s walk through a few concrete scenarios to illustrate how different types of Logic Apps work across various triggers and systems.

Currency Inserted (Salesforce → Fabric)

In this scenario, a Salesforce connector polls the CurrencyType object every minute for changes. When a new currency is detected:

  1. A Liquid template is used to transform the CurrencyType format into a simplified Currency object.
  2. Some fields (like datetime formats) can’t be transformed using Liquid due to its limitations, so they’re adjusted directly within the Logic App using expressions.
  3. The transformed object is then sent to Event Hub, which pushes it into the Event Stream in Microsoft Fabric for analytics.

Retail Store Deleted (Salesforce → Fabric via SOAP)

When a Retail Store is deleted, Salesforce sends an Outbound Message ( SOAP -based webhook) to the Logic App:

  1. The incoming message is validated against the OutboundRetailStoreDeletedEvent.xsd schema.
  2. A check is performed to confirm this change is part of an integration sync (using user context, as described in Part 1).
  3. After parsing and a manual field transformation (via a Compose action), the data is sent to Event Hub to deliver the new CDC deletion event into Fabric.

Dynamics 365 Account Sync (Fabric → Salesforce via Event Grid)

This is a more advanced use case that handles create, update, and delete events in a single Logic App, all triggered by a subscription to Event Grid.

Since subscriptions are scoped per entity (not per action), we handle branching within the Logic App itself:

➕ Create:

  • The Logic App checks if the incoming account already exists in Dynamics 365 by querying a MasterDataMapping table via a Lakehouse SQL endpoint.
  • If it exists (e.g., an account code like A001 was already synced from Salesforce), a conflict is detected. The Salesforce record is updated with a StatusSync = conflict.
  • Otherwise, a new Dynamics 365 Account is created via the Dataverse connector , with fields mapped accordingly and its id is sent to Fabric mapping table.

🔁 Update:

  • A check is made to confirm the record exists in the mapping table.
  • If not found, a 404 is returned.
  • If it does exist, we call an Azure Function to enrich the update, since Salesforce CDC events may not contain all fields needed for a proper sync.

❌ Delete:

  • Again, existence is verified via the mapping table.
  • If the account is found, we perform a cleanup of dependencies (e.g., Sales Orders ) that must be removed before deleting the Account from Dynamics 365 using the Dataverse connector.
  • If not found, a 404 is returned, indicating a potential sync issue.

Transformations

Inside the Logic Apps, data transformations are a key step to map and reshape fields coming from Salesforce into a format expected by the destination systems (such as Dynamics 365 or Fabric). Several transformation mechanisms depending on the complexity and context of the data are used. All approaches aim to produce the same result: a well-structured, consumable object on the other side.

DataMapper (Visual XSLT Mapping)

We used DataMapper to transform objects like RetailStore from Salesforce to Store objects consumed by Fabric.

DataMapper is Microsoft’s visual tool for mapping and transforming data across schemas. It allows users to build mappings between JSON or XML structures via a drag-and-drop interface, generating an XSLT file that can later be applied within Logic Apps.

However, the tool currently has significant limitations:

  • It behaves unpredictably when handling complex or mixed-schema scenarios (e.g., identical schema names in XML vs. JSON)
  • XML-to-XML transformations often fail to display properly or get lost
  • Some edits are lost when not saved properly due to the use of temporal files
  • It lacks advanced customization, requiring manual XSLT editing in many cases

Because of these constraints, DataMapper was used only when absolutely necessary. While promising, it’s not stable enough yet for this project.

Liquid Templates

Liquid is a lightweight template engine originally developed by Shopify. In Logic Apps, we use Liquid templates primarily to transform incoming Salesforce events into CDC-style JSON objects to be sent to Fabric Lakehouse via Event Hub.

These templates allow field filtering and conditional logic, which makes them perfect for reshaping Salesforce payloads into clean, minimal representations.

Example: Salesforce Account to Customer

{%- assign changedFields = content.payload.ChangeEventHeader.changedFields -%}

{
   "Operation": "{{ content.payload.ChangeEventHeader.changeType | capitalize }}",
   "Entity": "Customer",
   "Values": "{
    \"CustomerCode\": \"{{ content.payload.AccountNumber }}\",    
    {%- for changedField in changedFields -%}
     {% if changedField == "Name" %}
      \"CompanyName\": \"{{ content.payload.Name }}\",
     {% elsif changedField == "Email__c" %}
      \"EmailAddress\": \"{{ content.payload.Email__c }}\",
     {% elsif changedField == "Phone" %}
      \"Phone\": \"{{ content.payload.Phone }}\",
     {% elsif changedField == "LastModifiedDate" %}
      \"UpdatedDate\": \"{{ content.payload.LastModifiedDate }}\",
     {% endif %}
    {%- endfor -%}
    \"AddressLine1\": \"{{ content.payload.ShippingAddress.Street }} {{ content.payload.ShippingAddress.City }} {{ content.payload.ShippingAddress.PostalCode }} {{ content.payload.ShippingAddress.State }} {{ content.payload.ShippingAddress.Country}}\",
    \"Latitude\": \"{{ content.payload.ShippingAddress.Latitude }}\",
    \"Longitude\": \"{{ content.payload.ShippingAddress.Longitude }}\",
    \"CustomerType\": \"Company\",
    \"SalesForceId\": \"{{ content.payload.ChangeEventHeader.recordIds | first }}\"
     }",
 "CreatedDate": "{{ "now" | date: "%Y-%m-%d %H:%M" }}",
    "UpdatedDate": "{{ "now" | date: "%Y-%m-%d %H:%M" }}"
}
Enter fullscreen mode Exit fullscreen mode

This approach works well for real-time event streaming, although it has limitations, especially with datetime transformations, which must be handled elsewhere in the Logic App.

Manual Mapping using Compositions

In many cases, particularly when data is already in a usable format, we perform transformations manually using Compose actions inside Logic Apps. This is a straightforward method to restructure or rename fields without involving external tools or templates.

It’s especially useful for:

  • Light field remapping.
  • SOAP/XML payload extractions.
  • Intermediate enrichments.

Manual Mapping Using Connectors

When synchronizing with Dynamics 365, we rely on the DataVerse connector and its built-in CRUD operations. This makes it straightforward to map previously decoded values from Salesforce to their counterparts in Dynamics 365. Thanks to the rich set of expression functions available in Logic Apps’ Workflow Definition Language, we can apply transformations directly, such as string manipulation, conditional logic, and date formatting.

For instance, when mapping Salesforce Products to Dynamics 365, we convert date fields from Salesforce’s CDC (which are in epoch format) into standard UTC timestamps using expression functions before sending them downstream.

Azure Functions for Advanced Logic

When field transformations are too complex or when we only receive partial data (as is the case with Salesforce CDC updates) logic is offloaded to an Azure Function.

These functions:

  • Read the incoming JSON payload.
  • Identify only the changed fields.
  • Query our master data mapping layer if needed.
  • Apply transformations and send updates to Dynamics 365 through the Dataverse SDK.

Example Snippet:

using (ServiceClient svc = new ServiceClient(ConnectionStr))

if (svc.IsReady && inputAccountValue != null)
{
    var account = new Entity("account");

    account.Attributes["accountnumber"] = inputAccountValue.payload.cgcloud __Account_Number__ c;

    foreach (var changedField in inputAccountValue.changedFields)
    {
        if (changedField == "name")
        {
            account.Attributes["name"] = inputAccountValue.payload.Name;
        }
        else if (changedField == "cgcloud __Account_Email__ c")
        {
            account.Attributes["emailaddress1"] = inputAccountValue.payload.cgcloud __Account_Email__ c;
        }
        else if (changedField == "Phone")
        {
            account.Attributes["telephone1"] = inputAccountValue.payload.Phone;
        }
    }

    if (!string.IsNullOrEmpty(inputAccountValue.payload.ShippingAddress.Street))
Enter fullscreen mode Exit fullscreen mode

This gives us full flexibility and control, especially when dealing with partial updates and nested logic.

Event Grid Integration

As part of our CDC integration model, Event Grid is used as the main pub/sub mechanism for CDC events emitted by our custom CDC Ingestor running as a Container App.

The CDC Ingestor is responsible for:

  • Listening to entities’ changes from Salesforce CDC Pub/Sub API
  • Publishing them into Event Grid Topics

Logic Apps are then subscribed to these topics using Webhook-based filters (based on the subject field). For example, a Logic App subscribed with a filter on subject = Account will only trigger on Account events, regardless of the operation type.

This model avoids over subscribing and helps isolate business logic per entity.

Monitoring

All components in the OmniSync architecture are monitored using Azure Monitor , with additional diagnostics enabled through a Log Analytics Workspace solution.

Logic Apps Monitoring

Logic Apps include native history tracking, where you can review runs, triggers, and failures directly in the Azure Portal.

Container Apps Monitoring

For monitoring the CDC Ingestor or other custom container-based services, Container Apps Logs can be queried through Azure Monitor using Kusto queries. These logs include both system diagnostics and custom error messages emitted from the application code.

Salesforce Monitoring

Salesforce provides its own Monitoring tool to track outbound messages, such as SOAP or Event-driven webhooks. This includes:

  • Viewing the delivery status of outbound messages.
  • Monitoring failed retries or invalid endpoint responses.
  • Debugging SOAP/XML payloads directly from the monitoring queue.

This ensures that any integration failures between Salesforce and Logic Apps (e.g., due to schema mismatches or endpoint issues) can be proactively addressed from both sides.

Salesforce CLI

All Salesforce metadata used for this integration is version-controlled in a Git repository. To extract and manage this metadata, we use the Salesforce CLI (sf), which supports robust project management and deployment automation.

The CLI was used to retrieve key components from the source Salesforce org, including:

  • Custom Objects
  • Apex Triggers
  • Flows and Flow Definitions
  • Layouts and Applications
  • Workflow Rules
  • Connected Apps
sf project retrieve start --metadata CustomObject \
                          --metadata ApexTrigger \
                          --metadata Flow \
                          --metadata WorkFlow \
                          --metadata Layout \
                          --metadata FlowDefinition \
                          --metadata CustomApplication \
                          -m ConnectedApp
Enter fullscreen mode Exit fullscreen mode

This allows easy export of Salesforce configuration and logic to be deployed across environments (e.g., staging, QA, production) via CI/CD pipelines or manual promotion.

Lessons Learned

  • Logic Apps proved to be a powerful iPaaS tool, allowing to connect Salesforce, Dynamics 365, and Microsoft Fabric with minimal custom code. By handling transformations, validations, and routing logic visually, writing full applications was avoided while still delivering complex integration flows.
  • Organizing Logic Apps by direction Salesforce to Dynamics 365 and Salesforce to Fabric made the architecture easier to manage and extend. Using a mix of triggers (polling, EventGrid, SOAP webhooks) gave the flexibility to respond to different Salesforce behaviors and system needs.
  • For data transformation Liquid templates gave a powerful template and transformation engine.
  • Some complex cases needed the usage of Azure Functions were essential when deeper logic was needed, since trying to do in a visual way on Logic Apps would have been terribly complicated.
  • The Pub/Sub API for CDC was a really efficient and easy way to provide a scalable event delivery over HTTP/2 and gRPC.
  • Although at the start of the project DataMapper looked promising, it had limitations and bugs for more complex use cases, so manual Compose actions, Liquid templates or XSLT updates were more reliable in those cases.

Coming Next

📌 In the next post, I’ll dig into Dynamics 365 Integration

👀 Follow me here on Medium to catch Part 4.

💻 Source code: https://github.com/zodraz/salesforce

Top comments (0)