Data 360 for Salesforce Developers

Nine hands-on builds — banks, telcos, hospitals, retailers, airlines and freight companies — from your first data stream to zero-copy federation, millisecond data graphs and real-time agents.

Data 360 for Salesforce Developers

Data 360 (formerly Data Cloud) is where scattered customer data becomes one profile the whole platform can act on — Flow, Apex, Agentforce, analytics. This track teaches it the way we learn things here: nine complete builds, each anchored in a real business scenario, each with working SQL, Apex and configuration.

How this track is organized

Read the Fundamentals series in order the first time — every part leans on the one before it. The Advanced series assumes you've finished the fundamentals and can be read in any order.

SeriesSectionThe build
Fundamentals Part 1 · FundamentalsA retail bank unifies card, loan and app-usage data into one profile
Part 2 · Insights & SegmentsA telco flags declining data usage for a win-back campaign
Part 3 · Agentforce GroundingA healthcare service agent answers billing from the unified record
Part 4 · Apex & Flow IntegrationAn e-commerce retailer auto-creates a Case on churn risk
Part 5 · Tableau & CRM AnalyticsA logistics company builds an exec dashboard on unified data
Advanced Zero Copy FederationA retail chain queries Snowflake without duplicating terabytes
Data Graphs at RuntimeA telco serves a millisecond customer 360 to a live-call agent
Advanced Identity ResolutionAn airline merges loyalty, booking and app identities cleanly
Streaming & Real-TimeAn e-commerce site triggers an abandoned-cart agent in seconds

Where this fits on the site

Data 360 Fundamentals: One Customer, Many Systems

The scenario

Meridian Trust Bank is a retail bank. Card transactions live in the core banking system and land in Amazon S3 as nightly extracts. Loans live in a separate origination system. Mobile app events stream into Snowflake. Service and sales run on Salesforce.

The same customer exists in all four systems — as four unrelated records. A rep taking a call sees a Contact and a few Cases. They can't see that the caller holds two cards, is three years into a mortgage, and stopped opening the app six weeks ago. Marketing, meanwhile, keeps emailing card offers to people who already own that card.

This is the problem Data 360 (formerly Data Cloud) exists to solve: turn scattered records into one unified customer profile that the whole platform — Flow, Apex, Agentforce, analytics — can act on. In this post we build Meridian's profile end to end and, along the way, cover the concepts you'll use in every Data 360 project.

For beginners: think of Data 360 as a lakehouse bolted onto your org. Data stays big and raw over there; Salesforce gets a clean, stitched-together view of each customer over here.

The pipeline in one picture

Every Data 360 implementation is the same five stages. Meridian's build maps onto them directly:

  1. Ingest — bring in card, loan and app-usage data through data streams.
  2. Harmonize — map each source's fields onto a shared data model.
  3. Resolve identity — match records across sources into one unified profile per human.
  4. Compute insights — derive metrics like lifetime value with Calculated Insights.
  5. Activate — push the results into segments, CRM fields, flows, agents and dashboards.

Parts 2–5 of this series go deep on stages 4 and 5. This post covers the foundation: stages 1–3, plus how to query the results as a developer.

Stage 1 — Ingestion: data streams and DLOs

A data stream is a configured feed from one source. For Meridian:

  • Card transactions: an Amazon S3 connector pointed at the nightly extract bucket, scheduled to refresh after the core banking batch finishes.
  • Loans: the origination system exports CSV to the same bucket — a second S3 stream.
  • App events: the Snowflake data lives where it is. With zero copy, Data 360 queries it in place — no duplication, no sync jobs.
  • CRM data: the built-in Salesforce connector ingests Contact, Case and Account with a few clicks.

Each stream lands in a Data Lake Object (DLO) — a raw, source-shaped table. When you create a stream you assign it a category: Profile (who someone is — the loan holder file), Engagement (things that happened — card transactions, app events) or Other (reference data — branch list).

Gotcha: engagement streams require an immutable event-time field at ingestion. Pick the real transaction timestamp, not the file-load time — you can't change it later without recreating the stream.

Stage 2 — Harmonization: DLOs become DMOs

Raw DLOs are useless for cross-source questions because every system names things differently: cust_no in core banking, borrower_id in the loan system, ContactId in CRM. Harmonization fixes that by mapping each DLO onto Data Model Objects (DMOs) — a shared, standardized schema based on the Customer 360 data model.

For Meridian, the mapping looks like this:

  • Loan-holder and cardholder records map to the standard Individual DMO (ssot__Individual__dlm) — one source profile per system.
  • Emails and phone numbers map to Contact Point Email and Contact Point Phone — identity resolution will need them.
  • Card transactions map to a custom Card Transaction DMO; there's no standard object for them, and custom DMOs are fine.
  • App events map to a custom App Session DMO with the event-time field as its engagement timestamp.

Mapping is point-and-click in the Data 360 UI: open the DLO, choose the target DMO, connect fields. The payoff is that every downstream feature — insights, segments, agents — works against one vocabulary instead of four.

Stage 3 — Identity resolution: four records, one human

After harmonization Meridian has three Individual source profiles for the same customer. Identity resolution stitches them together using a ruleset with two parts:

  • Match rules decide when two source profiles are the same person. Meridian starts conservative: exact match on normalized email, OR exact phone plus fuzzy first name and exact last name.
  • Reconciliation rules decide which value wins when sources disagree — e.g. "last updated" for mailing address, "source priority: core banking first" for legal name.

The output is a set of Unified Individual records, each linked back to its source profiles through link DMOs. Nothing is deleted or merged destructively — the unified profile is a computed layer on top, and it recomputes as new data arrives.

Design carefully here. Over-matching is worse than under-matching: two different customers fused into one profile means a rep discussing someone else's mortgage on a call. Start with exact rules, measure the consolidation rate on the ruleset's processing results, and loosen deliberately.

DMOs vs sObjects: know which world you're in

The biggest mental shift for a Salesforce developer is that DMOs are not sObjects. They look similar in the UI and end in a suffix, but they behave differently in almost every way that matters:

AspectsObjectsDMOs
StorageTransactional CRM databaseLakehouse storage alongside the org
NamingAccount, My_Object__cssot__Individual__dlm, Card_Transaction__dlm
Query languageSOQLANSI SQL via the Query API (joins, aggregates, window functions)
WritesFull CRUD from Apex, Flow, APIPopulated by ingestion and mapping; you don't insert into a DMO from Apex
Reacting to changesApex triggers, record-triggered flowsData actions and Data 360–triggered flows (Part 4)
Row scaleMillions, with storage costs that biteBillions — engagement data is the design case
Cost modelStorage and licensesConsumption credits — queries and processing are metered

That last row matters day to day: a careless SELECT * over two years of card transactions costs real money. Treat Data 360 queries like you treat callouts — deliberate, filtered and cached where possible.

Query it like a developer

Everything above is configuration. Here's where it pays off. The Query API speaks ANSI SQL against DMOs, so cross-source questions become one statement. Meridian's "active card exposure per customer":

SELECT
    t.customer_id__c              AS customer_id__c,
    COUNT(t.card_id__c)           AS active_cards__c,
    SUM(t.current_balance__c)     AS total_balance__c
FROM Card_Account__dlm t
WHERE t.card_status__c = 'Active'
GROUP BY t.customer_id__c

From Apex, the same query runs through ConnectApi.CdpQuery. Results come back as ordered rows plus a metadata map that tells you each column's position:

public with sharing class CardExposureService {

    @AuraEnabled(cacheable=true)
    public static Decimal totalActiveBalance(String customerId) {
        ConnectApi.CdpQueryInput input = new ConnectApi.CdpQueryInput();
        input.sql =
            'SELECT SUM(current_balance__c) AS total_balance__c ' +
            'FROM Card_Account__dlm ' +
            'WHERE customer_id__c = \'' +
            String.escapeSingleQuotes(customerId) +
            '\' AND card_status__c = \'Active\'';

        ConnectApi.CdpQueryOutputV2 result =
            ConnectApi.CdpQuery.queryAnsiSqlV2(input);

        if (result.data.isEmpty()) {
            return 0;
        }
        Integer col = result.metadata.get('total_balance__c').placeInOrder;
        Object raw  = result.data[0].rowData[col];
        return raw == null ? 0 : Decimal.valueOf(String.valueOf(raw));
    }
}

Wire that into an LWC on the Contact page and the rep finally sees card exposure next to the Case list — data that never left the lakehouse until the moment it was asked for.

What about Calculated Insights?

Stage 4 of the pipeline is Calculated Insights: SQL-defined metrics that Data 360 computes on a schedule and stores as queryable objects — things like lifetime value, engagement score or churn risk. They deserve their own walkthrough, so that's Part 2 of this series, where a telco uses one to catch customers going quiet before they cancel.

What you learned

  • Data 360 is a five-stage pipeline: ingest, harmonize, resolve identity, compute insights, activate.
  • Data streams land raw data in DLOs; mapping DLOs onto DMOs gives every source one shared vocabulary.
  • Identity resolution builds unified profiles non-destructively with match and reconciliation rules — start strict.
  • DMOs are not sObjects: ANSI SQL instead of SOQL, no direct writes, consumption-based cost.
  • ConnectApi.CdpQuery.queryAnsiSqlV2 puts all of it within reach of Apex and LWC.

Next in the series: Calculated Insights and segments. The full series lives on the Data 360 track page, and the grounding story continues in our Agentforce grounding walkthrough.

Sources: Salesforce Help — About Identity Resolution · Identity Resolution Rulesets · Apex Reference — CdpQuery Class · Data 360 Query API

Calculated Insights and Segments: Catch Them Before They Cancel

The scenario

Arcline Mobile is a mid-size telco. Customers rarely call to complain before leaving — they just go quiet. Data usage drops for a month or two, then the porting request arrives. By the time churn shows up in a report, the customer is gone.

The retention team's ask is specific: "Give us a weekly list of accounts whose data usage is trending down hard, so we can hit them with a win-back offer while they're still customers."

Arcline already has the foundation from Part 1: daily usage records ingested into a Daily_Data_Usage__dlm DMO and identity resolution producing unified profiles. What's missing is the metric ("usage is declining"), the audience ("everyone whose metric crossed the line") and the delivery ("into the campaign tool, weekly"). In Data 360 those are exactly three features: a Calculated Insight, a segment and an activation.

What a Calculated Insight actually is

A Calculated Insight (CI) is a metric defined in ANSI SQL that Data 360 computes on a schedule and stores as its own queryable object. Think of it as a materialized view with rules:

  • Every output column alias must end in __c.
  • At least one column is a measure (a number you aggregate) and at least one is a dimension (what you group by — here, the customer).
  • The result is stored and versioned per run, so segments and queries read precomputed values instead of re-scanning billions of engagement rows.

For beginners: a CI is to Data 360 what a roll-up summary field is to CRM — except it's SQL, it can span any DMOs, and it runs over lakehouse-scale data.

Write the insight

The metric Arcline wants: average daily data usage over the last 30 days, compared with the 60 days before that. In Data 360, open Calculated Insights → New → SQL Builder and define:

SELECT
    u.customer_id__c AS customer_id__c,
    AVG(CASE
        WHEN u.usage_date__c >= CURRENT_DATE - INTERVAL '30' DAY
        THEN u.data_used_mb__c
    END) AS avg_daily_mb_last_30__c,
    AVG(CASE
        WHEN u.usage_date__c <  CURRENT_DATE - INTERVAL '30' DAY
        THEN u.data_used_mb__c
    END) AS avg_daily_mb_prior_60__c
FROM Daily_Data_Usage__dlm u
WHERE u.usage_date__c >= CURRENT_DATE - INTERVAL '90' DAY
GROUP BY u.customer_id__c

Two measures, one dimension. A customer averaging 900 MB/day two months ago and 250 MB/day now is exactly who the retention team wants — and now that comparison is a stored fact, not a query someone has to remember to run.

Set the publish schedule to daily and run a preview before publishing — the SQL builder shows sample output, which is where you catch the classic mistakes: a missing __c on an alias, a dimension you forgot to group by, or a measure that isn't actually aggregated.

One more decision before publishing: batch or streaming. Batch CIs recompute on a schedule and suit metrics where "as of last night" is fine. Streaming insights compute continuously over engagement streams for signals that matter within minutes — a fraud flag, a cart abandoned mid-session. Arcline's churn signal moves on a weeks scale, so batch is the right call, and the cheaper one: streaming compute is metered continuously, and paying real-time prices for a slow-moving metric is a common early Data 360 mistake.

Keep thresholds out of the CI. Notice the SQL computes the two averages but doesn't decide what "declining" means. Business lines move ("flag at 60%!" … "no, 70%!") — if the threshold lives in the CI you re-publish SQL every time. Put judgment in the segment, facts in the insight.

Build the segment

A segment is a saved audience definition over unified profiles. Once the CI has published, its measures appear in the segmentation attribute library like any other attribute. Arcline creates a segment on the Unified Individual:

  1. New Segment → segment on Unified Individual, name it Declining Data Usage — Win-Back.
  2. Drag in the CI attributes and set the rules: avg_daily_mb_last_30__c less than 60% of avg_daily_mb_prior_60__c, and avg_daily_mb_prior_60__c greater than 200 (so brand-new and dormant SIMs don't pollute the list).
  3. Add guard filters from profile attributes: contract status is active, and the customer isn't in the last month of a fixed-term contract (those get a different journey).
  4. Set the refresh schedule — every 24 hours here; segments can also refresh faster if the use case needs it.

Publish, and the count comes back: about 4,200 of Arcline's 1.9M subscribers currently match. That number alone told the retention team more than the quarterly churn report did.

Two habits make segments maintainable at scale. First, name them for the decision, not the mechanics — Declining Data Usage — Win-Back tells the next admin what it's for; Segment_CI_v3_final doesn't. Second, keep one segment per business action. When the same audience definition feeds an email journey, a call list and a service alert, resist forking it three times — one segment, three activations, so a threshold change happens in exactly one place.

Activate it

A segment sitting in Data 360 does nothing. Activation publishes its membership to an activation target — the system where action happens:

  • Marketing Engagement: Arcline's primary path. The win-back journey (email + app push with a personalized data-pack offer) triggers on segment entry.
  • File-based targets (Amazon S3): a weekly CSV for the outbound call vendor, with only the contact points the vendor needs.
  • Back into the platform: segment membership and CI values can also drive CRM-side automation — Part 4 of this series wires a CI change to an Apex trigger for exactly that.

When you create the activation you choose which attributes ride along with each member — Arcline sends first name, preferred channel and the two usage averages, so the offer copy can say "your usage dropped from 900 MB to 250 MB a day" instead of a generic plea.

Consent is part of activation, not an afterthought. Activations respect consent data mapped into the model — make sure marketing-consent contact points are mapped before the first send, not after the first complaint.

Did it work? Close the loop

Because the CI recomputes daily, measuring the campaign is one more query — no data science project required:

SELECT
    COUNT(ci.customer_id__c)              AS flagged_customers__c,
    AVG(ci.avg_daily_mb_last_30__c)       AS avg_usage_now__c,
    AVG(ci.avg_daily_mb_prior_60__c)      AS avg_usage_before__c
FROM Declining_Usage_Insight__cio ci
WHERE ci.avg_daily_mb_last_30__c
      < ci.avg_daily_mb_prior_60__c * 0.6

CI results live in objects with the __cio suffix and are queryable from the API, from Apex via ConnectApi.CdpQuery (shown in Part 1), and from dashboards (Part 5). Six weeks in, Arcline compared flagged customers who got the offer against a holdout group — the treated group's usage recovered at roughly twice the rate. That's the retention team's business case, generated by the same objects that run the campaign.

What you learned

  • A Calculated Insight is scheduled ANSI SQL stored as a queryable object — measures, dimensions, __c aliases.
  • Facts belong in the CI; thresholds and judgment belong in the segment, where they're cheap to change.
  • Segments are audience definitions over unified profiles; activations publish them to targets with the attributes the target needs.
  • CI outputs (__cio objects) are queryable, which makes measuring the campaign as easy as running it.

Next in the series: grounding Agentforce agents in Data 360, where unified profiles answer customer questions live in a conversation. The full series lives on the Data 360 track page.

Sources: Salesforce Help — Calculated Insights in Segmentation · Create Segments in Data 360 · Activation and Activation Targets

Grounding Agentforce in Data 360: The Agent That Knows the Patient

The scenario

CarePoint Health runs a network of clinics. Their number-one inbound call is billing: "Why did I get this invoice?" "Didn't my insurance cover that?" "What do I actually owe?" The answers exist — but scattered across the billing system, the payer/claims feed and the patient portal, none of which the service agent can see.

CarePoint already unified this data in Data 360 (the pipeline from Part 1): billing invoices, claim statuses and payment history all resolve to one unified patient profile. Now they want their Agentforce service agent to answer billing questions in the portal chat — from the unified record, live, mid-conversation. Not from last night's sync. Not from the Contact record alone.

This is a different problem from RAG

We've covered grounding agents in unstructured knowledge — PDFs, articles, chunked and vector-indexed — in the Agentforce Data Library walkthrough. That's the right tool when the question is "what's the refund policy?"

CarePoint's question is different: "what does this specific patient owe?" The answer isn't a passage to retrieve — it's structured data to look up: rows, amounts, dates, joined across systems. Grounding on structured Data 360 profiles is its own discipline, with its own tools. Use both in the same agent; don't confuse them.

Question typeGrounding toolExample
Policy / how-to (unstructured)Data Library, search index, retriever"Is a telehealth visit billed differently?"
This customer's facts (structured)Data graph, direct query, Apex action"What's my balance on invoice 4417?"

Real time vs batch: pick per question, not per project

Structured grounding has two speeds, and choosing wrong costs either money or accuracy:

  • Real-time (data graphs): a data graph is a precomputed, denormalized view of a unified profile and its related DMOs, materialized as a single document. Because it's precalculated, reads return in near real time — this is the shape you want inside a live conversation.
  • On-demand SQL (Query API / Apex): flexible, joins anything, but each call is metered compute with real latency. Right for questions a data graph doesn't already answer.
  • Batch (Calculated Insights): precomputed metrics like "balance overdue > 60 days", refreshed on schedule (Part 2). Perfect for flags and scores; wrong for "what did I just pay?", where stale answers destroy trust.
Rule of thumb: if the customer could have changed the answer today, ground it real-time. If the business computes the answer weekly anyway, a CI is cheaper and just as good.

Build step 1 — the data graph

CarePoint creates a data graph in Data 360 (Data Graphs → New):

  1. Primary object: Unified Individual — the patient.
  2. Related objects: Billing_Invoice__dlm (open and recent invoices), Claim__dlm (status, payer), Payment__dlm (last 12 months).
  3. Fields: only what the agent needs — invoice number, amount due, due date, claim status. Not the diagnosis codes. Grounding scope is a security decision, and the smallest useful graph wins.

Once built, the graph maintains itself as underlying DMOs change, and any consumer — prompt templates, agents, the API — reads the same fresh document.

Build step 2 — ground the prompt template

In Prompt Builder, CarePoint creates a flex template for the agent's billing topic. The instruction block is where a healthcare agent is kept honest:

You are a billing assistant for CarePoint Health.
Answer ONLY from the billing facts provided in this prompt.
If the facts don't contain the answer, say so and offer to
connect a human agent. Never speculate about insurance
coverage decisions.

Patient's question: {!$Input:patient_question}

The billing facts themselves come from a grounding resource added through the template's resource picker. Data graphs can be attached directly as a grounding source, or — for full control over the shape of what the model sees — through an Apex data provider that reads the graph and returns a Prompt string:

public with sharing class PatientBillingFacts {

    public class Request {
        @InvocableVariable(required=true)
        public String unifiedPatientId;
    }

    public class Response {
        @InvocableVariable
        public String Prompt;
    }

    @InvocableMethod(capabilityType='FlexTemplate://CarePoint_Billing_Answers')
    public static List<Response> getFacts(List<Request> requests) {
        ConnectApi.CdpQueryInput input = new ConnectApi.CdpQueryInput();
        input.sql =
            'SELECT invoice_number__c, amount_due__c, due_date__c, claim_status__c ' +
            'FROM Billing_Invoice__dlm ' +
            'WHERE unified_patient_id__c = \'' +
            String.escapeSingleQuotes(requests[0].unifiedPatientId) + '\' ' +
            'AND status__c = \'Open\' ORDER BY due_date__c';

        ConnectApi.CdpQueryOutputV2 result =
            ConnectApi.CdpQuery.queryAnsiSqlV2(input);

        Integer numCol = result.metadata.get('invoice_number__c').placeInOrder;
        Integer amtCol = result.metadata.get('amount_due__c').placeInOrder;
        Integer dueCol = result.metadata.get('due_date__c').placeInOrder;
        Integer stsCol = result.metadata.get('claim_status__c').placeInOrder;

        List<String> lines = new List<String>();
        for (ConnectApi.CdpQueryV2Row row : result.data) {
            lines.add('Invoice ' + row.rowData[numCol] +
                ': amount due ' + row.rowData[amtCol] +
                ', due ' + row.rowData[dueCol] +
                ', claim status: ' + row.rowData[stsCol]);
        }

        Response res = new Response();
        res.Prompt = lines.isEmpty()
            ? 'No open invoices for this patient.'
            : String.join(lines, '\n');
        return new List<Response>{ res };
    }
}

The Einstein Trust Layer sits in the middle of every resolution: sensitive fields can be masked before the prompt reaches the model, and grounding data never becomes training data. Note what the Apex controls — exactly which fields, in exactly what phrasing, reach the model. That precision is why the Apex-provider route is worth the extra class in regulated industries.

Build step 3 — an Apex action for the awkward questions

Some questions don't fit a precomputed graph — "how much have I paid toward my deductible this year?" needs an aggregate with a date filter. For those, CarePoint gives the agent a custom action: an @InvocableMethod that queries Data 360 directly and returns a typed answer.

public with sharing class PatientPaymentsAction {

    public class Request {
        @InvocableVariable(required=true label='Unified patient Id')
        public String unifiedPatientId;
    }

    public class Response {
        @InvocableVariable(label='Payments summary')
        public String summary;
    }

    @InvocableMethod(
        label='Get Patient Payments This Year'
        description='Sums this year\'s payments for a unified patient profile from Data 360.')
    public static List<Response> run(List<Request> requests) {
        List<Response> out = new List<Response>();
        for (Request req : requests) {
            ConnectApi.CdpQueryInput input = new ConnectApi.CdpQueryInput();
            input.sql =
                'SELECT SUM(amount__c) AS paid_ytd__c ' +
                'FROM Payment__dlm ' +
                'WHERE unified_patient_id__c = \'' +
                String.escapeSingleQuotes(req.unifiedPatientId) + '\' ' +
                'AND payment_date__c >= DATE_TRUNC(\'year\', CURRENT_DATE)';

            ConnectApi.CdpQueryOutputV2 result =
                ConnectApi.CdpQuery.queryAnsiSqlV2(input);

            Response res = new Response();
            if (result.data.isEmpty()) {
                res.summary = 'No payments found this year.';
            } else {
                Integer col = result.metadata.get('paid_ytd__c').placeInOrder;
                Object paid = result.data[0].rowData[col];
                res.summary = 'Payments this calendar year: ' +
                    (paid == null ? '0' : String.valueOf(paid));
            }
            out.add(res);
        }
        return out;
    }
}

Attach it to the billing topic in Agentforce Builder like any invocable action. The agent decides when to call it; your Apex decides what it's allowed to know. If the action should ever write data back, add the validation-and-confirmation guardrails from our governed agent actions build — read paths and write paths deserve different levels of paranoia.

Healthcare-grade caution: the agent runs as a user, and Data 360 permissions apply. Grant its integration user access to billing DMOs only — not clinical ones. The safest data in a prompt is data that was never groundable in the first place.

The conversation, end to end

A patient types: "I got an invoice for $180 but I thought insurance covered my visit." What happens:

  1. The agent classifies the request into the billing topic.
  2. The prompt template resolves the data graph for this patient: invoice 4417, $180, claim status "processed — patient responsibility: deductible".
  3. The model answers from those facts: the claim was processed, $180 applied to the deductible, and here's the payment link.
  4. The patient asks "how much of my deductible have I met?" — the agent calls PatientPaymentsAction and answers with the live number.

Every fact in that exchange came from the unified profile. The same conversation grounded only on CRM would have ended at "I'll create a case for our billing team" — which is exactly the call volume CarePoint was trying to deflect. For the same pattern in an employee-facing channel, see our Agentforce-in-Slack build, which grounds on live CRM through Data 360.

What you learned

  • Structured grounding (this customer's facts) and unstructured RAG (policies, articles) are different problems with different tools — use both, deliberately.
  • Data graphs precompute a unified profile into one fast-reading document — the default for in-conversation grounding.
  • Prompt templates ground on data graph fields with Trust Layer controls; instructions enforce "answer only from facts".
  • Custom @InvocableMethod actions with ConnectApi.CdpQuery handle the questions no precomputed shape covers.
  • Scope grounding like a security review: smallest graph, least-privileged agent user.

Next in the series: reacting to Data 360 changes from Apex and Flow — when the data should come to you instead. The full series lives on the Data 360 track page.

Sources: Salesforce Help — Data Graphs · Grounding with Data Graphs · Salesforce Developers — Ground a Prompt with Data 360 (workshop)

React to Data 360 Changes from Apex and Flow

The scenario

Northbay Goods is an e-commerce retailer. Following the pattern from Part 2, their data team built a churn-risk Calculated Insight in Data 360: order frequency, days since last session, return rate and support sentiment roll up into a 0–100 score per unified customer, refreshed nightly.

The score works. The problem is what happens next: nothing. The list sits in a dashboard someone checks on Fridays. By the time a human notices a VIP customer crossed the line, the win-back window has closed.

The retention team's requirement: "When a customer's score crosses 80, open a Case for the retention queue that day. One Case — not one per nightly refresh." That's an integration problem, and it's the part of Data 360 that belongs squarely to platform developers: getting changes in the lakehouse to trigger automation in the org.

Three ways out of the lakehouse

Data 360 has three mechanisms for pushing changes toward org automation. Pick by who maintains it and how much logic sits behind the trigger:

MechanismWhat it doesReach for it when
Data action → platform eventA rule on a DMO or Calculated Insight publishes a custom platform event when conditions matchYou want Apex — bulkified logic, dedupe guards, real unit tests
Data 360–triggered flowA flow launches when records in a DMO or CI change (labeled Data Cloud-Triggered Flow in Flow Builder)Admin-maintained, declarative-first orgs; simpler follow-on actions
Standard change eventDataObjectDataChgEvent notifies subscribers of data changes in Data 360Generic listeners and integration middleware watching broadly

Northbay wants a dedupe guard ("one open Case per customer") and a test suite the release pipeline can run. That's Apex logic, so the main build is path one. We'll cover the flow variant at the end — same trigger, different consumer.

Step 1 — define the platform event

In Setup → Platform Events, create Churn_Risk_Flagged__e with three custom fields:

  • Contact_Email__c (Text) — the key to find the CRM-side customer.
  • Churn_Score__c (Number) — the score that tripped the rule.
  • Unified_Customer_Id__c (Text) — the Data 360 profile ID, kept for traceability.

Design the event like an API contract, because it is one: Data 360 is the producer, your org is the consumer, and every field is something the consumer would otherwise have to query back for.

Step 2 — the data action

In Data 360:

  1. Data Action Targets → New → type Salesforce Platform Event → pick Churn_Risk_Flagged__e.
  2. Data Actions → New → source: the churn-risk Calculated Insight.
  3. Condition: churn_score__c >= 80. Map the CI's dimensions and measures onto the event fields.

From now on, every nightly CI refresh evaluates the rule and publishes one event per newly matching customer. Note the semantics: data actions fire on changes in the result, so a customer sitting at 85 for a week doesn't re-fire every night — but score oscillation around the threshold (79, 81, 79, 82…) can. That's one reason the subscriber still needs its own dedupe guard.

Step 3 — the Apex subscriber

A platform event trigger, thin as always, handing off to a service class:

trigger ChurnRiskFlagged on Churn_Risk_Flagged__e (after insert) {
    ChurnRiskCaseService.createRetentionCases(Trigger.new);
}

The service does the real work: resolve the contact, enforce "one open Case per customer", insert in bulk.

public with sharing class ChurnRiskCaseService {

    public static void createRetentionCases(List<Churn_Risk_Flagged__e> events) {
        Set<String> emails = new Set<String>();
        for (Churn_Risk_Flagged__e evt : events) {
            if (String.isNotBlank(evt.Contact_Email__c)) {
                emails.add(evt.Contact_Email__c.toLowerCase());
            }
        }
        if (emails.isEmpty()) { return; }

        Map<String, Id> contactByEmail = new Map<String, Id>();
        for (Contact c : [
            SELECT Id, Email FROM Contact WHERE Email IN :emails
        ]) {
            contactByEmail.put(c.Email.toLowerCase(), c.Id);
        }

        // Dedupe guard: customers who already have an open retention case
        Set<Id> alreadyFlagged = new Set<Id>();
        for (Case existing : [
            SELECT ContactId FROM Case
            WHERE ContactId IN :contactByEmail.values()
              AND Subject LIKE 'Churn risk:%'
              AND IsClosed = false
        ]) {
            alreadyFlagged.add(existing.ContactId);
        }

        List<Case> retentionCases = new List<Case>();
        for (Churn_Risk_Flagged__e evt : events) {
            Id contactId = String.isBlank(evt.Contact_Email__c)
                ? null
                : contactByEmail.get(evt.Contact_Email__c.toLowerCase());
            if (contactId == null || alreadyFlagged.contains(contactId)) {
                continue;
            }
            alreadyFlagged.add(contactId); // dedupe within this batch too
            retentionCases.add(new Case(
                ContactId   = contactId,
                Subject     = 'Churn risk: score ' + evt.Churn_Score__c.intValue(),
                Description = 'Flagged by the Data 360 churn-risk Calculated Insight. ' +
                              'Unified profile: ' + evt.Unified_Customer_Id__c,
                Priority    = 'High'
            ));
        }
        if (!retentionCases.isEmpty()) {
            insert retentionCases;
        }
    }
}

Route the Cases with standard assignment rules on the subject prefix, or set an OwnerId to the retention queue directly. If a downstream agent or automation should act on these Cases and write back, put it behind the confirmation guardrails from our governed agent actions build.

Platform event triggers have their own rules: they run as the Automated Process user (set Case ownership deliberately), retries replay the whole batch (keep the handler idempotent — the dedupe guard does double duty here), and there's no after undelete to worry about. Idempotency isn't polish; it's the difference between one Case and five.

Step 4 — prove it with a test

Test.stopTest() delivers published platform events, so the whole path is unit-testable:

@IsTest
private class ChurnRiskCaseServiceTest {

    @IsTest
    static void createsOneCaseAndDedupes() {
        Contact maya = new Contact(LastName = 'Iyer', Email = 'maya@example.com');
        insert maya;

        Churn_Risk_Flagged__e evt = new Churn_Risk_Flagged__e(
            Contact_Email__c       = 'maya@example.com',
            Churn_Score__c         = 91,
            Unified_Customer_Id__c = 'UI-000482'
        );

        Test.startTest();
        EventBus.publish(evt);
        EventBus.publish(evt); // same customer flagged twice
        Test.stopTest();

        System.assertEquals(1,
            [SELECT COUNT() FROM Case WHERE ContactId = :maya.Id],
            'Duplicate events must not create duplicate cases');
    }
}

That second publish is the whole point of the test: it encodes the retention team's "one Case, not one per refresh" requirement as an assertion the CI pipeline enforces forever.

The declarative variant: Data 360–triggered flow

If Northbay's admins owned this requirement instead, the same trigger point works without the event or the Apex: create a Data 360–triggered flow, point it at the churn-risk CI object, and set the entry condition to churn_score__c >= 80. The flow then runs Get Records on Contact by email and creates the Case — with a Get Records + Decision pair standing in for the dedupe query.

When to prefer which:

  • Flow: the logic fits in a handful of elements, admins maintain it, and per-record processing is acceptable.
  • Apex via platform event: you need bulk-safe queries, cross-object dedupe, unit tests, or the event has multiple consumers (Case creation today, a Slack alert tomorrow — same event, another subscriber, zero changes in Data 360).

That last point is the quiet architectural win of the event route: the platform event decouples Data 360 from however many things the org decides to do about churn next year.

What you learned

  • Insight without automation is a dashboard nobody reads in time — data actions are how Data 360 initiates action.
  • Data actions publish custom platform events when DMO or Calculated Insight conditions match; design the event like an API contract.
  • Apex subscribers give you bulkification, dedupe guards and real tests; Test.stopTest() delivers events in unit tests.
  • Platform event triggers run as the Automated Process user and may replay batches — build idempotent handlers.
  • Data 360–triggered flows cover the declarative cases; the platform event wins when logic or consumer count grows.

Next in the series: Data 360 with Tableau and CRM Analytics — the same unified data, now for the executives. The full series lives on the Data 360 track page.

Sources: Salesforce Help — Data Actions in Data 360 · Platform Events Developer Guide — DataObjectDataChgEvent · Salesforce Developers — Data Cloud-Triggered Flows and Invocable Actions

Data 360 Meets Tableau and CRM Analytics: The Exec Dashboard

The scenario

Vantage Freight is a logistics company. Shipment tracking events live in a data lake; customer accounts, quotes and complaints live in Salesforce. Over the last quarter they built the Data 360 foundation from Part 1: shipment events ingested as an engagement DMO, customers resolved into unified profiles, and an on-time delivery Calculated Insight per account.

Then the COO asked the question that funds data projects: "One dashboard. On-time rate, margin per lane, and which of our top-50 accounts are getting bad service — weekly, on my screen, not in a spreadsheet."

The data is ready. The remaining decision is the tool. Vantage has Tableau licenses in operations and CRM Analytics bundled with their Salesforce licenses — and the honest answer, as usual, is "it depends on who's looking."

The metric first: one CI, every tool

Resist building metrics inside the dashboard tool. Vantage defines on-time performance once, as a Calculated Insight (the discipline from Part 2), so Tableau, CRM Analytics and any future agent all read the same number:

SELECT
    s.account_id__c AS account_id__c,
    COUNT(s.shipment_id__c) AS total_shipments__c,
    SUM(CASE
        WHEN s.delivered_on__c <= s.promised_on__c THEN 1
        ELSE 0
    END) AS on_time_shipments__c,
    AVG(s.margin_pct__c) AS avg_margin_pct__c
FROM Shipment__dlm s
WHERE s.delivered_on__c IS NOT NULL
GROUP BY s.account_id__c

If the on-time rate lived as a Tableau calculated field, the ops team's number and the exec dashboard's number would drift within a quarter. A CI is the single source of "what does on-time mean here" — the dashboards become presentation layers, which is all they should be.

Path 1 — Tableau, live on Data 360

Tableau ships a native Data 360 connector (you'll still see it labeled Salesforce Data Cloud in the connect pane). It queries the lakehouse live — no extracts to schedule, no copies to reconcile — and it's backed by accelerated queries, a Data 360 service that speeds up live connections from Tableau.

  1. In Tableau, choose the connector from the connect pane and sign in with OAuth (Tableau Server needs the connected-app OAuth setup done once by an admin).
  2. Pick the data space, then drag in the shipment DMO, the unified account DMO and the on-time CI object — they join on the account dimension like ordinary tables.
  3. Build the three views the COO asked for: on-time trend, margin by lane, and a top-50-accounts table sorted by service score. Labels come through as object labels, not API names, so the ops analyst never sees __dlm.

Choose Tableau when the audience lives outside Salesforce (ops teams, execs who want a link or a TV screen), analysts already speak Tableau, or the visual requirements go beyond standard dashboard widgets.

Path 2 — CRM Analytics, inside the CRM

CRM Analytics reads Data 360 objects through its Salesforce-native connectors, and its dashboards embed directly on Lightning record pages. Vantage uses it for the second audience nobody mentioned in the exec meeting: account executives.

  • Sync the on-time CI and shipment DMO into a CRM Analytics dataset alongside CRM opportunity data.
  • Build one dashboard: this account's on-time trend, open complaints and shipment volume.
  • Embed it on the Account record page — the AE opens the account before a renewal call and sees the service story without leaving Salesforce.

Choose CRM Analytics when the consumer is a CRM user in record context, you want row-level security inherited from Salesforce sharing, or actionability matters — CRM Analytics widgets can launch actions on the records they chart.

The lazy third option is sometimes right. If all an exec needs is one number per account, skip the analytics stack: copy the CI value onto the Account with a data action and a flow (the wiring from Part 4) and use a standard Lightning report. Don't stand up a BI project to display one field.

The decision table

QuestionTableauCRM AnalyticsStandard reports + copied fields
AudienceExecs, ops, anyone with a browserCRM users in record contextCRM users, simple rollups
Data accessLive connector to DMOs/CIsDatasets synced from Data 360 + CRMOnly fields copied onto CRM records
FreshnessLive at query timeAs of last dataset syncAs of last data action / flow run
Embeds in LightningYes, via Lightning componentsNatively, with record contextNative
LicensingTableau licensesCRM Analytics licensesIncluded
Best atDeep visual analysis, broad distributionAnalytics next to the record, actionable widgetsOne number, zero new tools

Vantage shipped all three: Tableau for the COO's weekly view, CRM Analytics embedded for AEs, and an On_Time_Rate__c field on Account feeding list views. Same CI underneath all of them — that's the part that makes it maintainable.

Governance: who gets to see margin data?

The exec dashboard exposed a question Vantage hadn't asked while the data sat in the lake: margin per lane is commercially sensitive, and now it's one click from every viewer. The two paths govern differently, and it should influence your choice:

  • CRM Analytics inherits the Salesforce security model — the embedded account dashboard shows an AE only accounts they can already see, with row-level security enforced on the dataset. If the answer to "who can see this?" is "whoever can see the record," CRM Analytics gets you there with the least custom work.
  • Tableau authenticates each viewer's live connection against Data 360 permissions, and workbook-level access is managed on the Tableau side. Vantage publishes the COO workbook to a restricted project and keeps margin fields out of the ops team's data source entirely.
  • Either way, scope at the source. Data spaces partition what a connection can reach at all — the cleanest control is data the connector never sees. Decide field-level sensitivity when you design the CI, not after the first screenshot circulates.

Watch the meter

Live connections are convenient and metered. Data 360 queries consume credits, and a popular Tableau dashboard on auto-refresh is a query generator. Three habits keep the bill boring:

  • Aggregate in the CI, not the dashboard. The on-time CI reduces millions of shipment rows to one row per account before any dashboard touches it. Dashboards over raw engagement DMOs are the classic credit burner.
  • Match refresh to reality. The CI publishes nightly — a dashboard refreshing every five minutes recomputes the same answer 288 times.
  • Check consumption early. Review Data 360 usage cards in the first weeks after launch, not at renewal time.

What you learned

  • Define metrics once as Calculated Insights; treat every dashboard as a presentation layer on the same number.
  • Tableau's native connector queries Data 360 live with accelerated queries — best for broad, visual, outside-the-CRM audiences.
  • CRM Analytics brings unified data into record context with Salesforce-native security and embedding.
  • Copying a CI value onto a CRM field and using a standard report is a legitimate architecture, not a hack.
  • Analytics on consumption-priced data needs the same cost discipline as API design.

That closes the series. Start from the beginning on the Data 360 track page, or jump to Part 1: fundamentals — and if your next step is putting this data in front of an agent instead of an exec, that's Part 3.

Sources: Salesforce Help — Connect Tableau in Data 360 · Tableau Help — Salesforce Data 360 connector · Activation and Activation Targets

Zero Copy in Data 360: Query Snowflake Without Moving a Byte

The scenario

Hartwell Stores runs 340 retail locations. Every till transaction for the last seven years — about six terabytes — lives in a Snowflake warehouse the data team has spent three years curating. Now the personalization program needs store purchase history inside Data 360, joined to the unified profiles built in the fundamentals series.

The first proposal was the reflexive one: a nightly export pipeline copying Snowflake tables into Data 360. The data team pushed back hard, and they were right. A copy means double storage cost, a second source of truth that drifts from the first, up to 24 hours of staleness, and a pipeline someone has to own forever. The warehouse already answers these queries — the problem isn't the data, it's where it's queryable from.

This is the problem zero copy data federation exists to solve, and it's the most misunderstood feature in Data 360. This post assumes you know the DLO/DMO pipeline from Part 1 — here we go deep on the federation layer.

What zero copy actually is

When you federate a Snowflake table, Data 360 creates an external data lake object — a DLO that contains only metadata: the schema, the connection, and where the real rows live. No rows move. When a query touches that external DLO, Data 360 pushes the work down to the source — query federation — and stitches the results into the rest of the query.

The umbrella term covers three distinct capabilities, and mixing them up derails design conversations:

CapabilityDirectionWhat moves
Query federationWarehouse → Data 360Nothing at rest; query results at query time
File federationWarehouse → Data 360Nothing; Data 360 reads the underlying files (e.g. Iceberg) directly
Data share outData 360 → warehouseNothing; the warehouse queries Data 360 objects as shared tables

Hartwell needs the first one. The same pattern works across the supported platforms — Snowflake, Google BigQuery and Databricks all expose their tables to Data 360 as external DLOs, with federated authentication supported for all three.

Worth internalizing: an external DLO behaves like any other DLO downstream. You map it to DMOs, join it in Calculated Insights, and reference it in queries — the fact that its rows live in Snowflake is invisible to consumers. That's the entire point.

Federate or ingest? The real decision

Zero copy is not automatically the right answer — it changes where compute happens and when you pay for it. The decision that held up at Hartwell:

FactorFederate (zero copy)Ingest (copy in)
FreshnessAs fresh as the source, at query timeAs fresh as the last stream refresh
Cost profileSource compute (e.g. Snowflake warehouse) burns on every federated queryIngestion and storage credits once; queries hit local data
Hot-path use (segments, frequent CIs)Risky — every refresh re-queries the warehouseDesigned for it
Huge, rarely-touched historyIdeal — pay only when you actually askWasteful — terabytes stored to answer occasional questions
OwnershipWarehouse team keeps one source of truthTwo copies, two owners, drift risk

Hartwell's split: the seven-year transaction history stays federated (occasionally scanned, painful to copy), while a slim, pre-aggregated monthly spend per loyalty member table is ingested normally, because it feeds identity-adjacent attributes and segment refreshes daily. Federate the archive, ingest the hot path — that hybrid is the pattern most real implementations land on.

Setting up the Snowflake connection

The setup is deliberately anticlimactic — that's the feature. In Data 360 Setup:

  1. Create the connection. Add a Snowflake connection with the account URL, a dedicated warehouse, and a role scoped to exactly the schemas Data 360 may see. Hartwell created a DATA360_READER role in Snowflake first — least privilege lives on the warehouse side.
  2. Create the data stream. New data stream → Snowflake connector → pick the connection, database, schema, and the STORE_SALES table. Instead of scheduling a refresh that copies rows, this materializes an external DLO.
  3. Map it. Map the external DLO's fields to DMOs exactly as you would any DLO — Hartwell maps LOYALTY_ID to the same key their ingested profile data uses, which is what makes the join to unified profiles possible.

BigQuery and Databricks follow the same three steps with their own connectors; the external DLO you end up with is indistinguishable downstream.

Give federation its own warehouse. In Snowflake, point the connection at a dedicated (extra-small, auto-suspending) warehouse. Two reasons: Data 360 queries can never starve the data team's workloads, and the warehouse's own billing view becomes your federation cost meter — you'll want that number in the first month.

The payoff: one query across both worlds

With Snowflake_Store_Sales__dlm mapped, warehouse history joins Data 360 objects in ordinary ANSI SQL. Hartwell's first production Calculated Insight — in-store spend per unified customer over 90 days:

SELECT
    p.customer_id__c AS customer_id__c,
    SUM(s.net_amount__c) AS store_spend_90d__c,
    COUNT(DISTINCT s.store_id__c) AS stores_visited__c
FROM Snowflake_Store_Sales__dlm s
JOIN Loyalty_Profile__dlm p
    ON p.loyalty_id__c = s.loyalty_id__c
WHERE s.sale_date__c >= CURRENT_DATE - INTERVAL '90' DAY
GROUP BY p.customer_id__c

What executes where: the scan and filter on Snowflake_Store_Sales__dlm are pushed down to Snowflake — six terabytes are reduced to a 90-day aggregate before anything crosses the wire — and Data 360 joins that slim result to the local profile data. The narrower your filters and projections, the less the warehouse does and the faster (and cheaper) the query returns.

That CI now feeds a "high in-store, low online" segment for the e-commerce team — unified profile attributes and warehouse history in one definition, with the SQL discipline from the Calculated Insights build applying unchanged.

The bill has two line items now

Federation's failure mode isn't technical — it's a surprise invoice. Every federated query costs twice: Data 360 consumption credits and source-side compute. Three rules keep Hartwell's setup boring:

  • Never put an external DLO in a fast refresh loop. A segment refreshing every hour against federated data is a warehouse-compute subscription you didn't mean to buy. Aggregate federated data into a scheduled CI once a day; let segments and dashboards read the CI.
  • Push aggregation down, always. SELECT * from a federated DLO drags raw rows across the boundary. Filter and aggregate in the query so the warehouse ships summaries, not history.
  • Watch both meters for the first month. Data 360's consumption cards and the dedicated warehouse's billing, side by side — the same discipline as the analytics build, with a second bill attached.
Latency is real too. A federated query is a network hop plus warehouse execution — fine for CIs, dashboards and ad-hoc analysis; wrong for anything conversational. If an agent needs this data mid-call, precompute it into a data graph — that's the next post in this advanced series.

What you learned

  • Zero copy = external DLOs holding metadata only, with query federation pushing work down to Snowflake, BigQuery or Databricks at query time.
  • Query federation, file federation and data share out are three different capabilities — be precise about which one you're proposing.
  • Federate the huge, rarely-scanned archive; ingest the hot path that feeds segments and identity — hybrid is the normal answer.
  • Filters and aggregation belong in the query so reduction happens warehouse-side; never fast-refresh against federated data.
  • Budget both meters: Data 360 credits and source compute.

Next in the advanced series: data graphs — when the answer has to come back in milliseconds, not warehouse time. The full track lives on the Data 360 page.

Sources: Salesforce Help — Zero Copy Data Federation · Salesforce Developers — Zero Copy Data Federation with Snowflake · Data 360 Integration Guide — Databricks Connectors

Data Graphs: A Millisecond Customer 360 at Runtime

The scenario

Arcline Mobile — the telco from our Calculated Insights build — put an Agentforce agent on live support calls. The agent needs the caller's full picture the moment the call connects: current plan, open cases, the last five interactions, the churn-risk score, and this month's usage against quota.

The first implementation queried Data 360 with ANSI SQL per call — five DMOs, three joins, one CI. It worked in the demo. In production it added two to four seconds before the agent's first useful turn, and every call burned query credits recomputing joins whose answers hadn't changed since the previous call. Multiply by 40,000 calls a day.

We introduced data graphs briefly in the Agentforce grounding build. This post is the deep version: how to design one properly, what the materialized document actually looks like, and how to read it from REST and Apex at conversation speed.

The core trade: compute once, read many

A data graph inverts the query model. Instead of assembling the profile at read time, Data 360 assembles it at write time: it denormalizes a primary DMO and its related objects into a single materialized JSON document, and keeps that document updated as the underlying data changes. Reads become key lookups — no joins, no query planning, near-real-time responses.

The trade-offs are the classic materialized-view ones, and they discipline your design:

  • You pay storage and refresh compute for every profile, including the millions nobody will call about today.
  • The document answers exactly the shape you designed — new questions mean changing the graph, not the query.
  • Freshness is "near real time" by default; for moments where that's not enough, the lookup API has a live-read escape hatch (below).

For a support call, that trade is exactly right: the questions are known in advance, and latency is the whole game.

Designing Arcline's graph

In Data 360 → Data Graphs → New, design is three decisions:

  1. Primary object: Unified Individual — the graph produces one document per unified customer.
  2. Related objects: Subscription__dlm (plan, contract end), the Case DMO filtered to open cases, Interaction__dlm limited to recent records, and the churn-risk CI object. Related objects hang off the primary via the model's relationships.
  3. Fields: ruthlessly few. Plan name, contract end date, case subjects and statuses, interaction channel and timestamp, one churn score. Every field you add is paid for in every document, every refresh.

The materialized document the agent's action receives looks like this (trimmed):

{
  "ssot__Id__c": "0aXxx0000004GHi",
  "ssot__FirstName__c": "Priya",
  "Subscription__dlm": [
    { "plan_name__c": "Unlimited Plus",
      "contract_end__c": "2027-02-28" }
  ],
  "Case__dlm": [
    { "Subject": "Billing dispute - roaming",
      "Status": "Open" }
  ],
  "Churn_Risk_Insight__cio": [
    { "churn_score__c": 74 }
  ]
}
Keep aggregates in CIs, lists in the graph. The graph should carry the churn score, not the 90 days of usage rows that produce it. Child lists are the fastest way to blow up document size — cap them (open cases, last five interactions) and let Calculated Insights compress history into numbers, the split we've used since Part 2.

Reading it at runtime: the lookup API

Data graphs have a dedicated lookup endpoint — a key-value read, not a query. Fetch a customer's document by any participating DMO's key:

GET /api/v1/dataGraph/Arcline_Customer360
    ?lookupKeys=[UnifiedIndividual__dlm.ssot__Id__c=0aXxx0000004GHi]

Three details that matter in production:

  • lookupKeys takes DMO-qualified keys — you can look up by the unified ID, or by a source key such as a phone number mapped into the graph, which is exactly what a voice channel has at call start.
  • &live=true forces a live read of the underlying data instead of the materialized document — fresher, slower, costlier. Arcline uses it in exactly one place: immediately after a mid-call plan change, where reading a stale document back to the customer would be worse than a pause.
  • The response envelope is { "data": [ … ], "done": true } — your parser should treat data as an array even when you expect one profile.

Calling it from Apex

Arcline's agent action wraps the lookup in a service class behind a Named Credential (Data360_API), so auth stays out of code:

public with sharing class CustomerGraphService {

    public class GraphResult {
        public String planName;
        public Integer churnScore;
        public List<String> openCaseSubjects = new List<String>();
    }

    public static GraphResult fetch(String unifiedId) {
        HttpRequest req = new HttpRequest();
        req.setMethod('GET');
        req.setEndpoint('callout:Data360_API/api/v1/dataGraph/Arcline_Customer360' +
            '?lookupKeys=[UnifiedIndividual__dlm.ssot__Id__c=' +
            EncodingUtil.urlEncode(unifiedId, 'UTF-8') + ']');
        req.setTimeout(5000);

        HttpResponse res = new Http().send(req);
        if (res.getStatusCode() != 200) {
            throw new CalloutException('Graph lookup failed: ' + res.getStatus());
        }

        Map<String, Object> body =
            (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
        List<Object> data = (List<Object>) body.get('data');

        GraphResult result = new GraphResult();
        if (data == null || data.isEmpty()) { return result; }
        Map<String, Object> profile = (Map<String, Object>) data[0];

        for (Object sub : listOf(profile.get('Subscription__dlm'))) {
            result.planName = String.valueOf(
                ((Map<String, Object>) sub).get('plan_name__c'));
        }
        for (Object c : listOf(profile.get('Case__dlm'))) {
            result.openCaseSubjects.add(String.valueOf(
                ((Map<String, Object>) c).get('Subject')));
        }
        for (Object ci : listOf(profile.get('Churn_Risk_Insight__cio'))) {
            Object score = ((Map<String, Object>) ci).get('churn_score__c');
            result.churnScore = score == null
                ? null : Integer.valueOf(String.valueOf(score));
        }
        return result;
    }

    private static List<Object> listOf(Object node) {
        return node == null ? new List<Object>() : (List<Object>) node;
    }
}

Wrap fetch in an @InvocableMethod using the action pattern from the grounding build and the agent gets its whole opening context in one sub-second call — one document read instead of the four-join SQL that started this story.

Freshness, sizing and the real-time layer

  • Refresh behavior: the graph updates as underlying DMOs change — near real time for ordinary ingestion. What it can't outrun is your pipeline: if usage data lands nightly, the graph is nightly-fresh no matter how fast the lookup is.
  • Sub-second end to end: when the underlying events also need to arrive in milliseconds — a web session feeding a live conversation — that's the real-time layer with real-time data graphs, covered in the streaming post of this series.
  • Sizing: watch related-object counts and depth. A graph is a document store in disguise; treat "how big is one document" as a design review question, and re-check after adding any child list.
  • One graph per consumer shape: Arcline runs a slim service graph (this build) and a separate marketing graph with engagement summaries — two small documents beat one bloated universal profile.

What you learned

  • Data graphs move join cost from read time to write time — the right shape for known questions at conversation latency.
  • Design is primary object + few related objects + ruthless field selection; aggregates live in CIs, capped lists in the graph.
  • The lookup API reads by DMO-qualified keys; live=true is a deliberate, costed choice for must-be-fresh moments.
  • A Named Credential + JSON parse is all the Apex you need to put a full customer 360 behind an agent action.
  • Graph freshness is bounded by ingestion freshness — sub-second scenarios need the real-time layer.

Next in the advanced series: advanced identity resolution — the graph is only as good as the unified profile underneath it. The full track lives on the Data 360 page.

Sources: Salesforce Help — Data Graphs · Data 360 Query Guide — Get Data Graph Data with Lookup Keys · Data 360 Query Guide — Query Data Graph Data

Advanced Identity Resolution: One Traveler, Three Identities, Messy Data

The scenario

Altavia Airways has three pictures of every traveler: a 25-million-member loyalty program, booking records arriving from global distribution systems, and a mobile app with its own account store. The fundamentals build covered wiring sources like these into Data 360 and running a first ruleset. Altavia did exactly that — and the results were quietly wrong in both directions.

Under-merged: Robert Keller in loyalty, Bob Keller in bookings and R. Keller in the app stayed three profiles, so the agent greeting a Gold member had no idea about his complaint from last week's flight. Over-merged: a travel agency books hundreds of passengers with the agency's own email on every record — exact email matching fused hundreds of strangers into one monster profile. And a family sharing one household email got merged into a single "traveler" with two frequent-flyer numbers.

Airline data is the identity-resolution stress test: name variants across systems, third-party contact points, shared households. This post is about tuning past the defaults — assuming you already know what rulesets, match rules and reconciliation rules are from Part 1.

First: stop feeding the matcher poisoned keys

Altavia's worst problem — the agency-email mega-profile — is not a match-rule problem. No rule tuning survives a key that genuinely is shared by hundreds of people. The fix belongs upstream, before identity resolution ever sees the data:

  • Classify contact points at ingestion. Bookings carry a channel code that distinguishes direct bookings from agency bookings. Altavia added a formula field on the stream marking agency-channel emails, and mapped only direct-booking emails to Contact Point Email. Agency emails still exist on the booking DMO for operations — they just no longer participate in matching.
  • Normalize before you match. Trim, lowercase, strip formatting at the mapping stage where the source is sloppy. The match engine has normalized methods (below), but garbage keys with real-looking shapes — noemail@test.com, na@na.com — need an explicit exclusion list at ingestion.
Rule of thumb: if a value can legitimately belong to many people (agency emails, call-center phone numbers, shared devices), it must not be a match key. Identity resolution can weigh evidence; it cannot fix evidence that lies.

Fuzzy matching: what the precision levels actually do

With poisoned keys excluded, Altavia turned to under-merging. Match rule criteria support match methods beyond exact: normalized variants (email, phone) and fuzzy methods backed by an AI language model trained on names at global scale — it catches misspellings, diacritics ("José"/"Jose") and nickname synonyms ("Bob"/"Robert"), which is precisely the Keller problem.

Fuzzy methods come in three precision levels, and the level is a risk dial, not a quality dial:

PrecisionBehaviorUse when
HighConservative — close variants and well-known nicknames onlyThe other criteria in the rule are weak (e.g. only a city in common)
MediumBalanced variant coveragePaired with a strong exact key — Altavia's default
LowAggressive — distant variants matchRarely; only alongside multiple strong exact criteria

The design principle: every rule needs an anchor. Fuzzy criteria widen a match; an exact criterion (normalized phone, loyalty number, passport-derived key) justifies it. Fuzzy-on-fuzzy rules are how strangers get merged.

Altavia's ruleset, rule by rule

Match rules OR together — a profile pair unifies if any rule's criteria all pass. Altavia ships four:

Rule 1 — Loyalty anchor (exact)
  Loyalty_Number__c        : Exact

Rule 2 — Contact-point anchor
  Email (Contact Point)    : Exact Normalized
  First Name               : Fuzzy — High precision
  Last Name                : Exact

Rule 3 — Phone anchor
  Phone (Contact Point)    : Exact Normalized
  First Name               : Fuzzy — Medium precision
  Last Name                : Exact

Rule 4 — Booking identity
  Frequent_Flyer_Ref__c    : Exact
  Birth_Date__c            : Exact

Note what Rule 2 does to the household problem: a shared family email alone no longer merges — the fuzzy-high first name plus exact last name means Priya and Arjun Mehta at the same address stay two travelers, while Preya and Priya Mehta collapse into one. That single precision choice was the difference between the program working and support calls about "my husband's boarding pass is on my app."

When teams genuinely disagree on risk tolerance — service wants conservative merging, marketing wants coverage — Data 360 supports multiple rulesets, each producing its own unified profile set. Altavia decided against it: two universes of unified IDs means every downstream consumer (graphs, segments, agents) must pick one, and the operational overhead outweighed the flexibility. Reach for a second ruleset only when two consumers truly cannot share one definition of "same person."

Reconciliation: who wins each field

Matching decides which profiles unify; reconciliation rules decide what the unified profile says when sources disagree. The available policies — source priority, last updated, most frequent — are chosen per field, and treating that as one global decision is the second most common tuning mistake. Altavia's grid:

FieldPolicyWhy
Legal nameSource priority: loyalty → booking → appLoyalty is KYC-verified; app names are whatever the user typed
Mobile phoneLast updatedPhones change; the newest claim is usually right
EmailMost frequentThe address appearing across the most sources is the one the traveler actually uses
Home airportSource priority: app → loyaltyThe app value is user-chosen; loyalty's is inferred from enrollment years ago

Write the "why" column down somewhere real. Six months later, when marketing asks why the unified email isn't the one from last week's booking, the answer should be a design decision you can point to — not archaeology.

Measure, then loosen — never the reverse

Tuning without measurement is guessing. Two instruments matter:

  • The ruleset's processing results report the consolidation rate — how much source profiles shrank into unified ones. Track it per run: a sudden jump after a rule change means a rule started over-matching; barely above zero means your keys never overlap and the rules aren't the bottleneck — the data is.
  • A spot-check query against the unified link DMO, reviewing the biggest unified profiles first — that's where over-merges hide:
SELECT
    l.UnifiedRecordId__c AS unified_id__c,
    COUNT(l.SourceRecordId__c) AS source_profiles__c
FROM UnifiedLinkssotIndividual__dlm l
GROUP BY l.UnifiedRecordId__c
ORDER BY source_profiles__c DESC
LIMIT 50

A traveler with five source profiles is plausible. A "traveler" with three hundred is an agency inbox you missed. (Check the exact link-object name in your org — it carries your ruleset's suffix, e.g. UnifiedLinkssotIndividualIr1__dlm.) Altavia reviews this list after every ruleset change, before anything downstream consumes the new profiles.

Over-merge is the expensive mistake. Under-merging costs you context; over-merging shows one customer another customer's data — in an agent conversation, that's an incident (see the healthcare cautions in the grounding build). And unwinding it is slow: fix the rule, reprocess, and accept that unified IDs can change — which ripples into every data graph and activation keyed on them. Start strict. Loosen one criterion at a time. Re-measure.

What you learned

  • Shared keys (agency emails, household addresses) are an ingestion problem — exclude them from matching before tuning anything.
  • Fuzzy precision is a risk dial; anchor every fuzzy criterion to an exact key, and default to high/medium precision.
  • Match rules OR together — design each rule to be independently trustworthy.
  • Reconciliation is per-field policy design: source priority, last updated and most frequent each have a right home.
  • Track consolidation rate and audit the largest unified profiles after every change; loosen incrementally, never all at once.

Next in the advanced series: streaming and the real-time layer — because a perfect profile that arrives late is still the wrong answer. The full track lives on the Data 360 page.

Sources: Salesforce Help — Identity Resolution Match Methods · Match Rules for Individuals · Identity Resolution Reconciliation Rules

Streaming and Real-Time: The Five-Second Abandoned Cart

The scenario

Northbay Goods — the e-commerce retailer from our data actions build — has the classic conversion leak: a shopper loads a cart, reaches checkout, hesitates at the shipping cost, and leaves. The recovery email arrives the next morning; by then the shoes were bought elsewhere.

The requirement from the growth team: "When a signed-in customer abandons checkout, open a proactive chat with our agent — with the cart contents and an incentive decision — while they're still on the site." That's a seconds-scale loop: event in, profile updated, decision made, agent engaged.

Northbay's team assumed their existing "streaming" ingestion had this covered. It didn't — and understanding why is the most valuable part of this build. This post assumes the fundamentals series; the batch-side wiring (data actions → platform events → Apex) is in that post and gets reused here.

Three speeds, and the trap between them

Data 360 has three distinct freshness tiers. The names sound interchangeable. The latencies are not:

TierHow data arrivesRealistic latencyRight for
BatchScheduled connector refreshesHours to a dayHistory, finance data, most CIs
Streaming ingestionIngestion API streaming — fire-and-forget micro-batches, processed asynchronouslyMinutesOrder status, IoT telemetry, near-real-time dashboards
Real-time layerWeb SDK / Mobile SDK / server-to-server into the sub-second profileSub-second — the large majority of events land well under a second end to endIn-session personalization, live agent context

The trap: streaming ingestion accepts events instantly (a 202 Accepted in milliseconds) but processes them in micro-batches on a minutes cadence. Northbay's original design streamed cart events and expected instant reactions — the acceptance latency fooled them. For a next-morning email, minutes are fine. For "while they're still on the site," only the real-time layer closes the loop.

Capturing the events

For the storefront, the natural capture path is the Web SDK: it ships page, cart and checkout interactions from the browser into the real-time pipeline, with a schema you define per event type. Northbay tracks cart_update and checkout_step events keyed by the signed-in customer ID.

Their order-management system also participates server-side through the Ingestion API. The streaming pattern is a plain REST POST of a JSON envelope against a source and object you configured on the Ingestion API connector:

POST /api/v1/ingest/sources/Web_Store/CartEvent
Authorization: Bearer {data360_token}
Content-Type: application/json

{
  "data": [
    {
      "event_id": "ce-20260717-88412",
      "customer_id": "CUST-004471",
      "event_type": "checkout_abandoned",
      "cart_value": 182.50,
      "item_count": 3,
      "event_time": "2026-07-17T14:22:05Z"
    }
  ]
}

Production notes that save debugging time: the payload must match the connector's declared schema exactly, stay under the 200 KB limit, and a 202 means accepted, not processed — build your monitoring on data arriving in the DLO, not on HTTP status codes.

The real-time profile: where the pieces from this series meet

Sub-second data is useless if the rest of the pipeline runs overnight. The real-time layer runs fast versions of the machinery you already know:

  • Real-time identity: the web event carries the signed-in customer ID, which resolves to the same unified profile your identity resolution rules maintain — so the live session and the historical profile are one person, immediately.
  • Real-time data graphs: the sub-second profile updates a real-time data graph, so the cart state is readable through the same lookup API pattern from the data graphs post — the agent asks for the document and gets the cart the customer abandoned seconds ago.
  • Streaming insights: a windowed metric over the event stream — Northbay computes checkout reached, no purchase event within 15 minutes, cart value above 100 — the real-time sibling of the batch CIs from the Calculated Insights build.

Closing the loop: from signal to agent in seconds

The action side reuses the architecture from the data actions build, at higher speed: a data action on the streaming signal publishes a platform event, and the org reacts. Northbay's event mirrors their churn one:

trigger CartAbandoned on Cart_Abandoned__e (after insert) {
    CartRecoveryService.startRecovery(Trigger.new);
}
public with sharing class CartRecoveryService {

    public static void startRecovery(List<Cart_Abandoned__e> events) {
        List<MessagingSession> toRoute = new List<MessagingSession>();
        Set<String> customerIds = new Set<String>();
        for (Cart_Abandoned__e evt : events) {
            customerIds.add(evt.Customer_Id__c);
        }

        // Guard: one recovery attempt per customer per day
        Set<String> recentlyContacted = new Set<String>();
        for (Cart_Recovery_Attempt__c prior : [
            SELECT Customer_Id__c FROM Cart_Recovery_Attempt__c
            WHERE Customer_Id__c IN :customerIds
              AND CreatedDate = TODAY
        ]) {
            recentlyContacted.add(prior.Customer_Id__c);
        }

        List<Cart_Recovery_Attempt__c> attempts =
            new List<Cart_Recovery_Attempt__c>();
        for (Cart_Abandoned__e evt : events) {
            if (recentlyContacted.contains(evt.Customer_Id__c)) { continue; }
            recentlyContacted.add(evt.Customer_Id__c);
            attempts.add(new Cart_Recovery_Attempt__c(
                Customer_Id__c = evt.Customer_Id__c,
                Cart_Value__c  = evt.Cart_Value__c,
                Channel__c     = 'Proactive chat'
            ));
        }
        if (!attempts.isEmpty()) {
            insert attempts;
        }
        // The proactive chat itself is launched by a record-triggered
        // flow on Cart_Recovery_Attempt__c, which invokes the messaging
        // channel with the agent and passes the cart context.
    }
}

The recovery-attempt record is doing two jobs: it's the frequency-cap guard (the same idempotency discipline as the churn build — real-time signals fire more often, so the guard matters more), and it's the audit trail the growth team measures against. The agent that picks up the conversation reads the cart through the real-time data graph and applies the incentive policy — with the guardrails any write-capable agent needs (governed agent actions).

End-to-end: browser event → real-time profile → streaming signal → platform event → proactive agent chat, in single-digit seconds — against the next-morning email it replaced.

What real-time should cost you

The real-time layer is premium capacity, and the fastest tier should carry the least data. Northbay's rules:

  • Real-time only for in-session moments. Cart abandonment and live agent context qualify. Churn scoring stays nightly-batch (Part 2) — a weeks-scale signal gains nothing from millisecond delivery.
  • Slim events. The web event carries IDs, amounts and types — not product catalogs. Anything the agent needs beyond that, it reads from the graph at conversation time.
  • Cap the blast radius. The streaming signal's conditions (cart value, signed-in only, once per day) live in the insight and the Apex guard — tightening them is a config change, not a re-architecture. Facts in the stream, judgment in the thresholds: the same separation this series has used since Part 2.

What you learned

  • Batch, streaming ingestion and the real-time layer are three tiers with different latencies — streaming's instant 202 is acceptance, not processing.
  • The Web SDK feeds the sub-second profile from the browser; the Ingestion API streams server-side events as schema-matched JSON envelopes under 200 KB.
  • Real-time identity and real-time data graphs make the live session and the historical profile one readable document.
  • The action loop is the same data-action → platform-event → Apex pattern as batch, with idempotency guards promoted from good practice to hard requirement.
  • Put only genuinely conversational moments on the real-time tier; everything else is cheaper one tier down.

That completes the advanced series. The full track — five fundamentals builds and four advanced deep dives — lives on the Data 360 page.

Sources: Data 360 Integration Guide — Ingestion API · Data 360 Ingestion API Reference — Real-Time Ingestion · Streaming Ingestion Walkthrough