Liskov Substitution in MuleSoft: implementations that can replace each other
Code for this article:
src/main/mule/lsp· contract:lsp-order-after.raml· tests:lsp-test-suite.xml
Barbara Liskov’s principle says that if code works with a type, it must keep working with any subtype of it. Mule has no classes and no subtypes, so it is tempting to decide LSP does not apply. It does — but you have to look for it in the right place.
Where LSP lives in integration work
The first version of this article mapped LSP to “order types”: standard, express and international orders returning consistent responses. That is really a contract-consistency rule, and it stretches the principle. The situation where substitution actually happens in Mule is this: several implementations sit behind one contract, and consumers are not supposed to know which one they got.
- A process API reading orders from an old ERP and, after a migration, from a new one.
- A system API with a production backend and a stub used in tests.
- Two regional systems exposed through the same experience API.
Liskov also insisted the contract is about behaviour, not just signatures. For an API that means three things: the shape of the data, the meaning of the values, and how errors are reported.
The contract
#%RAML 1.0 DataType
type: object
properties:
orderId: string
status:
enum: [PENDING, SHIPPED]
totalPrice: number
# And, in behaviour: an unknown order is a 404, never an empty 200.
A consumer flow relies on it to decide whether an order can be cancelled:
<sub-flow name="lsp-after-summarise-order">
<ee:transform>
<ee:message>
<ee:set-payload><![CDATA[%dw 2.0
output application/json
---
payload ++ { canCancel: payload.status == "PENDING" }]]></ee:set-payload>
</ee:message>
</ee:transform>
</sub-flow>
Before: an adapter that looks compatible and isn’t
The legacy ERP adapter maps its codes (P, S) to the contract. The new ERP’s adapter was written quickly: the new system already uses readable names, so it copies them across.
<sub-flow name="lsp-before-provider-new-erp">
<!-- the new ERP returns { id, state: "pending", amount: "120.00" } -->
<ee:transform>
<ee:message>
<ee:set-payload><![CDATA[%dw 2.0
output application/json
---
{
orderId: payload.id,
status: payload.state,
totalPrice: payload.amount
}]]></ee:set-payload>
</ee:message>
</ee:transform>
</sub-flow>
Every field name matches, so a schema check on names would pass. The behaviour does not:
statusis"pending", socanCancelbecomes false for an order that can be cancelled.totalPriceis the string"120.00", not a number.- An unknown order produces an empty 200 instead of a 404.
Nothing fails. The consumer returns a confident, wrong answer — the most expensive kind of integration bug.
After: both adapters honour the whole contract
<sub-flow name="lsp-after-provider-new-erp">
<ee:transform>...</ee:transform> <!-- call the new ERP -->
<choice>
<when expression="#[payload == null]">
<raise-error type="APP:NOT_FOUND" description="Order not found" />
</when>
</choice>
<ee:transform>
<ee:message>
<ee:set-payload><![CDATA[%dw 2.0
output application/json
---
{
orderId: payload.id,
status: upper(payload.state),
totalPrice: payload.amount as Number
}]]></ee:set-payload>
</ee:message>
</ee:transform>
</sub-flow>
The adapter now translates vocabulary, types and errors. Try GET /lsp/after/orders/ORD-1001?provider=legacy and ?provider=new: the answers are identical.
The evidence
The MUnit suite asks both providers the same questions. In the before version the new provider gives a different canCancel, a text price and a 200 for a missing order — each asserted explicitly. In the after version, the pending order, the shipped order and the unknown order produce exactly the same result from both providers. That is substitutability, tested.
Two nuances worth knowing
- Adding an optional field is usually safe. Consumers built as tolerant readers ignore fields they do not know. What breaks substitution is removing, renaming or retyping a field — or keeping the name and changing the meaning, as above.
- Inputs count too. An implementation that demands an extra mandatory field in the request is not substitutable, even if its responses are perfect.
Checklist
- Is there a written contract that every implementation is tested against?
- Does it cover value vocabularies (enums, units, formats), not just field names?
- Do all implementations report “not found” and invalid input the same way?
- Could you swap one implementation for another in configuration and see the same test results?