A while back I was building a Customer 360 Process API for a financial-services client. The mobile app team wanted one clean endpoint — GET /customers/{cif}/360 — that would return a customer's profile, their accounts from the core-banking system, their investment holdings from a wealth platform, their mortgage from the loan-origination system, and quietly drop an audit record into the logging pipeline on every call. One request in, one assembled view out. Simple from the app's perspective, four backend systems behind the curtain.
The naive way to build that is to cram everything into a single flow. I've inherited those flows from other people and they are miserable: a 600-line XML file nobody dares touch, the same customer-ID validation copy-pasted across four endpoints, and an audit-format change that means editing eight places. Worse, you can't test one branch without deploying the whole application.
Mule 4 hands you three building blocks to avoid exactly this, and confusingly they all read like "flows": the public <flow>, the <sub-flow>, and the private flow (which is just a <flow> with no source). They are not interchangeable. The differences come down to two questions — does it share my context, and does it own its own error handling — and once you internalize those two axes, the right choice is usually obvious.
The two axes that actually matter
A public flow is what most people picture: it starts with a source (an HTTP listener, a scheduler, a JMS listener), runs its processors, and has its own <error-handler> block. It owns an independent context — its variables, payload, and attributes belong to that instance. This is your endpoint, your scheduled job, your real entry point. You can also call it from another flow via a Flow Reference, in which case the source is simply ignored.
A subflow has no source and, crucially, no error handler of its own. It inherits the caller's context completely — the parent's vars, payload, and attributes are all visible and mutable. Set a variable inside a subflow and the parent sees it the moment control returns. It runs inline, like a macro expansion, with no new transaction boundary. Any error it raises bubbles straight up to the parent's handler. Reach for a subflow when you want stateless reuse: shared validation, a common transform, the kind of logic you'd otherwise copy-paste and then forget to maintain in one of the copies.
A private flow looks like a subflow — no source — but behaves like a public flow on the inside. It has its own error handler and its own isolated context. Variables you mutate inside it do not leak back to the parent after it returns. That isolation is the whole point: it's a self-contained unit whose failures it can decide to swallow or propagate on its own terms. Audit logging, notifications, fire-and-forget side effects, and per-system API callers all belong here.
So the mental model I use: subflow means "inline me and share everything"; private flow means "I'm my own little boundary, hand me what I need and I'll return a result"; public flow is a private flow that also happens to have a front door.
Wiring them together with Flow Reference
All three get glued together with <flow-ref name="..."/>, which pauses the current flow, runs the target, and resumes. The behavior difference between calling a subflow versus a private flow is where people get tripped up.
When my endpoint calls the validation subflow, it's a literal inline expansion. The subflow reads vars.cif even though it never declared it, because it's the parent's context. If validation fails and the subflow does <raise-error type="APP:INVALID_CIF">, that error lands directly in the endpoint's error handler. There is no intermediate catch — the subflow has no handler of its own.
When that same endpoint calls the audit private flow, the audit logic can read context that was passed in, but anything it mutates stays local. And if the logging backend is down and the audit flow's on-error-continue swallows the connectivity error, the parent never finds out and the customer still gets their 200. That is exactly what you want for a non-critical side effect — a flaky log sink must never take down the main request.
The other piece worth knowing early is target and targetValue. When I want to capture a flow's output without clobbering the main payload, I write <flow-ref name="get-accounts-flow" target="accounts" targetValue="#[payload]"/>. The original payload is untouched and vars.accounts now holds the result. This becomes essential the moment you orchestrate several calls.
Orchestrating the downstream calls
The Customer 360 endpoint has to hit three independent system APIs keyed on the same customer ID. Done sequentially, three roughly 300ms calls cost you about 900ms. Since none of them depends on another's output, running them in parallel with <scatter-gather> brings that down to roughly the slowest single call. Each route just invokes a private flow that does one HTTP request to one backend.
<scatter-gather>
<route><flow-ref name="get-accounts-flow" target="accounts" targetValue="#[payload]"/></route>
<route><flow-ref name="get-holdings-flow" target="holdings" targetValue="#[payload]"/></route>
<route><flow-ref name="get-mortgage-flow" target="mortgages" targetValue="#[payload]"/></route>
</scatter-gather>
Keeping each system caller in its own private flow pays off in two ways: every backend gets a clean, independently testable unit you can mock in MUnit, and you get a natural place to attach a per-system error policy later. Sequential orchestration — where one call genuinely needs another's output, like fetching an account ID before querying its transactions — is just flow-ref processors stacked vertically instead.
The trap with scatter-gather is that it fails fast by default. If the wealth platform has its once-a-day hiccup, the whole scatter-gather throws MULE:COMPOSITE_ROUTING — even though the other two routes succeeded — and your entire 360 view collapses. You have to make a deliberate decision here. Fail-fast (return a 503 if anything is missing) is sometimes the correct business choice. More often, graceful degradation is better: give the customer their accounts and mortgage with holdings shown as empty rather than an error page. You get that either by adding an on-error-continue to the per-route flow so a failing route returns [], or by catching COMPOSITE_ROUTING at the parent and parsing the partial results out of the error object to assemble a 206-style partial response. Whichever you pick, write it down in a comment — the next engineer will not guess your intent from the absence of an error handler.
The gotchas that actually bite
The single most common confusion is "my variable disappeared after the private flow." Someone sets vars.x inside a private flow, returns, and finds it null in the parent. That's not a bug, that's the isolation contract — the parent's vars went in as a copy. If you genuinely need the result back, use flow-ref with target; if you need shared mutable state, you wanted a subflow, not a private flow.
The mirror image bites too: a subflow that quietly rewrites payload to { valid: true } leaves the parent continuing with that instead of the original message. Because subflows share context, payload mutations are visible outside. My rule is that validation subflows only ever set vars — never touch the payload unless transforming the payload is the explicit, named purpose of that flow.
A couple of structural ones round it out. Recursive flow references — flow A calls B, B accidentally calls A — deploy cleanly and then blow the JVM stack at runtime, because flow-ref is a call stack, not an asynchronous message. Sketch your dependency graph before deploying, and if you truly need recursion, push it through the VM connector so it doesn't sit on the stack. And resist the urge to wrap your audit private flow in <async> purely to shave latency: an async block spawns its own thread and its errors never bubble back, so a failing audit becomes a silent gap with no log at all. If you do go async, give the block its own error handler that at least writes to a local file.
The principle underneath all of this is small: pick the flow type by the contract you want, not by what compiles. Decide up front whether a piece of logic should share your context or stand alone, and whether its failures are yours to handle or someone else's to ignore — answer those two questions honestly and the orchestration almost designs itself.
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.








