Tag: Error Handling

  • Agentforce in a contact centre: where it helps the agent, and where it should stay out of the way

    A support agent picks up a call about a failed order. Before they’ve said a word, the case history, the customer’s last three interactions and a draft summary of the account are already sitting on screen. That’s Agentforce doing its job well: invisible, fast, and out of the customer’s way. Ten minutes later the same customer is angry about a refund policy exception, and the agent needs to read the room, bend a rule slightly, and say something that sounds human. That’s a different job entirely, and it’s not one Agentforce should be doing.

    I’ve spent years inside contact-centre Service Cloud orgs, and the pattern with Agentforce is becoming clear fast: the technology is genuinely good at a specific band of work, and genuinely bad at the work either side of that band. Knowing where that line sits matters more than any feature list Salesforce ships this year.

    Where Agentforce earns its place

    The strongest use cases I’ve seen all share one trait: they’re information-retrieval and drafting problems, not judgment problems.

    Summarising before the agent needs to ask

    Case summarisation is the easiest win in the whole platform. An agent opening a case with eleven prior interactions, three transfers and a product return doesn’t need to read all of that. They need the three facts that matter right now. Agentforce reading case history and surfacing a two-line summary saves real seconds on every single call, and seconds compound across thousands of calls a week.

    First-draft replies on routine requests

    Password resets, order status, standard policy questions: these have one correct answer and no emotional complexity. Letting Agentforce draft the reply and having the agent review and send it is faster than typing from scratch, and it’s lower risk than it sounds, because the agent is still the one hitting send.

    Routing and triage

    Classifying an inbound case by intent, urgency and the right queue is exactly the kind of pattern-matching large language models are built for. I’ve watched Omni-Channel routing improve meaningfully once case classification stopped depending on a customer picking the right dropdown option on a web form.

    Where it should stay out of the way

    The failure cases I’ve seen cluster around three things: emotional nuance, irreversible actions, and genuine edge cases the model has never seen framed quite that way.

    Anything with an angry or vulnerable customer

    An agent can hear tone shift in someone’s voice and adjust. A model reading transcript text is working from a much thinner signal. Handing de-escalation, retention conversations, or anything touching a customer in genuine distress to an autonomous agent is a bad trade even when the model gets the facts right, because the facts were never the hard part.

    Actions that can’t be undone

    Refunds above a threshold, account closures, data deletion requests: give Agentforce a narrow, well-tested action for these and it’ll happily execute it exactly as scoped. The risk isn’t the model being wrong occasionally. It’s that “occasionally” compounds fast when the action is irreversible and the volume is high. I treat any Apex action tied to money or data loss as something a human approves, full stop, no matter how good the surrounding prompt is.

    The exception that breaks the pattern

    Every contact centre has cases that don’t fit the normal shape: the customer who’s a VIP but isn’t flagged as one, the policy exception that was agreed on a call six months ago and never got written down, the integration that silently failed last Tuesday and left three systems disagreeing about the truth. Agentforce has no way to know what it doesn’t know. A human agent, at minimum, knows when something feels off.

    A rough framework for the line

    When I’m scoping where Agentforce should sit in a Service Cloud build, I ask three questions about the task:

    • Is there a single correct answer, or does the right answer depend on reading a person?
    • Can the action be reversed cheaply if the model gets it wrong?
    • Has this exact shape of request happened often enough that the model has real pattern density to draw on?

    Three yeses, and Agentforce can probably run with real autonomy. Two yeses, give it a supporting role with a human in the loop. One or zero, keep it out of the workflow entirely and spend the budget somewhere else.

    What this means for the build

    Practically, this changes how I scope Agentforce actions. I design the narrowest possible action for each task rather than one broad “handle the case” agent. A tight action that looks up order status is easy to test, easy to monitor, and easy to trust. A broad action that can also issue refunds, update addresses and escalate cases is none of those things, even if it demos beautifully.

    It also changes where I put the guardrails. Input validation on every Apex action an agent can call isn’t optional, because the agent will eventually call it with something you didn’t anticipate. And every action above a certain blast radius gets a human approval step before anything executes, not because I distrust the model specifically, but because that’s the same discipline I’d apply to any automation touching customer money or customer trust.

    None of this is an argument against Agentforce in a contact centre. It’s an argument for being precise about which parts of the job it’s actually solving. The agents who do this well in six months won’t be the ones who gave Agentforce the most access. They’ll be the ones who worked out, task by task, exactly where the model was reliable and designed everything else around a human doing what humans are still better at.

  • Identity resolution in Data Cloud: the unglamorous work every agent depends on

    Nobody gets excited about identity resolution. It doesn’t demo well. There’s no slide where you show a reconciliation rule and the room leans in. But every time I’ve seen an Agentforce rollout wobble after go-live, the root cause traces back to the same place: the agent didn’t actually know who it was talking to.

    Data Cloud’s whole pitch is a unified view of the customer. Identity resolution is the machinery that builds that view. If it’s loose, every downstream capability, segmentation, personalisation, and now agent reasoning, inherits the mess.

    What identity resolution is actually doing

    Strip away the marketing language and it’s three steps. First, match rules decide which records across your Data Model Objects probably belong to the same person, using identifiers like email, phone, or a loyalty ID. Second, reconciliation rules decide which source wins when two matched records disagree on a field value, most recent, most trusted source, or a custom rule you define. Third, the result gets written to a unified individual profile that every other Data Cloud capability reads from.

    That sounds clean in a diagram. In a real org it’s messier, because the inputs are messier. A contact-centre case system might use a work email. A marketing platform might have a personal one, captured years ago through a campaign form. A loyalty system might key on phone number and nothing else. Exact match on email alone will quietly fail to link all three, and you’ll end up with three “unified” profiles for one actual customer.

    A scenario I keep seeing

    Picture a contact-centre team that’s just turned on an Agentforce service agent. A customer calls in, the agent looks up the profile, and it finds one of the three fragments, the one tied to the phone number, not the one with the full purchase history sitting under the old email. The agent answers politely and wrongly, because it was never shown the full picture. Nobody wrote a bug. The match rules just weren’t tuned for how this business actually collects data.

    This is the part people miss: identity resolution failures don’t throw errors. They just produce a confidently wrong answer, which is a worse failure mode than a system that visibly breaks.

    Why it matters more once an agent is involved

    A human service rep who gets handed a thin profile usually notices something’s off and asks a clarifying question. An agent, especially one running a scripted topic with a defined action, often doesn’t. It takes the profile it’s given as ground truth and reasons forward from there. Feed it half a customer and it will give you a confident, half-right decision.

    And the stakes go up with the kind of actions an agent takes. A recommendation engine with a fragmented profile gives you a slightly worse suggestion. An agent with write access that updates a case, applies a discount, or triggers a refund based on a fragmented profile can take an action against the wrong history entirely. Identity resolution isn’t just a data quality nicety anymore. It’s the input validation layer for everything built on top of it, and I’d put it ahead of almost anything else on the checklist before an agent gets real write access.

    Where this quietly breaks in practice

    A few patterns show up again and again.

    • Match rules left on the out-of-box defaults, never tuned against a sample of real source data before go-live.
    • Exact-match-only logic on fields that are inherently inconsistent, like name or address, where fuzzy matching or standardisation would catch far more true matches.
    • No ongoing monitoring of the unification rate, so a source system change six months later (a new form field, a renamed source object) silently degrades matching and nobody notices until someone complains.
    • Reconciliation rules that default to “most recent wins” everywhere, even for fields where a specific system should always be the trusted source, like a billing address that should come from the ERP, not whichever marketing tool touched the record last.
    • Merge and unmerge handled as an afterthought, so when identity resolution gets it wrong, there’s no clean process to split a profile back apart without losing history.

    What actually helps

    Start with a sample of real production-like data before you finalise match rules, not synthetic test records that are too clean to reveal anything. Pull a few hundred genuine customer records with the kind of inconsistency your business actually produces, and run your match rules against them by hand. You’ll find the gaps fast.

    Be deliberate about which identifiers you trust for matching and in what combination. Email alone is rarely enough once a business has more than one channel of contact. Layer in phone, and where you have it, a persistent identifier like a loyalty number or account ID that doesn’t change when someone switches jobs or email providers.

    Set reconciliation rules field by field, not as one blanket policy. Billing-sensitive fields deserve a named trusted source. Preference fields are usually fine on recency.

    And treat the unification rate as a metric worth watching on an ongoing basis, the same way you’d watch API error rates or job failures. A sudden drop after a source system change is a signal, not noise.

    Before you let an agent act on any of this

    If you’re building toward agent write access, identity resolution deserves a review of its own before that conversation even starts. Ask what happens when the agent is handed a fragmented profile: does the topic design account for incomplete data, or does it assume a fully unified customer every time? If the answer is the latter, that’s a gap worth closing before the agent goes live, not after the first wrong decision gets escalated.

    The unglamorous work pays for itself quietly. Nobody notices good identity resolution. Everyone notices the agent that got it wrong.

  • How to scope a Salesforce enhancement so it doesn’t quietly become three

    A stakeholder asks for one thing: a validation rule so reps can’t close a case without entering a resolution code. Half a day’s work, by the sound of it. Three weeks later you’re deep in a Flow that also updates a custom object, fires an email alert, and feeds a new field into a report someone in finance now quietly depends on. Nobody sat down and decided to build all that. It just accumulated, one reasonable-sounding addition at a time.

    This is the most common way Salesforce enhancement work goes sideways. Not through bad estimates. Through scope that was never actually scoped in the first place.

    Why this happens more on Salesforce than elsewhere

    Declarative tools make it cheap to say yes to one more thing. There’s no build step, no deploy pipeline slowing anyone down mid-conversation. You can add a field, wire up a Flow branch, and show the stakeholder the result in the same meeting. That speed is the whole appeal of the platform, and it’s also exactly why scope drifts. When the cost of “just one more field” looks like zero, nobody treats it as a decision.

    Stakeholders see this too, even if they can’t name it. They watch you build live, so every follow-up thought feels like it belongs in the same ticket. “While you’re in there, can it also notify the queue owner?” “Since we’re touching this object, can we clean up that other field too?” Each ask is small. The sum of them is a different project than the one you quoted.

    Ask what breaks if the answer is no

    Before you open Setup, ask the person requesting the change one question: what happens if we do exactly what you asked, and nothing else? If the honest answer is “that’s fine, it solves the problem,” you have a real scope. If the answer involves three other systems, a different team, or “well, we’d probably also need,” you’ve just found the second and third tickets hiding inside the first.

    Most requesters haven’t separated the must-have from the nice-to-have in their own head yet. That’s not a failure on their part. It’s your job to pull that apart, not theirs to hand it to you pre-sorted. A five-minute conversation here saves a week of rework later.

    Write the scope down before you touch anything

    One sentence, before any configuration work starts: “This enhancement will do X for Y users, and nothing else.” Put it in the ticket. Get the requester to read it back and confirm. It feels bureaucratic for something that might take an afternoon, and that’s exactly why people skip it, and exactly why skipping it is how afternoons turn into weeks.

    This isn’t about process for its own sake. A written scope is the thing you point back to when someone asks, two weeks in, why the notification piece isn’t in yet. Without it, every later addition feels retroactively like it was always part of the plan. With it, you have a clean line: that’s a new request, let’s size it separately.

    Where the quiet third ticket usually hides

    A few patterns show up again and again in Salesforce work specifically:

    • Related automation. You add a field, and someone asks for a Flow to populate it automatically from somewhere else. That’s a second build, with its own edge cases and its own testing.
    • Reporting asks. “Can we track how often this happens?” sounds like adding a report. It often means a new field, a new way of recording history, and a dashboard nobody scoped.
    • Data cleanup. Half the time a new validation rule surfaces how messy the existing data is. Backfilling records so the new rule doesn’t lock users out of thousands of existing cases is its own project, and it’s rarely small.
    • Permission changes. A new field or object usually needs field-level security and permission set updates across every profile that touches it. On an org that’s been customised for years, that step alone can take longer than the change that triggered it.

    None of these are wrong to build. They’re just not the thing that was originally asked for, and they deserve their own estimate, their own priority, and their own sign-off.

    A practical way to hold the line

    When a new idea surfaces mid-build, and it will, don’t shut it down and don’t just absorb it either. Write it down as its own line item, note roughly how big it looks, and ask the requester to rank it against whatever else is in the queue. Most of the time this is enough to settle the person down: they weren’t attached to getting it this week, they just didn’t want the idea lost. Parking it properly protects both the current ticket and the idea itself.

    Keep a running “related but separate” list on every enhancement of any real size. On a recent piece of platform support work, that list is what let a reporting change ship on time while three follow-on requests moved into the next sprint instead of silently inflating the first one. The original ticket closed in the time it was quoted for. The other three got proper attention later, with their own scope and their own testing, instead of being bolted onto something that was never designed to carry them.

    What this actually protects

    Scoping tightly isn’t about saying no to good ideas. It’s about making sure every piece of work gets built with the same care, instead of the first idea getting a proper design conversation and the fourth one getting squeezed in under time pressure with no testing to speak of. A Salesforce org that’s grown this way for a few years is usually easy to spot: automation that does three unrelated things because it was easier to extend the existing Flow than start a new one, fields nobody can explain the purpose of, permission sets that grew sideways instead of on purpose.

    The fix costs almost nothing upfront. One sentence of written scope, one honest conversation about what breaks if you say no, and a parking list for everything else. Cheap insurance against the kind of technical debt that takes months to notice and years to unwind.

  • When a custom LWC beats a standard component, and when it doesn’t

    Every org I’ve worked on has at least one custom Lightning Web Component that didn’t need to exist. Usually it’s a record highlights panel rebuilt pixel-for-pixel to match a mockup, or a related list reimplemented because someone didn’t like the default row height. Three years later, nobody remembers why it’s custom, and it’s the thing that breaks every release.

    The opposite mistake happens too. I’ve seen teams force a genuinely complex screen flow into standard Flow screen components, bolting on formula fields and hidden sections until the logic is unreadable, because “we try to avoid custom code.” Both failures come from the same place: treating the custom-vs-standard call as a style preference instead of an engineering decision.

    Start from the default, not the exception

    Standard components should win by default. Lightning App Builder components, Dynamic Forms, Flow screen components and the standard related lists get platform upgrades for free, inherit accessibility fixes automatically, and don’t need a developer on hand every time field-level security changes. A junior admin can maintain a page built from standard components. A custom LWC usually needs whoever wrote it, or a very good comment trail, because the logic lives in JavaScript, not in clicks.

    So the real question isn’t “can I build this as a custom component.” Almost anything can be built as a custom component. The question is what you’re giving up to do it, and whether what you get back is worth that cost. Most of the time it isn’t.

    Where standard components actually fall over

    Cross-record logic in a single view

    Standard related lists and highlights panels are bound to one record and its direct relationships. The moment you need to pull in data from a grandparent object, aggregate across a child relationship, or show a calculated value that depends on more than a formula field can express, you’ve hit the ceiling. I built a component a while back that showed a rolling 90-day case volume trend against SLA breach count, on the account page, computed from two child objects with different filter criteria. No combination of standard list views and report charts got close, and a custom LWC calling Apex was the only honest answer.

    Conditional UI that standard components can’t express

    Dynamic Forms handles field-level conditional visibility well. It does not handle “show this entire section, with this specific layout, only when three unrelated conditions are all true and the user has one of two permission sets.” You can hack it with formula fields and record types, and I’ve watched teams spend more hours fighting that hack than the custom component would have taken to build properly.

    Inline actions that need immediate feedback

    Standard quick actions post back and refresh. If a contact-centre agent needs to trigger an action and see the result in under a second, without a page refresh breaking their flow, a declarative quick action often feels sluggish enough that agents route around it. That’s a real usability cost, not a cosmetic one, and it’s the kind of thing that shows up in average handle time six months later.

    What building custom actually costs you

    This is the part that gets skipped in the planning meeting. A custom LWC means:

    • Someone owns the Jest test suite, and it has to stay green through every org upgrade
    • Accessibility is now your responsibility, not the platform’s, down to keyboard navigation and screen reader labels
    • Every new field or business rule change is a deployment, not a configuration change an admin can make on a Friday afternoon
    • The component needs its own security review, because it’s making its own SOQL and Apex calls instead of relying on standard field-level security enforcement

    None of that is a reason to avoid custom components. It’s a reason to make sure the thing you’re building actually needs to be custom, because that list of costs doesn’t go away just because the component works on day one.

    A decision framework that holds up under pressure

    When a requirement lands on my desk that looks like it needs a custom component, I run it through four questions before anyone opens VS Code.

    Can Dynamic Forms, a Flow screen component, or a standard related list get within 80% of this with configuration alone? If yes, build that first and show the stakeholder. Half the time the remaining 20% turns out to be a nice-to-have, not a requirement.

    Does the logic depend on data the platform can’t surface declaratively? Cross-object aggregation, external callouts rendered inline, or calculations too complex for a formula field are the honest triggers for custom code. A preference for a different button colour is not.

    Will this component need to change often, and who will change it? If the answer is “the business team, frequently,” lean hard toward standard, even if it means a slightly less polished result today. If it’s “rarely, and only a developer touches it,” custom is a safer bet.

    What’s the blast radius if this breaks during a Salesforce release? A standard component degrades gracefully, usually. A custom LWC with an unhandled API version bump can take down a page entirely. If the component sits on a page every agent uses every call, that risk needs to be priced into the decision, not discovered after a Sunday release.

    What I actually check in code review

    When a pull request adds a new custom LWC, I’m not reviewing whether the code is clean, not first. I’m checking whether the PR description explains why a standard component couldn’t do the job. If that explanation isn’t there, the review stops until it is. That one habit has quietly reduced the amount of unnecessary custom UI across every team I’ve led, more than any architecture guideline ever has.

    The components worth building custom are the ones that would be genuinely painful to build any other way. Everything else is a related list with better padding, and the platform already gives you that for free.

  • Data migration mistakes that show up three months after go-live

    Every data migration gets tested at cutover. Record counts match the source system, a sample of accounts and contacts gets eyeballed, a handful of business users click through their most important records and confirm nothing looks obviously wrong. Hypercare runs for two or three weeks, the incident count stays low, and everyone moves on. Then, somewhere around month three, the real problems start showing up. And by then nobody’s thinking about the migration anymore, so the first instinct is to blame the new automation, or the new integration, or “something Salesforce changed.”

    It’s rarely Salesforce. It’s usually the migration, and the reason it took three months to notice is that the mistakes that matter aren’t the ones cutover testing is built to catch.

    Why cutover testing misses the damage that matters

    Cutover validation answers one question: did the records land? Count comparisons, field-level spot checks, a UAT pass against a sample. All of that is necessary. None of it tests what happens once real users, real automation and real time start interacting with the migrated data. The bugs that surface later aren’t data quality bugs in the traditional sense. They’re mismatches between what the migration assumed and what the org actually does with a record over its lifecycle.

    I’ve seen this pattern often enough on enterprise orgs that I now treat it as the default outcome of any migration that only tests for “did it land correctly,” rather than “will it behave correctly.”

    The five mistakes that don’t show up on day one

    Backdated CreatedDate breaking date-based automation

    Migrated records usually carry their original creation date, which is the right call for reporting. But entitlement processes, SLA timers, and any flow with a time-based path built on CreatedDate or a derived field will behave differently for a record created three years ago versus one created this morning. A case migrated with its original 2023 timestamp can slip straight through an SLA escalation that was designed around cases created after go-live. Nobody notices during hypercare because hypercare is full of freshly created records, not migrated ones. It shows up the first time someone runs an aged-case report or an escalation fires (or doesn’t fire) on a record nobody remembers was migrated.

    Ownership that technically works but breaks sharing

    A common shortcut: assign every migrated record to the integration user or a generic “Data Migration” user, then reassign in a follow-up script. When that follow-up step is incomplete, or when territory and queue assignment rules only fire on record creation (not on data load), you end up with records that are visible to the wrong people, invisible to the right people, or sitting in a queue nobody’s monitoring. This one is genuinely hard to catch at cutover because sharing visibility depends on who’s looking. A system admin testing UAT sees everything. The actual case owner three months later doesn’t.

    External IDs that aren’t as unique as they look

    If the migration used an external ID field to support future upserts from an integration, and that field wasn’t validated for true uniqueness against production edge cases (leading zeros stripped, whitespace, case sensitivity on a text field instead of a proper external ID field), you get silent overwrites. The first few weeks nothing collides because the integration is dealing with genuinely new records. Then a legacy record with a near-duplicate key gets touched by an inbound sync and quietly overwritten. Nobody gets an error. The data is just wrong, and it stays wrong until someone happens to compare it against the source system.

    Roll-up and formula fields that drift instead of break

    Roll-up summaries recalculate on the trigger events they’re built for: a child record inserted, updated or deleted. A bulk data load that doesn’t fire those events in the expected order, or that loads parent and child records out of sequence, can leave a roll-up value that was correct on migration day and never gets touched again unless something forces a recalculation. Three months in, someone compares a roll-up total against the underlying child records and finds a gap. It’s not corruption. It’s a number that was right once and nobody ever asked Salesforce to check it again.

    Validation rules disabled for the load and never fully restored

    Almost every migration disables validation rules, and sometimes triggers, to get bulk data through without a wall of errors. The riskier version of this is when a rule gets disabled at the metadata level rather than bypassed for the load user specifically, and re-enabling it becomes a manual step buried in a cutover runbook that doesn’t get checked off with the same rigor as the actual data load. Bad records start entering the system weeks later through completely normal channels, because the guardrail that should have stopped them was never switched back on.

    Catching these before they’re three months old

    None of this requires exotic tooling. It requires treating the first ninety days after go-live as part of the migration, not the period after it.

    • Before the load, list every automation with a time-based or CreatedDate-dependent path and decide explicitly how migrated records should behave against it, rather than discovering the answer in production.
    • Test sharing visibility as the actual end users who will own the records, not as a system admin. Profile-level testing catches what UAT with a full-access login won’t.
    • Run a uniqueness and format audit on any field used as an external ID or upsert key, against real edge cases from the source system, not a clean sample.
    • Force a full recalculation of roll-up and formula fields after the load completes, then reconcile a sample against the source rather than assuming the load triggered it correctly.
    • Put “validation rules and triggers restored to pre-migration state” on the cutover checklist as its own signed-off line item, with a metadata diff to prove it, not a verbal confirmation.
    • Schedule a deliberate data health check at thirty, sixty and ninety days post go-live. Nobody plans for this, which is exactly why it catches things.

    The teams that get burned by this aren’t careless. They’re just measuring the wrong moment. A migration that “worked” on go-live day and a migration that holds up at day ninety are different things to test for, and only one of them is the one your users actually experience.

  • What a good Salesforce technical lead actually does in code review

    Most code review comments on a Salesforce PR are really about formatting. Bracket placement, an unused variable, a missing test class. None of that is wrong to flag, but it’s not the job. The job is catching the thing that passes every static check and still takes the org down at 50,000 records, three releases from now.

    Reading for what scales, not what compiles

    A trigger that queries inside a for loop will pass code coverage. It’ll pass a linter. It might even pass a demo with ten test records. It fails the first time someone runs a data load, and by then the person who wrote it has usually moved to a different project. Governor limits don’t show up in a diff. You have to know what the code will do at volume, which means picturing the bulk case before you read a single line of the actual implementation.

    I ask four things before I look at logic: what triggers this, how many records can hit it in one transaction, what happens if it runs twice, and whether it depends on a specific order of operations somewhere else in the org. Most defects I catch in review live in the gap between those questions and what the developer assumed while writing the happy path.

    The bulkification checklist nobody writes down

    SOQL and DML inside loops are the obvious ones, and most experienced Apex developers catch those themselves now. The subtler failures are recursive trigger calls, callouts fired per record instead of batched, and collection logic that looks bulk-safe but still does an expensive comparison somewhere in the middle. None of that fails a unit test with 200 records. It fails a Tuesday morning data load with 40,000.

    Test coverage tells you almost nothing

    Seventy-five percent is a compliance number, not a quality signal. A test class can hit every line and still assert nothing beyond “no exception was thrown.” When I review test code, I’m checking for negative cases: a null field, a duplicate external ID, a user without the right permission set, or a picklist value that no longer exists. If every test in the class uses the same clean data setup, the coverage number is decorative.

    The better question in review isn’t “does this have tests.” It’s “would this test fail if the logic were wrong.” Write the bug in your head, then check whether any assertion in the class would catch it. Half the time it wouldn’t, and that’s the actual finding, not the coverage percentage sitting at 82%.

    Where governance debt actually gets created

    Every org I’ve worked in that’s been customised for years has the same pattern behind it. Nobody made one bad decision. A hundred small, reasonable-looking decisions got approved individually, and the org ended up with several ways to do the same thing: a validation rule here, a flow there, an Apex trigger doing the same check somewhere else. Each PR looked fine on its own.

    Catching that in review means asking a question most reviewers skip: does this already exist somewhere else in the org? Before approving a new flow that enforces a business rule, I check whether that rule is already sitting in a validation rule, a different flow, or a trigger nobody’s touched in two years. It takes an extra five minutes. It’s the difference between one source of truth and three that quietly disagree with each other by the time someone debugs a production issue.

    Naming and structure aren’t nitpicks

    A trigger handler named after the object instead of the intent. A flow with no description. An Apex class mixing unrelated responsibilities because it was faster to bolt on than to split out. These get waved through constantly because none of them break anything today. What they break is the next person’s ability to safely change the org without reading every line first, from scratch, with no context. I’ll push back on structure even when the logic is correct, because the logic being correct once isn’t the bar. The bar is someone else understanding it without asking the original author, who by then might not even be on the project anymore.

    Security review isn’t a separate step

    Field-level security, sharing model behaviour, CRUD and FLS enforcement on SOQL: these tend to get treated as a checklist item for a security audit months later, if at all. They should be part of every review, not a phase that happens afterward. Is this Apex class running without sharing when it should respect it? Does this LWC expose a method that skips permission checks a profile would otherwise enforce? Catching that in review costs one comment. Catching it in production costs an incident report and a client conversation nobody wants to have.

    What I actually leave as comments

    Not “nice work,” and not a list of style preferences dressed up as requirements. I leave the specific scenario that breaks it: this fails when the account has no owner, this recursion loop triggers if the same record updates twice in one transaction, this assumes a single currency and the org has four. Specific, reproducible, tied to a real condition that exists in this org. A developer can act on that immediately. “Please refactor for clarity” gives them nothing to act on and just adds a review cycle for its own sake.

    Good review also says yes fast. If a PR does what it needs to do, handles bulk correctly, respects sharing, and the tests would actually catch a regression, approve it. Slowing down good work just to leave a comment teaches people to dread review instead of using it.

    The habit that makes the biggest difference

    This isn’t about being harsher on people’s code. Good review protects the org the code will run in six months, not the sandbox it happened to be written in. The developer who wrote it is usually thinking about today’s ticket. The reviewer’s job is thinking about the org at scale, under load, with the security model intact, and readable by whoever inherits it next year. That gap is the whole difference between a review that catches typos and one that actually protects the platform.

  • 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.