Open/Closed in MuleSoft: extending without editing, and what it costs
Code for this article:
src/main/mule/ocp· tests:ocp-test-suite.xml
Bertrand Meyer’s rule is that a module should be open for extension and closed for modification: you add behaviour by adding code, not by editing code that already works and is already tested. Of the five principles, this is the one that translates least cleanly to Mule, and it is worth being clear about why.
What it means in a Mule application
In an object-oriented language you extend behaviour with a new class that implements an existing interface. Mule has no interfaces, so the closest equivalent is: the flow that decides stays the same, and new behaviour arrives as new flows plus configuration.
The example is a payment endpoint. It supports credit card and bank slip, and the business now wants pix.
Before: a router you edit for every method
<flow name="ocp-before-process-payment">
<http:listener config-ref="http-listener-config" path="/ocp/before/payments" allowedMethods="POST">
<http:response statusCode="#[vars.httpStatus default 200]" />
</http:listener>
<choice>
<when expression="#[payload.paymentMethod == 'creditCard']">
<flow-ref name="ocp-before-process-credit-card" />
</when>
<when expression="#[payload.paymentMethod == 'bankSlip']">
<flow-ref name="ocp-before-process-bank-slip" />
</when>
<otherwise>
<raise-error type="APP:BAD_REQUEST"
description="#['Unsupported payment method: ' ++ (payload.paymentMethod default 'none')]" />
</otherwise>
</choice>
</flow>
Supporting pix means a new when in a router that every payment goes through. The edit is small, but it reopens a flow that the credit-card and bank-slip paths depend on.
After: the router looks the implementation up
# config/app.yaml
payment:
flow:
creditCard: "ocp-after-process-credit-card"
bankSlip: "ocp-after-process-bank-slip"
pix: "ocp-after-process-pix"
<flow name="ocp-after-process-payment">
<http:listener config-ref="http-listener-config" path="/ocp/after/payments" allowedMethods="POST">
<http:response statusCode="#[vars.httpStatus default 200]" />
</http:listener>
<set-variable variableName="paymentFlow"
value="#[output application/java --- p('payment.flow.' ++ (payload.paymentMethod default 'none'))]" />
<choice>
<when expression="#[isEmpty(vars.paymentFlow)]">
<raise-error type="APP:BAD_REQUEST"
description="#['Unsupported payment method: ' ++ (payload.paymentMethod default 'none')]" />
</when>
</choice>
<flow-ref name="#[vars.paymentFlow]" />
</flow>
Pix was added as one sub-flow and one line of YAML. The router did not change, and the configuration doubles as an allow-list: a method that is not configured is rejected before anything runs.
Two details that are easy to get wrong in Mule 4, and that the first version of this article did get wrong:
p()insideset-variableneedsoutput application/java. Without it, the runtime cannot choose between the Java and JSON media types in scope and fails with “Unable to infer a output media type”.payload.paymentMethodcan be null.++ nullis an error in DataWeave, hence thedefault 'none'.
The evidence
The suite runs credit card, bank slip and an unknown method against both routers, with identical results. Then it adds the case that matters: pix is rejected by the before version and accepted by the after version — with no change to the after router.
The costs — and why this is only a partial fit
- Studio cannot validate a dynamic
flow-ref. A typo in the YAML is found at runtime, not at design time. - MUnit needs a workaround. MUnit loads only the flows it can see referenced, so a dynamically referenced flow is missing unless something references it statically. The suite does that in a
before-suite. It works, but it is test code that exists because of the design. - “Closed” is relative. You still edit a file — the YAML. The gain is that the edit is in data, not in the flow that every payment executes, and a new method cannot break an existing one.
- For a stable, short list, the
choiceis fine. If payment methods change once a year, the explicit router is easier to read and to debug. Reach for dynamic routing when the list really does grow, or when different teams own different implementations.
Checklist
- Would adding a new variant edit a flow that all existing variants run through?
- If you use dynamic routing, is the configuration the allow-list, and are unknown values rejected?
- Is there a test for each configured implementation, not just the router?
- Is the extra indirection justified by how often the list changes?