Category: Salesforce

  • API limits and callouts: designing integrations that survive scale

    Most Salesforce integrations work fine in the demo. They work fine in UAT. Then six months after go-live, volume triples, and the same integration starts throwing CalloutException errors in production at 2am. Nobody changed the code. The limits were always there. The traffic just caught up to them.

    I’ve inherited more than one of these. The pattern is always the same: a callout was built to solve today’s problem, with no thought for what happens when the record count goes from hundreds a day to tens of thousands. Fixing it after the fact costs far more than designing around the limits up front.

    Where the limits actually bite

    Salesforce’s governor limits around callouts aren’t obscure. They’re published, they haven’t moved much in years, and almost nobody designs for all of them at once.

    • 100 callouts per transaction, synchronous or asynchronous combined.
    • A 120-second cumulative timeout per transaction for all callouts.
    • 6MB heap in a synchronous transaction, 12MB in asynchronous.
    • A daily API request limit per org, scaled by edition and licence count, that counts every REST, SOAP, and Bulk API call against the same bucket.

    None of these are hard to hit. A trigger that calls out once per record, running on a batch of 200 from a Data Loader import, blows past the 100-callout ceiling before lunch. A nightly sync job that pulls every modified record one at a time, instead of in bulk, burns through the daily API allocation while the rest of the org is still waking up.

    The patterns that actually hold up

    Bulkify the callout, not just the trigger

    Everyone bulkifies DML by now. Fewer people apply the same discipline to callouts. If a trigger fires on 200 records and the external system has a batch endpoint, send one request with 200 records in the payload, not 200 requests. Most modern integration platforms and even plenty of legacy systems support this. If the other side genuinely can’t take bulk payloads, that’s a conversation to have with whoever owns that system, not a problem to absorb silently into Apex.

    Go async before you’re forced to

    Queueable Apex, Batch Apex, and scheduled jobs exist precisely because synchronous transactions have the tightest limits. A callout triggered from a user clicking a button needs to happen fast or not at all from their perspective. A callout syncing records to a data warehouse doesn’t. Moving it to Queueable buys a separate governor limit context, more heap, and room to retry without holding up the user. The habit worth building: ask whether a callout needs to block the transaction it’s in, and default to async when the answer is no.

    Treat the daily API limit as a budget, not a wall

    Teams tend to notice the API limit only once they’ve hit it, which is the wrong time to notice. Set up an alert well before the ceiling, at 70 or 80 percent of daily usage, using the REST API Usage metric or a scheduled check against OrgLimits. Then look at what’s actually consuming the budget. In a contact-centre org I worked with, a third-party case enrichment tool was polling for updates every five minutes around the clock, whether or not anything had changed. Moving that to an event-driven push cut its API footprint by more than half, and the integration got faster in the process.

    What happens when the other side goes down

    Design for the happy path and the integration will fail the first time the external system has a bad day, which it will. A few things make that survivable instead of catastrophic:

    Retries with backoff, not retries with brute force

    Retrying a failed callout immediately, in a loop, is how a flaky external API turns into a self-inflicted denial of service. Exponential backoff, implemented through a scheduled or Queueable retry with increasing delay, gives the other system room to recover instead of hammering it the moment it’s struggling.

    Idempotency keys for anything that writes

    If a callout can be retried, it will eventually be retried twice for the same operation, whether from a timeout, a network blip, or a scheduled job overlapping with a manual retry. Design the payload with a unique idempotency key and have the receiving system (or a staging layer on this side) reject duplicates. Without this, retry logic that’s meant to make the integration more reliable quietly starts creating duplicate records instead.

    A circuit breaker for the integration, not just error handling

    When an external system starts failing consistently, stop calling it. A simple circuit breaker, tracking consecutive failures and pausing outbound callouts for a cooldown period once a threshold is crossed, protects both the Salesforce org’s limits and the struggling external system. It also turns a 2am pager alert into a dashboard that says “integration paused, retrying in 10 minutes” instead of a transaction log full of timeouts.

    Stop polling where you can push

    Platform Events and Change Data Capture exist specifically to replace the polling pattern that eats API allocation for no good reason. If an external system needs to know when a Salesforce record changes, publishing an event the moment it happens is cheaper, faster, and far less fragile than that system asking “anything new?” every few minutes. The same logic runs in reverse: if Salesforce needs near-real-time updates from an external system, check whether it can push a webhook into an inbound integration rather than Salesforce polling it on a schedule. Either direction, push beats poll on cost and on latency.

    Named Credentials are doing more than you think

    Teams often treat Named Credentials as just a place to store an endpoint and a password. They’re also the natural place to centralise retry-aware timeout settings, and they decouple the authentication mechanism from the Apex code calling it. When an OAuth token needs refreshing or a certificate needs rotating, that change happens in Setup, not in a deployment. For integrations that are going to live for years and get touched by whoever’s on the team at the time, that separation matters more than it looks like it does on day one.

    None of this needs to be built on day one for every integration. A low-volume internal tool calling an internal API once a day doesn’t need a circuit breaker. But the moment an integration touches customer-facing volume, or sits on a path that other automation depends on, these patterns stop being nice-to-haves. The cost of adding them after the first production outage is always higher than the cost of designing for scale before the first record ever gets sent.

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

  • Omni-Channel routing: what breaks it in practice

    Omni-Channel routing gets blamed for a lot of things it didn’t cause. A case sits in queue for twenty minutes, an agent swears they were available, and someone opens a ticket saying “routing is broken.” Nine times out of ten, the routing engine did exactly what it was configured to do. The configuration just stopped matching how the team actually works, months ago, and nobody noticed until a customer complained.

    I’ve debugged this pattern on more than one contact-centre org now. The fix is rarely a routing change. It’s usually somewhere upstream of routing: presence, capacity, or a queue that quietly drifted out of sync with the team roster.

    Start by checking what “available” actually means

    Omni-Channel routes work based on presence status, and presence status is only as reliable as the configuration behind it. The most common failure I see: an agent’s presence status is marked “Available,” but the status options aren’t tied to the actual channels the agent can handle. They’re online, green light showing, but their service presence status was built for phone and chat, not the new async channel someone added six months ago. The routing engine has no way to know that. It just sees “Available” and sends work.

    Same problem shows up in reverse. A status like “In a Meeting” or “Training” should decline new work, but if it wasn’t explicitly configured to do that, agents sit there accumulating case assignments while genuinely unavailable. Nobody built it wrong on purpose. Someone added a new presence status for a one-off project eighteen months ago, used the default settings, and it’s been quietly misrouting work ever since.

    What to actually check

    Go through every Service Presence Status record and confirm, for each one, exactly which channels it allows and whether it’s marked to decline incoming work. Don’t trust the label. “Available – Chat Only” doesn’t restrict anything unless the configuration says so. This takes maybe thirty minutes and it’s the single highest-value audit you can run on a struggling routing setup.

    Capacity models that were right on day one

    Capacity-based routing works well until the assumptions behind it stop holding. A common one: capacity weights were set when the average case took four business days to resolve. Then the team restructured and a chunk of cases started reopening within hours, sometimes the same day. The capacity model never changed. It still assumes a case closes and frees up capacity. In practice, agents are juggling three or four reopened cases that look closed to the model but are very much open to the customer.

    The result is agents who look underutilised on paper, routing more work to them, while they’re actually buried. This one’s hard to catch from the routing side because every signal routing looks at says the agent has room. You have to go looking at reopen rates and average handle time by case type, not at the routing config itself.

    Queue membership that nobody owns

    Queues drift. Someone leaves the team and their manager removes their licence, but forgets to pull them off the three queues they were on. Someone joins and gets added to the “general” queue on day one, then never gets added to the specialised queues they were actually hired to work. Six months later, routing decisions are being made against a membership list that reflects an org chart from last year.

    This is the one I’d flag hardest, because it’s invisible until you specifically go look. Nobody gets an error. Routing just quietly sends slightly less work to the right people and slightly more to the wrong ones, and the gap compounds. A quarterly audit of queue membership against the actual team roster catches this before it becomes a pattern someone has to explain to leadership.

    A smaller version of the same problem

    Skills-based routing has the identical failure mode at a more granular level. Skills get assigned when someone’s onboarded and almost never reviewed again. An agent who moved off a product line two years ago is still being routed cases for it, because the skill record was never updated. Treat skills like permission sets: they need a review cadence, not a one-time setup.

    Routing configuration changes that never got tested

    This is the one that should worry admins most. Routing configuration in Salesforce is declarative, which makes it feel low-risk. It isn’t. A single change to routing priority, or to how overflow is handled after a time limit, can shift work distribution across an entire team, and there’s usually no automated test watching for that.

    Before any routing change goes to production, simulate it against a realistic volume pattern, not just a happy-path manual test with one case. Push through a burst of work that mimics a busy Monday morning and watch where it lands. Check the overflow path specifically: what happens when the primary queue is full and work has to spill somewhere else. That’s the path that gets touched least often in testing and breaks most often in production, usually at the exact moment volume spikes and you can least afford it.

    Where I’d actually start

    If routing feels broken and you don’t know why, don’t start with the routing configuration itself. Start with presence statuses and confirm they decline work when they should. Then check queue membership against the current team roster. Then look at capacity assumptions against actual reopen and handle-time data. In most cases I’ve worked, the routing engine was never the problem. It was faithfully executing rules built on assumptions that stopped being true a long time before anyone noticed.

  • Building a business case for a Salesforce enhancement, not just logging a ticket

    Most Salesforce enhancement tickets I’ve seen sit in a backlog for months, get bumped every sprint planning, then quietly disappear into a colder list somewhere. Not because the idea was bad. Because nobody ever wrote down what it was worth.

    A ticket describes a symptom. “Case assignment is slow.” “Reps re-enter the same data three times.” “The approval process takes two days when it should take two hours.” None of that tells a product owner or a steering committee why this fix matters more than the other fifteen things competing for the same sprint. A business case does that job. It’s the difference between an idea getting funded and an idea getting forgotten in a backlog nobody re-reads.

    Start with the metric, not the feature

    The instinct is to describe the solution first: “we need a new Flow,” “we should build a custom LWC for this screen,” “let’s add validation rules.” Stop there and you’ve written a technical spec, not a business case. Nobody outside the Salesforce team can evaluate whether a Flow is worth building. They can evaluate whether cutting case resolution time by 20% is worth three sprints of effort.

    Work backwards from a number the business already tracks, not one you invent for the pitch. Average handle time, case backlog size, order fulfilment cycle time, first-contact resolution rate, the number of manual steps in a process someone in ops complains about weekly. If leadership doesn’t already watch the metric, your business case has to do double duty: justify the fix and justify why anyone should care about the number in the first place. That’s a much harder sell.

    Three questions before you write anything

    • What number moves, and by roughly how much? Don’t chase false precision here. “Reduces average handle time by 60 to 90 seconds per case” is more credible than a suspiciously exact 73 seconds nobody can defend under questioning.
    • Who feels the current pain, and how often? A process that annoys one admin twice a month is a different case than one that costs every service rep four extra clicks on every single case.
    • What happens if this doesn’t get built? Sometimes the honest answer is “nothing much,” and that’s useful information too. It tells you to deprioritise it yourself before someone else does.

    What a real business case looks like

    I’ve worked with a contact-centre team where case assignment ran on a static round-robin queue, built years earlier when the team had a third of the headcount it has now. New agents got flooded with complex cases they weren’t trained for, senior agents sat idle between escalations, and average handle time had crept up for over a year with nobody able to say why. The fix, in Salesforce terms, was a skills-based Omni-Channel routing configuration with capacity weighting. Unremarkable, technically. Maybe two sprints of configuration and testing.

    The business case wasn’t “let’s configure Omni-Channel routing better.” It was: current average handle time is trending up 4% quarter over quarter, misrouted cases account for an estimated 15% of escalations, and every escalation costs the team roughly double the handling time of a case routed correctly the first time. Fix the routing logic, and the model predicted a measurable drop in escalation volume within two reporting cycles. That’s a sentence a delivery lead can put in front of a steering committee without translation.

    Size the cost honestly, including the boring part

    Every enhancement has a cost beyond the sprint estimate: testing, user training, change management, and the risk of breaking something that currently works fine. Leaving these out doesn’t make the business case stronger. It makes it fragile, because the first person who asks “what about regression testing on the existing automation?” exposes a gap you should have owned upfront.

    State the cost plainly. Two sprints of build, one sprint of testing and UAT, a half day of training for the team that uses it. If the enhancement touches a process with downstream integrations, say so and name what needs revalidating. A business case that admits its own cost is more credible than one that pretends the work is free, and it survives scrutiny from someone who’s seen enhancements blow their estimates before.

    Who actually reads this, and what they’re looking for

    A technical lead reading your case wants to know the approach is sound and the estimate is realistic. A product owner wants to know it fits the roadmap and doesn’t conflict with something already committed. Whoever controls budget wants one thing above all: a number they can defend to their own leadership later. Write for that last person first, because if they can’t repeat your case in their own words in a follow-up meeting, it won’t survive contact with prioritisation.

    That means cutting anything that only makes sense to someone who already knows Salesforce. “Reduces flow interview steps by consolidating three screen flows into one” is an implementation detail. “Cuts the time to process a standard order from four minutes to under ninety seconds” is a business case. Keep both versions if you need them, the technical detail for your own team, the outcome for everyone else, but don’t hand the wrong one to the wrong audience.

    The case that never got approved, and why

    I’ve also seen the other version: a well-built enhancement, genuinely useful, that never got funded because the request arrived as a two-line ticket asking for “improved data quality on the Account object.” True, probably, and completely unactionable for anyone deciding where to spend the next quarter’s capacity. Nobody could say what “improved” meant, what it currently cost the business to not have it, or what would change if it shipped. It sat in the backlog for over a year before someone finally reframed it around duplicate account records causing incorrect territory assignment, at which point it got approved in the next planning cycle without much debate.

    Same technical work, same team, same effort estimate. The only thing that changed was whether anyone reading the request could tell what it was worth.

    Write it short

    A business case for a Salesforce enhancement doesn’t need to be long. Half a page is usually enough: the metric, the current cost of not fixing it, the proposed change, the estimate including the boring parts, and what success looks like in numbers someone can check later. Anything longer starts competing with the reader’s patience instead of their attention.

    Log the ticket if you want a record. But if you want the enhancement built, write the case first. The ticket describes what’s broken. The business case is what gets someone to spend their limited capacity fixing it instead of the fifteen other things asking for the same slot.

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

  • Contact-centre metrics that Salesforce can actually move

    Most contact-centre dashboards are full of numbers nobody acts on. Average handle time goes up, someone sends an email about it, and by next quarter it’s back down and nobody’s sure why. Meanwhile the metrics that actually predict whether customers are getting a decent experience sit two tabs deep in a report nobody opens. If you’re running Service Cloud, the platform gives you real levers to move the numbers that matter. Most teams just aren’t pulling them.

    Start by throwing out the vanity metrics

    Average handle time is the classic offender. It’s easy to measure, it’s on every wallboard, and it tells you almost nothing useful on its own. An agent who spends four extra minutes on a call because they actually solved the problem is doing better work than one who transfers it to make their number look good. I’ve seen contact centres where AHT was treated as a KPI for individual agents, and the predictable result was more transfers, more repeat contacts, and a worse experience dressed up as efficiency.

    Occupancy rate has the same problem. High occupancy looks great until you realise it means agents have no time to update knowledge articles, no time for proper wrap-up, and no slack to absorb a spike. None of this means AHT and occupancy are worthless. They’re context metrics. They should never be the metric a team is optimising against.

    First contact resolution is where Salesforce earns its keep

    FCR is one of the few contact-centre numbers that correlates directly with customer satisfaction, and it’s also one Salesforce can genuinely move, not just report on. The lever isn’t a dashboard. It’s what the agent sees on screen in the first fifteen seconds of a case.

    Three things drive FCR up in practice:

    • A case layout that surfaces the customer’s history, open orders, and prior contacts without the agent hunting for them across three related lists
    • Knowledge articles surfaced contextually against the case record type and subject, not buried in a search box the agent has to remember to use
    • Guided flows for the handful of case types that make up most of your volume, so a newer agent follows the same resolution path a five-year veteran would take from memory

    Case swarming beats escalation for complex issues

    For the cases that genuinely need more than one person, escalation chains are slow. A case gets reassigned, sits in a queue, and the customer explains their problem again. Swarming, where you pull the right specialist into the existing case via Chatter or a quick huddle instead of reassigning ownership, keeps the case moving and keeps FCR intact even on the hard tickets. It’s a process change more than a technical one, but Salesforce needs to support it: visibility into who’s on the case, a place to record what was tried, and a way to close the loop without three separate handoffs.

    Backlog and case age tell you more than volume does

    Total case volume is mostly a function of how many customers you have and what’s happening in the business that week. It’s not something a Salesforce configuration change is going to move, and treating it as a performance number just creates pressure to close cases fast rather than close them properly.

    Case age and backlog, on the other hand, are almost entirely within your control. A case that’s been open for eleven days without an update isn’t a volume problem, it’s a routing or ownership problem, and it’s exactly the kind of thing Omni-Channel and assignment rules exist to prevent. The fix usually isn’t more agents. It’s routing configuration that actually matches how work arrives.

    Where routing configuration actually breaks

    In most orgs I’ve reviewed, the routing model was built for the org as it existed two or three reorganisations ago. Skills-based routing gets set up once, at launch, and then nobody revisits it as products change and teams split. The result is cases sitting in a queue that technically has capacity, assigned to a skill nobody’s mapped to the agents who actually handle it anymore. Aging case reports catch the symptom. Fixing the routing model catches the cause.

    Customer effort is the metric leadership actually cares about, even if they don’t call it that

    Customer Effort Score doesn’t get the airtime CSAT does, but it’s usually the better predictor of churn, and it maps cleanly onto things Salesforce controls: how many times a customer has to repeat information, how many channels they have to switch across to get an answer, how many times a case bounces between queues before it’s resolved.

    Every one of those is measurable in Salesforce today if you’re capturing channel switches and case reassignment history properly, which most orgs aren’t set up to do out of the box. It takes a bit of configuration: a field that increments on reassignment, a flow that logs channel transitions, a report type built to expose it. It’s not exotic work. It’s just work that usually loses out to whatever the next enhancement ticket is.

    What this looks like as an actual build, not a wish list

    If you’re trying to move these numbers rather than just report on them, the practical starting point is narrow: pick the two or three case types responsible for most of your volume, and build the layout, knowledge surfacing, and routing rules around those first. Don’t try to fix the whole case model in one release. A typical enterprise org has forty or fifty case record types and record pages, and most of the FCR problem lives in the five or six busiest ones.

    Instrument case age and reassignment count before you touch anything, so you’ve got a real baseline instead of a guess. Then make one change at a time. Swap in contextual knowledge for the busiest case type, watch FCR for that type over two or three weeks, then move to routing. Contact-centre leadership teams that see one number actually move because of a specific change trust the next change a lot more than the ones who get handed a redesigned dashboard and told to trust the process.

  • The real cost of technical debt in an org that’s been customised for a decade

    Every Salesforce org older than five years has the same conversation eventually. Someone asks why a simple change, adding one picklist value, say, takes three days and touches four different automations. The answer is never one thing. It’s a decade of reasonable decisions that stopped being reasonable once they piled up.

    I’ve walked into more than one org where the object model told the story of the company’s history better than any wiki did. A field called Legacy_Region__c that nobody uses anymore but three validation rules still reference. A Process Builder flow built in 2019, replaced by a Flow in 2022, except the old one was never deactivated because someone was worried something depended on it. Nobody remembers what, so it stays.

    Technical debt in Salesforce doesn’t look like technical debt

    In a codebase, debt is usually visible: duplicated functions, missing tests, a file nobody wants to open. In Salesforce, it hides inside declarative tools that were designed to feel safe and reversible. That’s the trap. Flows, validation rules, page layouts and permission sets all feel low-risk to add. Almost nothing about the platform makes it feel expensive to leave them there once they’re outdated.

    So the debt accumulates quietly. A workflow rule from 2018 firing alongside a Flow built in 2023 doing something similar. Sharing rules layered on top of role hierarchy changes that never got cleaned up. A dozen record types on Opportunity, half of which one team uses and the other half inherited from a merger nobody talks about anymore.

    None of this shows up as an error. The org runs. Reports still generate. That’s exactly why it survives for years: there’s no exception in the debug log for “this design decision made sense in 2016 and now costs you two hours per release.”

    Where it actually accumulates

    A few places I check first on any long-lived org, roughly in the order the cost tends to show up:

    • Automation sprawl. Workflow rules, Process Builder, and Flow, often all three, doing overlapping things on the same object. Each one was the “right” tool when it was built. Nobody goes back and consolidates once the platform moves on.
    • Field bloat. Custom fields that were needed for a project that ended two years ago. Every extra field is a line item in every future data migration, every integration mapping, every user training session.
    • Permission model drift. Profiles built for a team structure that got reorganised twice since. Permission sets added on top instead of profiles being revisited, because touching a profile feels riskier than it is.
    • Integration middleware nobody owns. A point-to-point callout built for a system that’s since been replaced, still running, still consuming API calls, because decommissioning it means finding out what else might be quietly relying on it.

    The metric that actually shows the cost

    Forget “technical debt” as a vague feeling for a second. The number that tends to convince a skeptical stakeholder is release lead time: how long it takes from “we’ve agreed on the requirement” to “it’s live in production.” On a clean org, a small enhancement should move in days. On a decade-old org with unmanaged debt, I’ve seen that same class of change take three or four weeks, not because the work is hard, but because nobody’s confident what else it’ll touch.

    That’s the real cost. Not that the platform breaks. It’s that every change gets slower and every estimate gets padded, because the safe move is always “test everything” instead of “test the thing we changed.”

    Deciding what to fix and what to leave alone

    You can’t clean up a decade of decisions in one project, and trying to is how technical debt initiatives die. I generally sort candidates into three groups.

    Fix now

    Anything actively causing incidents or blocking current work. If a validation rule is throwing false positives for a live business process, or two automations are creating duplicate records, that’s not debt anymore, that’s a bug wearing debt’s clothes. Fix it regardless of whether there’s a broader cleanup initiative funded.

    Fix opportunistically

    Anything sitting directly in the path of a planned enhancement. If you’re already touching the Case object for a new requirement, that’s the moment to retire the two dead workflow rules on the same object, not six months later in a separate project that will never get prioritised on its own.

    Leave alone, but document

    Old automation that isn’t causing harm and isn’t in anyone’s way. The instinct to “clean everything” is expensive and rarely worth it. What matters more is that someone writes down what it is and why it exists, so the next person doesn’t spend half a day reverse-engineering intent from Apex comments that were never written.

    Making the business case

    The mistake I see most often is technical teams asking for “time to pay down technical debt” as a standalone ask. That request loses against a feature roadmap every time, because it has no measurable outcome attached to it.

    What works better: tie the cleanup to the thing leadership already cares about. If release lead time is the pain point, propose reducing it from three weeks to one, and name the automation consolidation work required to get there. If it’s onboarding time for new admins, tie the field and record-type cleanup to a specific reduction in ramp-up time. A composite example I’ve used before: an org with 40+ unused fields on Contact, cleaned down to the dozen actually referenced anywhere, cut the time for a new admin to confidently make a change from a couple of weeks to a few days, mostly because they stopped second-guessing what was safe to touch.

    What good looks like a year later

    You’ll know the debt work is paying off when small changes stop needing a war room. When someone can look at the automation on an object and understand, within ten minutes, what runs and why. When the person doing a release doesn’t need to test the whole org because they trust the boundaries of what they changed.

    That’s not a glamorous outcome to put in a project readout. It also happens to be the one that determines whether every future project on that org moves at a reasonable pace or grinds along at the mercy of a decade of decisions nobody’s revisited.

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