← Blog

Dependency Inversion in MuleSoft: business flows that own their abstractions

Code for this article: src/main/mule/dip · tests: dip-test-suite.xml

The Dependency Inversion Principle has two parts: high-level modules should not depend on low-level modules; both should depend on abstractions, and abstractions should not depend on details. The second half is the one people skip, and it is the one that makes the principle work.

What gets “inverted”

Without DIP, the business logic depends on the system: it knows the ERP’s field names, codes and quirks. With DIP, the business logic defines the model it needs — here, a canonical order { orderId, status, totalPrice } — and the ERP integration is written to conform to it. The dependency arrow now points from the detail to the abstraction, which is owned by the business side.

In Mule this is the idea behind API-led connectivity: process logic depends on a system API’s contract, and the system API hides the backend. The example shows the same thing inside one application, so it can run without a network.

Before: the business rule speaks ERP

<flow name="dip-before-get-order">
    <http:listener config-ref="http-listener-config" path="/dip/before/orders/{orderId}" allowedMethods="GET">
        <http:response statusCode="#[vars.httpStatus default 200]" />
    </http:listener>
    <ee:transform doc:name="Call legacy ERP (simulated)">...</ee:transform>
    <ee:transform doc:name="Business rule mixed with ERP mapping">
        <ee:message>
            <ee:set-payload><![CDATA[%dw 2.0
output application/json
var status = payload.STS_CD match {
    case "P" -> "PENDING"
    case "S" -> "SHIPPED"
    else -> "UNKNOWN"
}
---
{
    orderId: payload.ORD_NO,
    status: status,
    totalPrice: payload.TOT_AMT as Number,
    canCancel: payload.STS_CD == "P"
}]]></ee:set-payload>
        </ee:message>
    </ee:transform>
</flow>

The cancellation rule is written in terms of STS_CD == "P". Replace the ERP, add a cache, or stub the ERP in a test, and you are editing the business rule.

After: the rule depends on an “order repository”

<flow name="dip-after-get-order">
    <http:listener config-ref="http-listener-config" path="/dip/after/orders/{orderId}" allowedMethods="GET">
        <http:response statusCode="#[vars.httpStatus default 200]" />
    </http:listener>
    <flow-ref name="${orders.repository}" />
    <flow-ref name="dip-after-apply-order-rules" />
</flow>

<sub-flow name="dip-after-apply-order-rules">
    <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>

Two implementations return the canonical order: dip-after-order-repository-legacy-erp, which contains every ERP detail, and dip-after-order-repository-in-memory. orders.repository in config/app.yaml chooses one. Because the property is resolved when the app starts, this is a static reference — Studio and MUnit see it, unlike the dynamic routing in the Open/Closed example.

The evidence

The same assertions — pending order, can be cancelled, total 120; unknown order, 404 — pass for the before and after flows. Then two more tests call the ERP adapter and the in-memory repository directly and check they return the identical canonical order. That is the property DIP buys: implementations can change while the rule and its tests stay put. (The Liskov article covers what those implementations must honour to be swappable safely.)

What DIP is not

The first version of this article listed three techniques that are good practice but are not dependency inversion:

  • Externalising configuration (config-ref="${db.config}", endpoints in properties). This changes which database you talk to, not what the flow depends on — the flow still speaks SQL to a table.
  • Dynamic HTTP endpoints. Same thing: a configurable address for a concrete dependency.
  • Storing configuration in Object Store. Object Store is for runtime state, not configuration.

It also showed a query built as WHERE order_id = #[attributes.queryParams.orderId], which concatenates user input into SQL. In Mule 4, pass values as parameters:

<db:select config-ref="orders-db-config">
    <db:sql>SELECT * FROM orders WHERE order_id = :orderId</db:sql>
    <db:input-parameters>#[{ orderId: attributes.uriParams.orderId }]</db:input-parameters>
</db:select>

Checklist

  • Is the business rule expressed in the business’s terms, or in a system’s field names and codes?
  • Who owns the model the flow depends on — the business logic, or the backend?
  • Can you run the business logic against a stub implementation in a test?
  • Are database queries parameterised?

Wrapping up the series

Three of the principles carry over almost directly — Single Responsibility for flows, Interface Segregation for API contracts, Dependency Inversion for the relationship between business logic and systems. Liskov Substitution applies fully once you look at implementations behind a contract rather than at inheritance. Open/Closed is the weakest fit: useful where the set of variants really grows, and a cost where it doesn’t.

Every claim in these articles is backed by a test in mulesoft-solid-examples. Clone it, break something, and see which test notices.