# Engineering a Salesforce Platform Beyond the Limits of Declarative Automation

> An assessment process where the arithmetic had to reconcile with a number produced outside Salesforce, the volume arrived in batches, and the rules changed every quarter. Writing code was the easy decision. Keeping it maintainable was the work.

- Source: https://synconai.com/case-studies/beyond-declarative-apex-and-lwc
- Publisher: SynconAI (https://synconai.com)
- Type: Case study
- Evidence: Illustrative scenario. This describes how SynconAI approaches the problem. It is not an account of one named customer, and no outcome is claimed as measured.
- Organisation: A financial services firm running a high-volume assessment process whose eligibility and pricing rules are revised on a quarterly cycle by a risk team that does not work in Salesforce
- Industry: Financial Services
- Products: Apex, Lightning Web Components, Custom Metadata, Sales Cloud
- Service: Salesforce Development (https://synconai.com/salesforce-development)
- Reading time: 12 minutes

## In short

An assessment process with batch-scale volume, a calculation that had to reconcile with a figure produced outside Salesforce, and rules revised each quarter was built as Apex behind a Lightning Web Component. The rules themselves stayed in custom metadata so a quarterly change is configuration, the calculation sits in a service layer testable without records, and bulk safety was designed in rather than reviewed for.

## Key points

- Deciding to write code is a two-line record: the requirement, the property that decided it, and what would make you revisit. Undocumented, it reads later as a developer preference.
- Rules that change on a schedule belong in custom metadata, not in classes. If a quarterly policy revision needs a release, the code has absorbed something that was never code.
- A calculation that cannot be tested without creating records is not a calculation, it is a database procedure. Keep queries and writes out of the layer that does the arithmetic.
- Custom code the client cannot maintain is a liability you created. Handover is a design constraint from the first class, not a document written at the end.

## Where each responsibility lives

Four layers, each with one job and an explicit list of what is not allowed to enter it. The component renders the assessment and captures what the assessor does, and holds no rule about what any of it means. The service performs the calculation on plain in-memory objects, so it can be exercised in a test without a single record existing. Domain and data access own every query and every write, which is what makes bulk safety a property of the design rather than a habit of whoever wrote the method. The configuration layer holds the rules themselves as custom metadata records, so a quarterly policy revision is edited by the process owner rather than released by an engineer. Requests travel down and results travel back up. Nothing skips a layer.

### 1. Experience: Lightning Web Component

- Assessment workbench component
- Apex controller with a single entry point
- Client-side validation and formatting
- Keyboard, focus and screen reader behaviour

Guarantees the interaction and the accessibility contract. No rule, no threshold and no arithmetic may live here, because anything that does becomes untestable and invisible to the process owner.

### 2. Service: the calculation

- Assessment scoring service
- Request and result value objects
- Rule selection by effective date
- Pure in-memory logic, collections in and collections out

Guarantees a deterministic answer for a given input and rule set. No query, no write, no reference to the running user and no knowledge that a UI exists.

### 3. Domain and data access

- Assessment trigger handler
- Selector classes for every query
- Repository for every write
- Collection-shaped methods only

Guarantees that every read and write is bulk safe and happens in one known place. No business rule may leak in, or the rule becomes something only a database can answer.

### 4. Configuration: custom metadata

- Scoring rule records
- Band threshold records
- Effective-from dates on every row
- Owned and edited by the risk process owner

Guarantees that a quarterly rule change is a record edit with an audit trail rather than a code release. No behaviour may hide here: the rows are values, and the logic that reads them stays in the service.

## The problem, as it actually presented

The request arrived as a screen. "We need somewhere an assessor can work through a case and see the decision as they go." Estimates for that shape of request are usually wrong, because the screen is the visible part and the rules behind it are the expensive part.

What sat behind it was an assessment run at volume every working day. Cases arrive two ways: assessors typing while a customer waits, and an overnight feed that lands a large batch in one transaction window. Attached to each case is an eligibility and pricing outcome, and that outcome has to agree with a figure the risk team produces independently, using their own model, outside Salesforce. When the two disagree the conversation is not about software. It is about which number the business told a customer.

The rules producing that outcome are revised on a quarterly cycle. The risk team does not think of what they publish as configuration. They think of it as a policy document, it is approved by a committee, and it carries a date from which it takes effect. Cases assessed before that date must keep being explainable under the old rules.

The existing implementation was declarative, and it had been the right answer when it was built for a fraction of the current volume with a simpler rule set. It had stopped being the right answer for reasons that were specific rather than general: the overnight batch had begun to fail intermittently in ways nobody could reproduce, the outcome had drifted from the risk team figure at the edges of two bands, and every quarterly revision now took a fortnight of careful clicking that nobody wanted to own.

::: warn
The tell was not the failures. It was that a quarterly policy change had become a project. When routine configuration needs a project plan, the thing being configured has outgrown where it lives.
:::

## Why this was a code problem rather than a preference

We treat the build decision as a written record rather than an instinct, for the reason set out in [Flow or Apex? Write the decision down before you build](/insights/flow-or-apex-write-the-decision-down): undocumented, it reads a year later as somebody having preferred an editor to a canvas.

Three properties pushed this one over the line, and none of them was that the requirement looked complicated.

**Exactness with an external oracle.** The outcome must reconcile with a number produced elsewhere. That turns the requirement into a specification with an answer key, and a specification with an answer key wants tests that run automatically on every deployment, not a canvas somebody eyeballs before a release.

**Volume arriving in bulk.** A daytime assessor saving one case at a time is not a demanding workload. A nightly feed that lands the day backlog at once is a different requirement wearing the same clothes. Every automation on that save shares one per-transaction budget for queries, record changes and processing time, and that budget is communal across everything the platform runs in the save rather than allocated per component. The current values move between releases, so we check the [Salesforce developer documentation](https://developer.salesforce.com/) rather than a figure somebody recalls from a certification.

**A calculation that must be testable in isolation.** The risk team publishes worked examples. Being able to run those examples against the implementation, in seconds, without standing up an org full of records, is worth more than any argument about which tool is more modern.

What did **not** decide it was the branch count. The rule set is large and it is fully enumerable, and enumerable rules are exactly the case for keeping things declarative, as we argued in [Flow or Apex: the criteria applied to ten real requirements](/insights/flow-vs-apex-when-to-use-each). That is why the decision was not "build it in Apex". It was to split the requirement, and to be specific about which part went where.

## The rules do not belong in the code

The most common failure in this shape of build is that the policy ends up inside the classes. Thresholds become constants, bands become nested conditions, and the quarterly revision becomes a developer task with a release attached.

So the first design decision was that the rules leave the code entirely. Each rule row lives as a custom metadata record: the conditions it applies to, the values it contributes, and the date it takes effect from. The service reads them and applies them. It does not know what any particular row means, and it holds no threshold of its own.

Two consequences follow, and both were the point.

A quarterly revision becomes a record edit made by the process owner, with the platform recording who changed what and when. It moves through a sandbox first, it is reviewed, and it does not need an engineer, a release window or a regression cycle over unrelated code.

And because every row carries an effective-from date, the old rules do not disappear when the new ones arrive. A case assessed in March is still explainable under the March policy, which matters a great deal in a regulated product where somebody may ask about that decision much later. Effective dating was not a nice-to-have. It was the requirement that made the metadata design non-negotiable.

::: tip
Ask one question of any rule you are about to write into a class: who changes this, and how often? If the answer is a business owner, on a schedule, it is configuration wearing the clothes of code.
:::

## The service layer, and what it refuses to know

Underneath, the calculation sits in a service that is deliberately ignorant.

It receives plain in-memory objects describing the assessment inputs and the rule set that applies. It returns plain objects describing the outcome. It performs no query and no write. It does not know which user is running, whether a screen is open, or whether it was invoked by an assessor clicking, by a batch job, or by a test method.

That ignorance is what makes it testable. A test constructs the inputs, constructs the rules, calls the service and asserts on the result. No records, no setup data, no dependency on org configuration that somebody may change next month. The risk team worked examples become a test suite, and a change that breaks one of them fails visibly rather than surfacing in a reconciliation weeks later.

Around it, the arrangement is conventional and boring on purpose. Selector classes own every query. A repository owns every write. The trigger handler is thin, and its job is to assemble inputs, call the service and hand results back for persistence. Nothing else queries. Nothing else writes.

The rule that keeps this honest is expressed as a prohibition rather than a principle: no query and no write may appear in the service layer, and no business rule may appear in the data access layer. Both are easy to check in review, which is more than can be said for "keep it clean".

## Bulkification as a design property, not a review checkpoint

Bulk safety fails when it is treated as something you inspect for afterwards. By then the method signatures take one record, the callers pass one record, and making it bulk safe is a refactor rather than a fix.

So it is a property of the design instead. Every method that crosses a layer boundary takes a collection and returns a collection. There is no single-record variant to reach for, because a single record is a collection of one and the platform is genuinely happier that way. Queries are assembled once and filtered in memory rather than issued inside a loop. Writes are collected and performed once at the end.

The test suite enforces it. Alongside the worked examples there are tests that push a batch through the same path and assert the same outcomes, which catches the two classic regressions: a query that has crept inside a loop, and logic that quietly assumes the first record in the collection is the only one.

None of this required knowing a number. It required a shape. The shape is the control, and the platform limits are the reason the shape exists.

## The component we did build, and what it obliged us to

We build a Lightning Web Component when the standard experience genuinely cannot express the interaction, and not before. Here the standard record page could not, for a reason we could state plainly: the assessor works across several related records at once, changes an input, and needs the recalculated outcome and the reason for it in front of them immediately, without saving and without navigating away.

That is a real gap rather than a taste. So the component was justified. What follows from that justification is the part people underestimate.

Building your own control means taking on everything the standard controls do for free. Every interactive element is reachable and operable by keyboard, in an order that matches the visual one. Focus is managed deliberately when panels open and close, and it never disappears to the top of the document. The recalculated outcome is announced to assistive technology politely, rather than updating silently or interrupting mid-sentence. Status is never carried by colour alone. Contrast is checked rather than assumed. Errors are associated with the field they belong to, not floated somewhere near it.

We test this with a keyboard and with a screen reader, by hand, before it ships. Automated checks catch a useful share of issues and miss the ones that matter most, which are about whether the thing is usable rather than whether the markup is defensible.

The component itself holds no rules. It calls a controller, the controller calls the service, and the result comes back. Move a threshold into the component and you have made it invisible to the process owner and untestable outside a browser, twice over.

## Test design where coverage is not the goal

Coverage is a deployment gate. It is not a measure of whether the code is correct, and treating it as one produces suites full of tests that execute lines and assert nothing.

The suite here is organised around assertions instead.

The risk team worked examples are the backbone, each one a test that feeds documented inputs and asserts the documented outcome. Band boundaries get their own tests on both sides of every edge, because the edges are where a disagreement with the external figure actually appears. Effective dating is tested by running the same case against rule sets either side of a change date and asserting that both answers are the ones the policy says they should be. Bulk behaviour is tested as described above. And negative cases assert that bad input fails in a defined way rather than producing a plausible wrong number, which is the more dangerous outcome in a pricing calculation.

Read end to end, the tests describe what the system promises. That is the quality worth optimising for, because it is what makes the suite useful to the person who did not write it.

## The decisions that were contested

**Keeping the surrounding process declarative.** There was an argument for taking the whole thing into Apex while we were in there, on consistency grounds. We refused, because the process around the assessment is enumerable, it changes for business reasons, and the operations lead should be able to read it. Consistency is not worth removing a business owner from their own process.

**Not building a rules engine.** A general expression evaluator, so that any future rule could be written without a deployment, was proposed and was genuinely tempting. We argued against it. The rules are enumerable and their shape is stable, and a bespoke rules engine is a product with a syntax to document, errors to report and a maintainer to find. Custom metadata with typed columns answered the actual requirement. It would be a different conversation if the shape of the rules changed as often as the values.

**Effective dating rather than replacing rules in place.** Editing rows on the day the policy changes is simpler, and the team would have accepted it. We insisted on effective dates because a decision made under the old policy has to stay explainable, and reconstructing a superseded rule set from a deployment history is not an answer you want to give under scrutiny.

**Doing the accessibility work inside the estimate.** It was questioned as scope. Our position is that it is not a phase, and that a bespoke control which cannot be operated by keyboard is unfinished rather than lean.

## The handover problem

Custom code the client cannot maintain is a liability you created, and it is created at design time rather than at go-live.

Handover was therefore a constraint on the architecture. The most frequent change, the quarterly revision, touches no code at all. The next most likely change, a new rule shape, touches one class in one place. There is exactly one location where arithmetic lives, so nobody has to search for it.

What we wrote down alongside the code: which layer does what and what may never enter it, how to add a rule row and how to retire one, what to do when the risk team publish a revision, how to run the suite and read a failure, and the named signals that mean a change has crossed back into engineering territory rather than configuration. A client engineer walked the whole thing with us, then made a change in front of us, and that last part is the only part that counts.

## What changes

The results worth claiming here are operational, and they follow from the design rather than from effort.

| Before | After | What made the difference |
| --- | --- | --- |
| A quarterly policy revision took a fortnight of careful clicking | A revision is a metadata edit by the process owner, reviewed in a sandbox first | Rules left the automation and became records with effective dates |
| Superseded rules survived only in somebody's memory | Any past decision can be re-explained under the policy of the day | Effective-from dating on every rule row |
| The outcome could only be checked by running records through the org | The calculation runs against the risk team worked examples in seconds | A service layer that performs no query and no write |
| The overnight batch failed intermittently and irreproducibly | The batch path and the assessor path exercise the same collection-shaped code | Bulk safety designed into the signatures, not reviewed for afterwards |
| The screen and the rules were the same artefact | The component renders and captures, and holds no rule | Layer boundaries expressed as prohibitions a reviewer can check |
| Only the original builder could safely change anything | The most frequent change needs no engineer at all | Handover treated as a design constraint from the first class |

The second-order effect is the one the business notices. The conversation with the risk team changes shape. It stops being "when can development fit in the new policy" and becomes "the new policy takes effect on the first, here is the sandbox, check it".

## What we would tell you before starting

Write the decision down before anybody opens an editor. One paragraph naming the requirement, the property that made it code, and the condition that would make you revisit. Every custom build we are later asked to rescue is missing that paragraph, and without it the code reads as somebody's preference no matter how good it is.

Then separate the parts. The instinct to put the whole requirement in one place is what produces the unmaintainable build people fear. Rules a business owner revises are configuration. Arithmetic that must reconcile with an external number is code. Process an operations lead should be able to read is declarative. The same requirement can be all three, and usually is.

And be honest about when this is over-engineering. If your rules change once a year, if the volume is modest and arrives one record at a time, and if nobody outside the org produces a competing number, then the layered service, the metadata rule set and the bespoke component are all cost with no matching benefit. Flow was right. It is right most of the time. What earns this design is the combination of a schedule, a batch and an answer key, and if you cannot point at all three, build the simpler thing and revisit it when one of them turns up.

## Questions

### Does choosing Apex here contradict a declarative-first approach?

It does not, because most of the requirement stayed declarative. The process that surrounds the assessment is still a flow an admin can read, and the rules are still rows a business owner can edit. What went into code was one thing: an arithmetic result that has to match a figure produced outside Salesforce, at volumes arriving in batches. Declarative-first is a default, not a prohibition, and the honest version of it names what pushed a specific requirement over the line.

### Why custom metadata rather than a custom object for the rules?

Because rules are configuration that should travel with a deployment and be readable from Apex without spending a query, and custom metadata is the platform feature built for exactly that. A custom object turns rules into data: it needs sharing decisions, it consumes queries in the transaction that reads it, and it does not move between environments with the release. There are cases for a custom object, usually when business users need reporting or approvals over the rows, but the default here is metadata.

### What stops this becoming the unmaintainable custom build we were warned about?

Three things, none of them heroic. The rules live where a non-engineer can change them, so the most frequent change never touches code. The calculation is isolated, so a defect has one place to be. And the tests assert against the worked examples the risk team already produces, so a future change either reproduces those numbers or fails loudly. Unmaintainable custom builds are usually undifferentiated ones, where the rules, the plumbing and the screen are all the same tangle.

---

SynconAI. https://synconai.com/case-studies/beyond-declarative-apex-and-lwc
