Tag: Batch Processing

  • Data Cloud ingestion patterns: getting external data into Salesforce without a mess

    Every Data Cloud project I’ve touched hits the same wall about six weeks in. The demo worked because someone fed it clean, pre-shaped data by hand. Then someone asks “can we pull in the real feed from the call centre platform?” and the confident answers get vague fast.

    Ingestion is where Data Cloud projects quietly succeed or fail. Not the Calculated Insights, not the segmentation, not the flashy Agentforce demo sitting on top. The pipe. Get the pipe wrong and everything downstream inherits the mess, except now it’s harder to see because it’s been through a few layers of transformation first.

    Three ways data actually gets in

    Data Cloud gives you three real paths, and most orgs end up using at least two of them whether they planned to or not.

    Batch, via connectors or the Bulk Ingestion API

    This covers the bulk of what lands in Data Cloud: CRM objects through the native Salesforce connector, Marketing Cloud data extensions, files sitting in S3 or Google Cloud Storage, data from a warehouse like Snowflake or Redshift. It’s scheduled, it’s batched, and it’s forgiving if a run is a few minutes late. Good fit for anything that doesn’t need to react to an event the moment it happens: historical case data, product catalogues, loyalty tiers, order history.

    Streaming, via the Ingestion API

    This is the one teams reach for too early. Streaming ingestion exists for data that needs to show up in near real time, things like a web click, an app event, a call just logged in the contact centre. It’s genuinely useful when a Calculated Insight or a segment needs to react within seconds. It’s overkill, and more expensive to run and maintain, when the actual business need is “update it sometime today.” I’ve seen teams build a streaming pipeline for data that gets looked at once a week.

    Native connectors for Salesforce-native sources

    If the data already lives in Sales Cloud, Service Cloud, or Marketing Cloud Engagement, the native connectors do the heavy lifting without custom code. They’re the easiest path and usually the first one worth setting up, because they prove the rest of the model works before you go anywhere near a third-party source.

    The mistake that shows up in almost every org: mapping without modelling

    Data Cloud makes it very easy to wire up a source, map some fields, and watch rows land in a Data Lake Object. That’s the trap. Mapping fields is not the same as deciding what those fields mean in your data model.

    I worked with a contact-centre team that ingested case data from two systems: Salesforce Service Cloud and a legacy ticketing tool that hadn’t been decommissioned yet. Both had a field called “status.” Both got mapped straight into Data Cloud. Nobody stopped to ask whether “Resolved” in one system meant the same thing as “Closed” in the other, or whether they should feed the same Calculated Insight at all. The segment built on top of that data looked fine in testing and was wrong in production, because it was quietly blending two different definitions of “resolved.”

    Before you map a single field, write down what the Data Model Object is actually for, who’s allowed to write to it, and what “done” looks like for the data flowing into it. That document takes an afternoon. Skipping it costs weeks later, usually right after go-live when someone notices the numbers don’t add up.

    Schema drift will happen. Plan for it before go-live, not after

    Source systems change. A field gets renamed, a picklist value gets added, an API version gets deprecated. On a standard integration, that breaks loudly and you fix it in an afternoon. In Data Cloud, a schema change upstream can quietly degrade a mapping, a Calculated Insight, or an identity resolution rule without throwing an obvious error. The pipeline keeps running. It’s just running on slightly wrong assumptions.

    Build monitoring around ingestion volume and null rates, not just success and failure flags. A batch job that “succeeds” but ingests zero rows, or ingests rows with a key field suddenly empty, needs the same attention as a failed job. Most teams only find out about a quiet schema break when someone downstream notices a number that looks off, and by then the bad data has already fed a few cycles of Calculated Insights.

    Identity resolution depends on what you ingested, not what you intended to ingest

    This one trips people up because it feels backwards. You don’t design identity resolution first and then feed it data. You design ingestion carefully, because identity resolution can only work with the identifiers you actually brought in. If the source system’s email field is inconsistently formatted, or a phone number comes through with inconsistent country codes, no amount of matching rule tuning in Data Cloud fixes that at the resolution stage. The fix belongs upstream, in how the data is cleaned on the way in.

    A practical habit: before connecting any new source, list the identifiers it carries (email, phone, loyalty ID, device ID, whatever applies) and check their format against what’s already in Data Cloud. A five-minute check here saves a painful unification exercise three months in, when unified profiles turn out to be quietly duplicating because two sources wrote the same phone number two different ways.

    A sequence that actually works

    Start with one source, ideally a native connector, and get it fully modelled: DMO defined, field meanings documented, identifiers validated. Add monitoring on that single pipeline before adding a second source. Only then bring in the next source, and check its identifiers against the first before mapping a single field.

    It’s slower than connecting everything in week one. It’s also the difference between a Data Cloud instance leadership trusts and one where every number needs a caveat attached. The second kind doesn’t get used for long, however good the dashboards look on day one.

  • Designing a Salesforce solution around one metric leadership will actually track

    Ask most delivery teams what a Salesforce enhancement is meant to achieve and you’ll get a feature description back. “We’re adding a custom object for renewals.” “We’re building an approval process for discounts over 15%.” Ask what number on a leadership dashboard is supposed to move because of it, and you’ll usually get a pause.

    That pause is the problem. A solution built without a target metric gets evaluated on whether it shipped, not whether it worked. Six months later nobody can say if the renewals object actually reduced churn, or if the approval process actually cut discount leakage, because nobody wired the build to a number in the first place.

    Why “it works” isn’t the same as “it moved something”

    I’ve sat in enough steering committees to know the difference between a project that’s technically complete and one that’s actually earning its budget. The first gets a green status and a demo. The second gets renewed funding next year because someone can point at a chart and say “that’s us.”

    Most orgs I’ve reviewed have dozens of automations, reports and custom fields that were built for a stakeholder request, went live, and then quietly stopped being anyone’s responsibility. Nobody’s watching whether they’re doing anything. The gap isn’t in testing. It sits earlier, at the requirements stage, long before a single flow gets built.

    Pick the metric before you open the object manager

    Before any real design work starts, force one question: what single number does this need to move, and who already looks at that number every week? Not a KPI you’ll invent for the project. One that already exists on someone’s dashboard, in someone’s board pack, or in someone’s head when they walk into a Monday meeting.

    For a contact-centre client I worked with, that number was average handle time on a specific case type. Not “improve agent efficiency.” Not “modernise the case workflow.” Handle time, on that case type, measured weekly, already tracked by the operations lead before we touched anything. Every design decision after that got tested against one question: does this reduce handle time on that case type, or doesn’t it?

    That constraint kills a lot of scope creep on its own. A nice-to-have field that doesn’t touch the metric gets deprioritised without an argument. A workaround that adds three clicks but saves the agent from a phone call gets fast-tracked, because someone can show the maths.

    What changes in the technical design

    Once you know the metric, it should show up in three places in the build, not just the final report.

    • The data model needs a field or relationship that captures the event driving the metric, at the point it happens, not reconstructed later from case comments or a separate spreadsheet.
    • The automation needs to fire close enough to that event that the data is accurate. A nightly batch job that recalculates handle time after the fact is a different design than a trigger or flow that stamps timestamps as the case moves through statuses.
    • The reporting layer needs to expose the metric in a way the person who owns it can actually check without asking IT for a custom report every Friday.

    Skip any of those three and you end up with a metric that technically exists but nobody trusts, because getting to it takes fifteen minutes and a pivot table.

    A composite example: the discount approval that didn’t cut leakage

    A typical enterprise org I’ve seen runs something like this. Finance flags discount leakage as a problem. The response is an approval process: discounts above a threshold route to a manager before the quote goes out. It ships. Everyone moves on.

    Eighteen months later, leakage hasn’t moved. The approval process captured whether a discount was approved, not what happened to margin as a result. Nobody built a field for the original list price versus the approved price at the line-item level. Nobody reported on override frequency by manager. The process existed, the metric didn’t, and finance is back asking for the same fix under a different name.

    More approval steps won’t fix that. Going back to the one number that actually matters, margin retained per deal, and checking whether the data model can produce it today, will. Usually it can’t, because the build was scoped around the workflow rather than the measurement.

    The scoping conversation this forces

    When a stakeholder brings a request, I ask them to finish this sentence: “I’ll know this worked when [metric] moves by [amount] by [date].” If they can’t finish it, the request isn’t ready for a solution design session yet. It’s a blunt filter, and a genuinely useful one for separating a real business problem from a feature someone saw in a demo.

    It also changes who signs off. A feature list gets approved by whoever asked for it. A metric commitment gets approved by whoever owns that number, and that person has a much stronger incentive to push back on scope that won’t move it.

    What this looks like in a solution design document

    Practically, add one section before the requirements table: the target metric, its current baseline, who owns it, and how the proposed build is expected to shift it. Keep it to a few lines. If you can’t fill it in, that’s the signal to go back and ask better questions before committing engineering time.

    I’ve started refusing to take a build past discovery without this filled in. It’s cut the number of “why did we build this again” conversations down to almost none, mostly because writing the metric down forces a conversation that usually gets skipped entirely.

    Where this earns its keep

    The payoff isn’t just cleaner requirements. It’s a much easier renewal conversation for whoever’s paying for the work. “We shipped the feature” competes for budget every year. “We moved handle time by 22% on the highest-volume case type” doesn’t need to compete. It gets funded again without a fight.

    That’s the actual argument for doing this on every enhancement, not just the big ones. Small requests accumulate into an org nobody can explain the value of. One metric, tracked from design through to the dashboard, is what turns a backlog of tickets into a track record.

  • Event-driven architecture on Salesforce: Platform Events in practice

    Platform Events sound simple in the Trailhead version: publish an event, subscribers pick it up, everyone’s decoupled and happy. Then you build the real thing, and it turns out “decoupled” doesn’t mean “hands off.” I’ve put Platform Events into production for order fulfilment updates, contact-centre status changes and integration retries, and the pattern only pays off if you design for what it actually guarantees, not what the demo implies.

    The problem they’re solving

    Most Salesforce orgs start with point-to-point calls. A case gets updated, a trigger fires a callout to the order system. The order system changes status, a middleware job polls Salesforce for changes. It works until you add a fourth system that also needs to know when a case changes, and now you’re threading callouts through triggers that were never built to fan out to that many places.

    A Platform Event breaks that coupling. The publisher fires one event. Any number of subscribers, inside Salesforce or outside it, pick it up independently. Nobody’s Apex depends on knowing who’s listening. That’s the real win. Not “event-driven” as a buzzphrase, but the fact that adding a fifth subscriber next year costs you a new trigger or a new external listener, not a change to the publisher.

    Where Platform Events beat the alternatives, and where they don’t

    Change Data Capture looks similar and gets picked by default because it needs no code to publish. But CDC fires on every field change to a record, whether or not that change is business-relevant. If you only care about status moving to “Escalated,” a custom Platform Event that you publish explicitly, from the exact place in your code where that transition happens, is a cleaner signal than filtering CDC payloads downstream for the one change you care about.

    Flow and direct Apex calls still win when both sides genuinely need to know the outcome before moving on. If a case can’t be closed until the refund system confirms the payment reversed, that’s a request-reply problem, not a fire-and-forget one. Publishing an event and hoping something eventually closes the case is the wrong tool for a workflow that has a required next step.

    The publish-timing distinction that catches people out

    Salesforce gives you two ways to publish: immediately, or after the transaction commits. Default triggers and most Apex use immediate publish, which means subscribers can receive the event before your database transaction finishes, and before anyone knows whether that transaction actually succeeds. Publish “Order Confirmed” immediately, and if the transaction then rolls back on a validation rule three steps later, you’ve told the world something that never happened.

    Publish-after-commit, using a transaction finalizer or letting the event fire from a change that’s already committed, avoids that. It costs a bit of latency. It’s worth paying for anything a downstream system will act on with real consequences, like triggering a payment or notifying a customer.

    Designing subscribers that survive contact with reality

    A few things I check on every Platform Event subscriber before it goes anywhere near production:

    • Ordering isn’t guaranteed across a burst. If ten events land within the same few seconds, don’t assume your Apex trigger processes them in the sequence they were published. Design the payload so each event is self-contained and idempotent, not a delta that only makes sense if the previous one landed first.
    • Retention is 24 hours, not forever. If a subscriber is down for a day, replay picks up from where it left off using a stored replay ID, but only within that window. A subscriber offline longer than that has lost events permanently, and your integration needs a reconciliation job as a backstop rather than faith in the event stream.
    • Apex trigger subscribers still run inside normal governor limits. A trigger that fires a callout per event, or does a DML per record without bulkifying, hits limits the moment volume climbs past whatever you tested with. Treat a Platform Event trigger exactly like a record trigger: assume it processes 200 events in one execution context, and write it that way from day one, not after the first bulk load fails.

    Where the actual failures happen

    In practice, the outages I’ve seen weren’t about the event bus dropping messages. Salesforce’s delivery is solid. They were about subscribers written to assume happy-path ordering, or a daily event allocation limit nobody was monitoring until a spike in case volume burned through it by mid-afternoon. Platform Events carry a published daily limit per org, and it’s easy to forget it exists until a marketing campaign drives ten times the normal case volume and your integration user starts silently failing to publish.

    Build monitoring around that limit before you need it. A scheduled Apex job that checks consumption against the org limit and alerts at 80% costs almost nothing to build, and it saves you from discovering the ceiling in production, usually during the exact spike you least want to be debugging blind.

    A pattern that actually holds up

    The design that’s worked well for me on contact-centre integrations: publish a small, versioned Platform Event the moment a business-relevant state change is committed. Keep the payload thin, mostly IDs and the new state, not a full record snapshot. Let each subscriber, an Apex trigger updating a related object, an external system via CometD or the Pub/Sub API, a Flow sending a notification, query for whatever additional detail it needs rather than relying on the event to carry everything.

    That last point matters more than it sounds. A thin event means a schema change on the source object doesn’t force you to version the event contract every time someone adds a field. A fat event that mirrors the whole record becomes a maintenance problem the first time three of your five subscribers don’t care about the new field and one of them breaks on it anyway.

    What to check before you commit to this pattern

    Before building a Platform Event architecture for something new, ask whether the receiving side actually needs to react asynchronously, or whether it’s request-reply wearing an event-driven costume because someone read that events are the modern way to integrate. Not every callout needs decoupling. The ones that do, usually notifications, status propagation, audit trails, background enrichment, are worth the investment: looser coupling, an easier path to adding subscribers later, and a clean separation between “this happened” and “here’s what we do about it.”

    Get the publish timing right, design payloads that assume disorder, and put a limit monitor in place before go-live rather than after the first incident. That’s most of what separates a Platform Event implementation that quietly does its job from one that generates a support ticket every time volume spikes.

  • Governor limits in bulk trigger design: patterns that actually scale

    Most Apex trigger bugs aren’t logic bugs. They’re volume bugs. Code that works fine in a dev org against three test records falls over the first time someone imports 400 leads or a nightly integration batches 2,000 case updates in one go. By then it’s live, and the failure looks like a mystery instead of what it actually is: a trigger that was never built to run more than once per transaction.

    I’ve reviewed enough trigger code across enterprise orgs to know the failures repeat. Same handful of anti-patterns, different object, different team. Here’s what actually holds up under volume, and where the line sits between “fix the trigger” and “this needs to be async.”

    Why governor limits catch people off guard

    Salesforce doesn’t limit how many records you can touch. It limits how many times you can call certain operations within a single transaction: 100 SOQL queries, 150 DML statements, 10,000 DML rows, 10 seconds of CPU time. A trigger that fires once per record, not once per batch, blows through those limits the moment more than a handful of records land in the same transaction.

    The trap is that single-record testing hides the problem completely. You insert one lead, the trigger fires, one SOQL query runs, everything’s fine. Nobody sees the SOQL-inside-a-for-loop until a data load or an API integration sends 200 records through at once and the transaction dies mid-way with a “Too many SOQL queries: 101” error that tells you almost nothing about which line caused it.

    The pattern that fixes 90% of it: bulkify first, always

    Bulkification isn’t an optimisation you add later. It’s the baseline shape of correct trigger code. The rule is simple: never put a query or a DML statement inside a loop over trigger records.

    Collect, then query once

    Instead of querying related records for each item in Trigger.new, pull every relevant ID into a Set<Id> first, run one SOQL query against the whole set, and build a map keyed by the field you need to match on. Every lookup after that is a map read, not a query. This one change is usually the difference between a trigger that handles 5 records and one that handles 5,000.

    Batch the DML the same way

    Same logic applies on the way out. Build a List of records to update or insert as you process the trigger set, and fire one DML statement at the end. Not one per record, and not one per sub-loop either. If you’re updating related records based on the trigger object, group them into a single list before the DML call, even if that means restructuring the loop to build up state first and act second.

    Use maps instead of nested loops

    Nested for-loops over related record lists look harmless until the data volume climbs. A loop inside a loop is O(n²), and at a few hundred records that’s suddenly tens of thousands of iterations for logic that should have been a single map lookup. If you find yourself looping through child records for every parent record, stop and build a Map<Id, List<SObject>> instead, keyed by the parent ID, populated in one pass.

    The pattern people skip: recursion control

    Bulkification gets the attention. Recursion is the one that actually causes production incidents. Code review rarely catches it, and the failure usually shows up months later, when someone adds a second trigger action that happens to update the same object.

    If your trigger updates the record it’s firing on, or updates a related object whose trigger updates back, you can end up re-entering the same trigger multiple times in one transaction without meaning to. The standard fix is a static boolean flag, set to true the first time the trigger body runs and checked at the top of every subsequent entry, so the second and third passes short-circuit. A cleaner version of the same idea uses a static Set<Id> of record IDs already processed rather than a blunt on/off switch, which matters when the trigger legitimately needs to fire again for records it hasn’t seen yet in the same transaction.

    Either way, don’t assume “it worked in the sandbox test” rules this out. Recursion usually needs a specific chain of updates to trigger, and that chain often doesn’t exist until someone builds a second automation six months after the first one shipped.

    One trigger, one handler, per object

    This is more of an architecture decision than a governor-limit fix directly, but it prevents the two problems above from compounding. Multiple triggers on the same object, each written independently, means multiple separate paths that can each query or DML inside a loop, and no single place to coordinate recursion control or execution order. A trigger handler pattern, one trigger per object that delegates to a handler class with clearly separated before/after insert/update/delete methods, gives you one place to enforce bulkification and one place to manage state across the whole transaction. It also means a new developer can find the logic instead of hunting through however many trigger files have accumulated on a Case object over the years.

    When bulkification isn’t enough

    Sometimes the honest answer isn’t a better trigger. It’s moving the work out of the trigger context entirely. If a trigger needs to make a callout, that can’t happen synchronously at all, callouts from triggers have to go through a Queueable or a future method. If the processing genuinely needs to touch tens of thousands of records, batch Apex with its own governor limit reset per chunk is the right tool, not a trigger straining to stay under 10,000 DML rows in one shot.

    A good rule of thumb: if you’re bulkifying correctly and still hitting limits on realistic data volumes, that’s a signal the logic belongs in an async context, not a sign you need a cleverer synchronous workaround.

    Test at the volume that actually breaks things

    A test class that inserts one record and asserts the trigger fired proves almost nothing about whether the trigger scales. Write at least one test method per trigger that inserts 200 records (the standard batch size Salesforce uses for bulk operations) and confirms it completes without hitting limits. That test will catch the SOQL-in-a-loop mistake in about the time it takes to run, long before it reaches a client’s production org during a data migration or an integration spike.

    None of this is exotic. Bulkify the queries, bulkify the DML, control recursion, keep one handler per object, and push genuinely large or callout-dependent work into async processing. The teams that get burned aren’t missing knowledge of these patterns. They’re skipping them under deadline pressure on the trigger that “only handles a couple of records anyway”, right up until it doesn’t.

  • From batch to real-time: what changes when order fulfilment stops running on a schedule

    Most order fulfilment builds on Salesforce start the same way: a scheduled job runs every fifteen minutes, or every hour, and picks up whatever’s ready to move. It works. Nobody questions it for the first year. Then the business grows, volumes climb, and someone in operations starts asking why an order placed at 9:01am doesn’t ship until the 9:15 run picks it up. That question is usually where the batch-to-real-time conversation actually starts, not in an architecture review.

    What batch processing quietly costs you

    A scheduled batch job isn’t wrong. It’s a reasonable default when volumes are low and nobody’s watching the clock. But it carries a hidden tax that only shows up once you look for it: every record sits in a queue for an average of half the run interval before anything happens to it. Run every fifteen minutes, and you’ve built in a 7.5 minute average delay before a single fulfilment step even starts. Stack three or four sequential batch stages, each with its own schedule, and you can lose the better part of an hour before an order actually leaves the warehouse.

    That delay is invisible until a customer calls asking where their order is, or a warehouse team flags that they’re getting work in inconsistent bursts instead of a steady stream. I’ve seen contact-centre teams build entire workarounds around a batch cadence they didn’t even know existed, manually checking and re-triggering processes because “it usually catches up by lunchtime.”

    The failure mode nobody plans for

    There’s a second cost that matters more than latency: batch jobs fail as a unit, or close to it. If a bulk job hits a governor limit or a bad record halfway through a batch of 200, you’re now debugging which of those 200 actually completed, which is a much worse afternoon than debugging one failed record in isolation. Batch design tends to optimise for throughput and quietly deprioritise traceability. Real-time processing, done properly, forces you to think about one record at a time, which makes failures easier to isolate and easier to explain to the business.

    The actual trigger for going real-time

    It’s rarely “we want to modernise.” It’s almost always a specific, measurable pain point: a customer-facing SLA that batch timing can’t meet, a warehouse team that needs work distributed evenly rather than in spikes, or an integration partner who expects near-instant confirmation. If you can’t point to one of those, real-time isn’t a priority yet, and rebuilding a working batch process for its own sake is a good way to introduce risk without a return.

    Where it does matter, the shift usually isn’t “replace the batch job with a trigger.” It’s a genuine change in what fires the work, what carries state, and who’s accountable when something breaks.

    What changes underneath

    Moving off a schedule means the fulfilment logic has to become event-driven rather than time-driven. On Salesforce that typically means a combination of a few things working together, not one silver bullet:

    • Platform Events or Change Data Capture firing the moment an order record actually changes state, instead of waiting for the next poll
    • Asynchronous Apex (Queueable or async processing) picking up the work so the triggering transaction stays fast and doesn’t hold a user-facing save hostage
    • Flow orchestrating the business logic where it’s genuinely declarative, with Apex reserved for the parts that need bulkification, callouts, or complex branching Flow handles poorly
    • A retry and dead-letter pattern, because real-time doesn’t mean failure-proof. Something will fail at 2am with nobody watching, and you need a way to catch it and reprocess without a human noticing a gap three days later

    That last point trips up a lot of first attempts. Teams replace the batch job with a trigger, get excited that orders now process in seconds, and forget that batch processing had accidental resilience built in: if something failed, the next run would often just pick it up again. Real-time architectures need that resilience designed in deliberately, usually through a monitoring object or a Platform Event replay strategy, because there’s no “next run” to bail you out.

    Governor limits don’t disappear, they just move

    A common assumption is that moving to per-record processing sidesteps bulk governor limits. It doesn’t. It just shifts the risk from “one batch might hit a limit” to “a burst of 500 near-simultaneous order updates might trigger 500 near-simultaneous transactions, each doing a callout.” If your real-time trigger fires a synchronous callout per record with no queuing or throttling, you’ve traded a slow, predictable batch problem for a fast, unpredictable concurrency problem. Bulkification discipline still applies. It just applies to unpredictable, uneven bursts instead of a scheduled, sizeable chunk.

    Where teams get this wrong

    The most common mistake isn’t technical, it’s scope. Teams try to make everything real-time in one release because the business asked for “faster fulfilment” without qualifying which step actually matters. Not every stage of a fulfilment process needs sub-second latency. Order confirmation to the customer might genuinely need to be instant. Inventory reconciliation with a warehouse system might be perfectly fine running every five minutes. Treating the whole pipeline as one monolithic thing that must become real-time, rather than identifying the two or three steps where latency actually causes pain, is how a two-week enhancement becomes a three-month rebuild.

    The second mistake is skipping observability. A batch job that fails is loud, usually because someone notices the batch didn’t run or the record count looks wrong. A real-time process that silently drops one event out of ten thousand is quiet, and quiet failures compound. Before flipping the switch, you need a way to answer “did every order that should have processed actually process,” and that usually means a reconciliation job running in the background, ironically often on… a schedule. Real-time fulfilment and scheduled reconciliation aren’t opposites. The second one is what makes you trust the first.

    A path that doesn’t require a rewrite

    You don’t need to tear out the batch job on day one. A practical sequence looks like this: identify the single fulfilment step causing the most business pain, build an event-driven path for that one step alongside the existing batch process, run both in parallel long enough to compare outcomes, then retire the batch step once you trust the numbers. Repeat for the next step if it’s warranted. Most orgs never need to fully retire batch processing everywhere. They end up with a hybrid: real-time where seconds matter, scheduled where they don’t, and a clear, documented reason for which is which.

    That’s a more honest answer than “we went real-time” anyway. The goal was never the architecture. It was the customer who used to wait fifteen minutes and now doesn’t.