Single Responsibility in MuleSoft: one reason to change per flow
Code for this article:
src/main/mule/srp· tests:srp-test-suite.xml
The Single Responsibility Principle is usually quoted as “do one thing”. Robert Martin’s own wording is more useful: a module should have one reason to change. Later he sharpened it further — one actor who asks for the change. The difference matters in integration work, because “small” and “single responsibility” are not the same thing. A ten-line flow can still mix three reasons to change.
What it means in a Mule application
The unit is a flow or sub-flow, and a “reason to change” is usually a person or team:
- Validation rules change when the business changes what a valid order is.
- Pricing changes when finance changes how totals are calculated.
- The ERP call changes when the ERP, its endpoint or its payload changes.
If one flow contains all three, every one of those requests edits the same XML, and every release of that flow has to be retested for all three concerns.
Before: one flow, three reasons to change
<flow name="srp-before-process-order">
<http:listener config-ref="http-listener-config" path="/srp/before/orders" allowedMethods="POST">
<http:response statusCode="#[vars.httpStatus default 200]" />
</http:listener>
<choice>
<when expression="#[isEmpty(payload.orderId)]">
<raise-error type="APP:BAD_REQUEST" description="orderId is required" />
</when>
</choice>
<choice>
<when expression="#[(payload.quantity default 0) <= 0]">
<raise-error type="APP:BAD_REQUEST" description="quantity must be greater than zero" />
</when>
</choice>
<ee:transform doc:name="Price, send to ERP and build response">
<ee:message>
<ee:set-payload><![CDATA[%dw 2.0
output application/json
var total = payload.price * payload.quantity
---
{
orderId: payload.orderId,
totalPrice: total,
erpReference: "ERP-" ++ payload.orderId,
status: "SENT_TO_ERP"
}]]></ee:set-payload>
</ee:message>
</ee:transform>
<logger level="INFO" message="#['Order ' ++ payload.orderId ++ ' sent to ERP']" />
</flow>
It works. The problem is the transform in the middle: pricing and the ERP response are fused in one DataWeave script, so a pricing change and an ERP change are the same edit.
Note the validation uses raise-error. A common mistake — one I made in the first version of this article — is to “validate” with set-payload "Order ID is missing". That does not stop the flow; the invalid order carries on to pricing and the ERP.
After: the flow orchestrates, sub-flows own the changes
<flow name="srp-after-process-order">
<http:listener config-ref="http-listener-config" path="/srp/after/orders" allowedMethods="POST">
<http:response statusCode="#[vars.httpStatus default 200]" />
</http:listener>
<flow-ref name="srp-after-validate-order" />
<flow-ref name="srp-after-calculate-total" />
<flow-ref name="srp-after-send-to-erp" />
</flow>
<sub-flow name="srp-after-calculate-total">
<ee:transform>
<ee:message>
<ee:set-payload><![CDATA[%dw 2.0
output application/json
---
{
orderId: payload.orderId,
totalPrice: payload.price * payload.quantity
}]]></ee:set-payload>
</ee:message>
</ee:transform>
</sub-flow>
The main flow now reads like a description of the process. Each sub-flow has one owner: finance can change srp-after-calculate-total without the diff touching validation or the ERP call. The full file is in srp-after.xml.
The evidence
The MUnit suite runs the same three cases against both versions — a valid order, a missing orderId, a zero quantity — and both pass. That is the point: SRP is a refactoring, not a behaviour change. What changes is the cost of the next change, and how precisely you can test it: each sub-flow can be called directly from a test with flow-ref.
Where the principle stops helping
- Do not split for the sake of splitting. A sub-flow with one logger that exists “for SRP” adds navigation cost and no clarity. Split where the owners differ.
- Sub-flows are not reuse across applications. To share logic between Mule apps, publish it — as a Mule plugin or an XML SDK module in Exchange, or as its own API. Domain projects (Anypoint’s “shared resources”) share connector configurations, not flows.
- Error handling is a responsibility too. Keep it in one global error handler, as the example does in
global.xml, instead of copyingon-errorblocks into every flow.
Checklist
- Can you name the one team or rule that would make this flow change?
- Does the main flow read as a sequence of steps rather than a mix of details?
- Can each step be tested on its own?
- Does validation stop the flow when it fails?