SapotaCorp

Choice router in Mule: conditional routing that stays readable

A single customer-360 endpoint that fans out to three different banking backends sounds trivial until the data fights back. Here's how the Choice router actually behaves in Mule 4, the null-safety and ordering traps that turn a clean route into a silent bug, and when you should reach for First Successful or Round Robin instead.

Choice router in Mule: conditional routing that stays readable

Key takeaways

  • The Choice router evaluates when-branches top to bottom and runs exactly one — the first that matches — so order your conditions specific-to-general, because a broad condition placed first will silently starve every narrower branch beneath it.
  • Always give a Choice an Otherwise branch that does something deliberate, usually raising an error; without it, an unmatched payload passes through untouched and breaks a downstream component instead of failing where the routing decision was made.
  • Every when-expression touching external data must be null-safe and type-aware: use the default operator or the ?. chain, normalize string case, and coerce stringified numbers before comparison, or you'll get a Cannot coerce Null runtime error thrown right at the Choice scope.
  • Choice is for routing on data conditions only — use First Successful for failover between a primary and backup backend, and Round Robin for application-level load distribution; bending Choice into those roles is a semantic anti-pattern that won't actually catch errors or balance load.

A while back I was building a customer-360 API for a bank. The brief looked deceptively simple: expose one endpoint, GET /customers/{cif}/360, and have it return a unified view of the customer regardless of who they are. The catch was that the data didn't live in one place. Wealth clients were served by a separate wealth-management platform, retail customers lived in the core-banking system, and insurance policies sat behind a partner gateway run by a joint-venture insurer. The customer's segment — WEALTH, BANKING, or INSURANCE — was the thing that decided which backend held the truth.

The tempting wrong answer is to expose three endpoints and make the mobile app figure out which one to call. That leaks business logic out to the client, forces the app to query the customer's type first, and adds a round trip before any useful work happens. The client should not need to know that wealth and retail customers live in different cores. That's our problem to hide, not theirs.

So the design is one endpoint, a lookup of the customer's type from the CIF database, and then a routing decision inside the integration layer. In Mule 4 that routing decision is the Choice router, and most of this article is about the ways it bites you in production once real data starts flowing through it.

How Choice actually evaluates

Choice is Mule 4's if / else if / else. You give it a sequence of <when> branches, each guarded by a boolean DataWeave expression, plus an optional <otherwise>. The runtime reads the first <when>, evaluates its expression, and if it's true it runs that branch and stops — every remaining when and the otherwise are skipped. If it's false, it moves to the next one. If nothing matches and there's an otherwise, that runs.

The mental model that trips people coming from C or Java is fallthrough. There isn't any. Choice is not a switch where cases cascade until a break. Exactly one branch runs, full stop, and Mule effectively breaks for you after the first match. For the customer-360 router that's exactly what we want:

<choice doc:name="Route by customerType">
    <when expression="#[payload.customer_type == 'WEALTH']">
        <flow-ref name="get-wealth-360"/>
    </when>
    <when expression="#[payload.customer_type == 'BANKING']">
        <flow-ref name="get-banking-360"/>
    </when>
    <when expression="#[payload.customer_type == 'INSURANCE']">
        <flow-ref name="get-insurance-360"/>
    </when>
    <otherwise>
        <logger level="ERROR"
                message="#['Unknown customer_type=' ++
                         (payload.customer_type default 'NULL') ++
                         ' for cif=' ++ payload.cif]"/>
        <raise-error type="APP:INVALID_CUSTOMER_TYPE"
                     description="customer_type must be WEALTH, BANKING, or INSURANCE"/>
    </otherwise>
</choice>

Each flow-ref points at a sub-flow that knows how to talk to one backend — the wealth platform, the core-banking system, the insurance partner. The Choice itself stays a thin, readable routing table, which is the whole point.

The Otherwise branch is not optional

The single most expensive thing you can do with a Choice is leave off the otherwise. Here's the failure mode: every when evaluates to false, there's no fallback, and the Choice simply does nothing. The payload flows straight through, unmodified, into whatever comes next. The bug is silent because nothing errors — a downstream transform just receives a payload in the wrong shape because the branch that was supposed to reshape it never ran.

I treat a missing otherwise as a code-review reject. And it shouldn't be a quiet pass-through either; it should be a deliberate error path. In the router above, an unknown type logs the offending customer_type and cif, then raises an error that the global error handler turns into a 400. When a customer with a corrupted, null type came through, the logs told the exact story — three false evaluations, then the otherwise, then a logged customer_type=NULL for cif=.... That's the difference between a five-minute diagnosis and an afternoon of guessing.

What you should not do is invert this and use otherwise as a real branch — handling one special case in a when and dumping the main logic into the fallback. The primary paths deserve explicit conditions so the flow reads like documentation.

The gotchas live in the when-expressions

Every when expression must return a boolean, and it's DataWeave inside #[...]. That part is easy. The part that generates production incidents is that these expressions run against data you don't fully control, and DataWeave is unforgiving about nulls and types.

Null safety comes first. A direct comparison like null == 'WEALTH' is fine — it just returns false. The danger is reaching deeper. An expression like #[payload.customer.address.city == 'Hanoi'] throws Cannot coerce Null to Object the moment customer or address is null, and the stack trace points at the Choice scope, which sends you hunting in the wrong place. Guard every traversal, either with the default operator or the safe-navigation chain:

<when expression="#[(payload.customer?.address?.city default '') == 'Hanoi']"/>

Type mismatches are the next one. Databases and JSON payloads love to hand you "1000000" as a string. The instant you write #[payload.amount > 1000000] against a string, you get Cannot compare String with Number. Either coerce at the comparison — #[(payload.amount as Number {format: '#'}) > 1000000] — or, better, normalize types in a Transform Message at the top of the flow so the Choice never has to think about it.

And then case sensitivity, which is the sneakiest because it doesn't throw. If your when checks for 'WEALTH' but the database stored wealth, the comparison is just false, the request falls into otherwise, and the customer reports that "the API suddenly stopped working" while everything looks healthy in the code. Normalize with upper(payload.customer_type default '') in the expression, or clean the whole payload up front.

There's one more that catches people inside a For Each. Within the loop, payload is the current item, not the original message. If you route on #[payload.customerType] inside a loop over transactions, and the field actually lives on the parent object, the expression is always false. The fix is to capture what you need into a variable before the loop and reference vars.customerType inside.

Order matters, and so does staying flat

Because it's first-match-wins, the ordering of your when branches is part of the logic, not cosmetic. Put a broad condition first and you starve everything below it. A classic version: a branch guarded by balance > 0 placed above one guarded by balance > 1000000000 means the VIP branch never runs, because any VIP also satisfies the broader condition first. The rule is specific-to-general, top to bottom — the narrowest, most special conditions go up top, the catch-most conditions go at the bottom, and the true catch-all is otherwise.

The other readability killer is nesting. When a rule depends on several dimensions — say, channel and segment and loan amount — it's natural to nest Choice inside Choice inside Choice. Three levels deep, with thresholds buried in the innermost branches and the same structure copy-pasted across channels, you've built something nobody can safely change. My rule of thumb: more than two levels of nesting means stop and refactor. Usually the fix is to pull the decision out into DataWeave and flatten the Choice. Compute the actual decision once:

<ee:set-variable variableName="requiresManualReview">
    <![CDATA[%dw 2.0
    output application/java
    var thresholds = { "WEALTH": 5000000000, "BANKING": 500000000, "INSURANCE": 300000000 }
    var limit = thresholds[payload.customerType] default 100000000
    ---
    payload.loanAmount > limit]]>
</ee:set-variable>

Now the Choice collapses to two flat branches keyed on vars.requiresManualReview. The thresholds live in one place where the business can change them, the channel dimension turned out not to affect the decision at all so the outer Choice disappeared entirely, and you can unit-test the DataWeave on its own.

When Choice is the wrong router

Choice routes on data conditions you can evaluate before doing any work. It is not the tool for two other things people reach for it to do.

If your primary backend can fail and you need a backup, that's failover, and Choice can't express it because a when can't catch an error from a route it hasn't run yet. You want First Successful, which tries each route in order and stops at the first that doesn't raise an error. In the wealth scenario that's calling the primary platform, falling back to the disaster-recovery instance if it's down, and finally serving a cached snapshot from Redis. Choice with a #[primary failed] guard is an anti-pattern that simply won't work.

If you need to spread requests across several equivalent backend instances because there's no infrastructure load balancer in front of them, that's Round Robin, which alternates routes request by request. Choice has no notion of rotating state across requests. Picking the right router is about matching semantics: data condition means Choice, fallback means First Successful, load distribution means Round Robin.

A Choice router stays valuable exactly as long as it stays a readable routing table. The moment it starts catching errors, balancing load, or nesting three deep with inline thresholds, it has drifted away from what it's good at — and the fix is almost always to move that complexity into DataWeave or a more appropriate router, and let the Choice go back to doing the one clear thing it does well.


Building or operating MuleSoft integrations? Our Salesforce team designs API-led architectures, builds Mule flows, and runs them in production. Get in touch ->

See our full platform services for the stack we cover.

Engineering certifications

Sapota engineers hold credentials on MuleSoft. Each badge links to the individual engineer's credly profile.

Browse MuleSoft certs

Need this on your team?

Sapota engineers ship the patterns you read here. Two-week paid trial, direct pricing from $1,800/ engineer/month, no agency markup.

Get a quote
Contact Us Now

Share Your Story

We build trust by delivering what we promise – the first time and every time!

We'd love to hear your vision. Our IT experts will reach out to you during business hours to discuss making it happen.

WHY CHOOSE US

"Collaborate, Elevate, Celebrate where Associates - Create Project Excellence"

SapotaCorp beyond the IT industry standard, we are

  • Certificated
  • Assured quality
  • Extra maintenance

Tell us about your project