Let's picture this: an Advanced Configurator that felt fast last week now takes seconds to respond. You added one more constraint – and the model chokes. The error message points at a constraint, the constraint looks correct, and a day disappears into logic that was never the problem.
Nine times out of ten, the cause is much simpler – and much more boring.
Somewhere in the model, a variable was declared without a domain. The solver is dutifully working through four billion possible values for an attribute that, in reality, takes one of ten.
This article walks through why that matters, what bounded domains look like across CML types, and how to spot the unbounded ones still hiding in your model.
A variable in CML is similar to an attribute in object-oriented programming, with one important addition: it carries a domain – the set of values it is permitted to hold.
The domain is not optional in the conceptual sense. Every variable has one. The only question is whether you defined it, or whether the engine inferred a default that is much wider than you intended. Take this declaration:
int discount;
To you, this is a quantity – probably a small positive number. To the Constraint Modeling Language engine, it is a 32-bit integer with the same range as int in Java – roughly 4.3 billion possible values. Adding a domain narrows it:
int discount = [1..10];
Now the variable can take exactly ten values. The solver knows that. The model is honest about it. And, most importantly, the search space the engine has to explore shrinks accordingly.
To understand why this matters in practice, it helps to recall what the constraint solver does behind the scenes.
CML is a declarative language – you describe what a valid configuration looks like, and the Constraint Rules Engine figures out how to get there. To do that, the solver:
.png)
When a chosen value turns out to be inconsistent, the solver discards it and tries the next one. That rollback is called a backtrack. A small number of backtracks is normal. Tens of thousands are not – Salesforce's debug log guidance considers under 1,000 backtracks with no violations as the marker of a healthy model.
The size of a variable's domain is one of the strongest predictors of how many backtracks the solver will perform.
Let’s take the simplest possible model: two integers that should sum to 4, with one bigger than the other.
type SolverExample {
int x;
int y;
constraint(x + y == 4 && x > y);
}
The logic is correct, the constraint is valid, and there are only two variables. But with x and y declared without domains, the solver has to consider every integer in the 32-bit range for each.
The result – over 50,000 backtracks, and the configurator gives up with Cannot satisfy constraints with given inputs. To a developer reading that log, the natural assumption is that the constraint is wrong. It is not – the domain is too wide.
Now, just for the sake of example, let's assume x can't be negative and won't realistically exceed 1,000. Bound it to that range:
int x = [0..1000];
The same model solves in 3 backtracks. y is still unbounded, but with x constrained, the solver propagates the relationship and never has to enumerate y exhaustively. Narrowing one variable narrowed the entire problem. The pattern generalises:
Wide domain → more candidate values → more attempts → more backtracks.
Narrow domain → fewer candidates → fewer attempts.
The more variables your model has, the more this effect compounds.
This one is closer to what you actually meet on a project. Consider a property investment bundle with a relation and three variables:
relation to one or more Property records,portfolioLoanAmount,downPaymentPercent,equityAmount.With one constraint: the equity has to exceed 1,000.
type PropertyInvestmentBundle : LineItem {
relation properties : Property;
decimal(2) portfolioLoanAmount;
int downPaymentPercent;
decimal(2) equityAmount = portfolioLoanAmount * downPaymentPercent / 100 * properties[Property];
constraint(equityAmount > 1000);
}
Run it as is, and the configurator times out:
"Number of Backtracks" : "2172398",
"Total Execution Time" : "10000ms",
...
"errorMessage" : "There's a goal execution error in your CML code:
IntComparison(GT,[DecimalVar(equityAmount=, 2)])"
"messageKey" : "ReachedTimeLimitDuringGoalExecutionBecauseOf"
Two million backtracks, ten-second timeout, and an error message that points at equityAmount – which can send a developer down a rabbit hole of checking whether the multiplication is wrong, or whether the constraint is malformed. Neither is. The model has too wide domains, and the solver is exploring billions of combinations.
The fix is to add bounds, one variable at a time, and watch the numbers move. Each step below is a single line of CML, informed by a question you can answer by talking to the business.
Talk to the business users. Ask how many properties a single bundle can realistically include. The answer in this case: between 1 and 10.
relation properties : Property[1..10];
That is one change, on one line. The new log:
"Number of Backtracks" : "191658",
"Total Execution Time" : "6520ms",
That is one change, on one line. The new log: backtracks down to 191,658, execution time 6.5 seconds – but already an order of magnitude better, and the solver is no longer treating 9,999 properties as a valid configuration.
Same conversation, applied to the other variables. A down-payment percent cannot be negative or above 100, and for this organization it lives between 15 and 45. A loan amount in this product line falls between 500 and 2,000.
decimal(2) portfolioLoanAmount = [500..2000];
int downPaymentPercent = [15..45];
The third version of the log:
"Number of Backtracks" : "147",
"Total Execution Time" : "1ms",
"solverStatus" : "SUCCESS"
As you see, that is the same model, same business logic, and same equityAmount formula. Three lines of domain definitions converted a configuration that timed out into one that solves in a millisecond.
The before-and-after, side by side:
This is the pattern most teams discover the hard way: that performance issues which feel architectural are often nothing more than missing domains.
.png)
Each CML data type has its own conventions for declaring a domain. The official reference is the Variable Data Types documentation; what follows is the practical view, with the choices that matter for solver performance.
A range, or a discrete list of allowed values.
@(defaultValue = "5") int defaultQty = [1..10];
int numberOfNozzlesPerZone = [1, 3, 5, 7, 10];
Use a range when any value in the interval is valid. Use a discrete list when only specific values are allowed – the solver will not waste effort on the gaps.
Two things matter here: the range and the scale (the number of digits after the decimal point).
decimal(2) TaxRate = [0.08..1000.00];
decimal(1) waterSystemPressure = [1.2, 2.0, 5.7, 10.0];
double(2) percentage = [0.00..100.00];
double(2) strictPercentage = [10.00, 15.25, 50.00, 100.00];
Scale multiplies the number of values the solver considers. A double(1) over [0..100] represents 1,001 candidate values. A double(2) over the same range represents 10,001 candidate values. A double(4) over the same range represents one million. Pick the smallest scale that actually reflects the precision you need downstream.
A string variable used as a picklist gets a fixed domain – the list of allowed options.
@(defaultValue = "Red") string color = ["Red", "Green", "Blue"];
For free-form text input that does not need validation, leave the domain empty – the solver will not enumerate it. If users should be restricted to specific values but you want them to type rather than pick, use a domain list anyway; the engine will reject anything outside it.
Multi-select picklists follow the same pattern, with the list defining the available options.
@(defaultValue = '["Red", "Green"]') string[] selectedColors = ["Red", "Green", "Blue"];
Dates take a range between two boundary values.
date shipDate = ["2023-01-01".."2026-12-31"];
date requestedDeliveryDate = ["2024-01-01", "2025-12-31"];
If a date can take only specific values (a fixed delivery calendar, for example), enumerate them rather than relying on a range.
Booleans have an implicit domain of true, false, and null – there is nothing to narrow.
@(defaultValue="true") boolean isActive;
Relations are not a variable type in the strict sense, but they have a domain too: their cardinality – how many child instances are allowed.
relation locations : Location[1..99];
The lower bound is 0 if the relation is optional, or 1 (or more) if at least one child is required. The upper bound depends on the business rule.
A subtlety worth knowing: if you do not declare cardinality on a relation, the engine applies a default upper bound of 9,999. That can be raised globally with maxRelationSize at the top of the model:
property maxRelationSize = 100000;
...
relation locations: Location[1..99999];
But raising it should be a deliberate decision. The default exists because letting the solver consider tens of thousands of relation instances is exactly the kind of thing that produces multi-million-backtrack runs.
Open the CML editor and walk through the model with these checks. None of them require special tools.
Search the model for int and decimal and double. Every line should have an = followed by either a range or a list. A bare declaration like int discount; is a flag.
Search for relation. Every relation should have explicit cardinality in [min..max] form. relation accessories : Accessory; is a flag – the engine will use 9,999 as the upper bound.
A decimal(4) attribute that always rounds to two decimal places downstream is forcing the solver to enumerate values nobody will ever care about. Match the scale to the actual precision of the data.
Strings without a domain are fine for free-form input that the solver does not reason about.
When in doubt, run a configuration session, pull the RLM_CONFIGURATOR_STATS block from the Apex debug log, and check Number of Backtracks and Total Execution Time. Numbers in the thousands or above are a signal that something in the model is unbounded somewhere – even if every individual declaration looks fine in isolation.
Bounded domains come with two caveats worth knowing before you start tightening every variable in sight.
On a green-field project, business hasn't yet decided how many properties a bundle can hold or what the maximum discount is. Educated guesses based on near-term scenarios are reasonable, but they need to be revisited – and the model has to be updated when business reality changes.
If a variable has been unbounded for a year, real configurations may exist that fall outside the bounds you are about to introduce. Before adding bounds to a production model, collect data on the values that have actually occurred, then set the new domain slightly wider than the observed range.
There is almost always a small slice of users with specific needs that fall outside the typical pattern – their configurations will break if you assume the 90% case is the whole picture.
The mitigation in both cases is the same: make domain definition part of the design phase, not the optimization phase. Catching an unbounded variable on day one of the project costs nothing. Catching it when the model gets more complex, after the configurator has been timing out for two weeks, costs a lot more.
Unbounded domains are easy to fix once you know where they are – the hard part is finding them in a model with hundreds of variables, where missing brackets buried three levels deep can take down the whole configuration.
That is what we look for in every Veloce CML audit: the small declarations with disproportionate impact.
What happens if I do not declare a domain at all?
For numeric types, the solver assumes the full range of the underlying Java type (about 4.3 billion values for int). Relations default to a cardinality of 9,999 unless raised with maxRelationSize. Strings without a domain are treated as free-form text and not enumerated.
Can a domain be too narrow?
Yes. If real configurations exist outside the bounds, the solver returns a constraint violation. Inspect actual data before tightening, and leave a small margin above and below the observed range.
Will narrowing the domain change which configuration the solver picks?
It can. Removing values from a domain may remove what would otherwise have been the chosen solution. If a specific configuration needs to be selected when several are valid, use defaultValue annotations – do not rely on the solver's internal ordering.
Where can I read more about CML performance?
The official starting points are CML Best Practices and Use the Apex Debugging Log File. On the Veloce side, our article on the five most common CML performance pitfalls covers domains as one of several patterns.