A catalogue of design and integration patterns applied to Mule 4, one series at a time. Each series is a set of small, runnable flows with MUnit tests, and accompanies an article series on brunosouzas.com.
| Series | Status | Code |
|---|---|---|
| SOLID in MuleSoft | Complete | src/main/mule/solid/ |
| Enterprise Integration Patterns | In progress | src/main/mule/eip/ |
Everything runs in one Mule application and needs no external systems: ERPs, databases and payment providers are simulated with synthetic data, so it runs anywhere.
Requirements: Java 17, Maven 3.9+, Mule runtime 4.9 (Anypoint Studio or a standalone runtime).
- Anypoint Studio: import the project (File → Import → Anypoint Studio project from file system) and run it. The app listens on port
8081. - Try it: open
requests.httpin VS Code (REST Client) or IntelliJ and send the requests, or use curl:
curl -s 'localhost:8081/lsp/before/orders/ORD-1001?provider=new'mvn clean testMUnit runs on the Mule Enterprise runtime, so Maven needs access to the MuleSoft Enterprise repository (credentials in ~/.m2/settings.xml, as in any MuleSoft project). The build fails below 80% application coverage; the current suites cover 181 cases at about 98.8%.
Before-and-after examples of the five SOLID principles applied to Mule 4 flows. Each principle has a before endpoint that breaks the principle and an after endpoint that follows it, and MUnit tests show where the two behave the same and where the "before" version causes problems.
Articles: SOLID in MuleSoft (also available in Portuguese).
| Principle | Scenario | Before | After | Code | Article |
|---|---|---|---|---|---|
| Single Responsibility | Process an order | One flow validates, prices and calls the ERP | The flow orchestrates; each step is a focused sub-flow | solid/srp/ |
SRP |
| Open/Closed | Take a payment | A choice router that must be edited for every new method | The router looks up the implementation in config; pix was added without touching it |
solid/ocp/ |
OCP |
| Liskov Substitution | Read an order from the legacy or the new ERP | The new ERP adapter looks compatible but changes values and error behaviour, so the consumer silently takes wrong decisions | Both adapters honour the whole contract (shape, values, errors) and can replace each other | solid/lsp/ |
LSP |
| Interface Segregation | Read an order | One endpoint returns everything to everyone, including internal cost data | Mobile and finance each get an interface shaped for their need | solid/isp/ |
ISP |
| Dependency Inversion | Read an order and decide if it can be cancelled | The business rule knows the legacy ERP's field names and codes | The rule depends on an "order repository"; in-memory and ERP adapters are swapped in config | solid/dip/ |
DIP |
The MUnit suites are in src/test/munit/solid/ and the RAML contracts for LSP and ISP in docs/contracts/solid/. The HTTP endpoints keep their paths (/srp/..., /ocp/..., /lsp/..., /isp/..., /dip/...), so the calls shown in the articles still work.
Notes:
- The OCP router uses a dynamic
flow-ref. MUnit only loads flows that are referenced statically, so the OCP suite references the payment implementations in abefore-suite. Real applications do not need this. - Everything here is intentionally small. The point is the shape of the flows, not the business logic.
In progress. Patterns from Hohpe and Woolf's Enterprise Integration Patterns, implemented in Mule 4 around one scenario: an order is routed, charged and paid by a payment provider, its stock is reserved, and it is invoiced. Every runnable example uses HTTP, DataWeave and object stores only (plus the Aggregators module for the aggregator), so it needs no broker and no external system.
Wave 1, messaging and reliability:
| Pattern | Scenario | Code | Endpoints |
|---|---|---|---|
| Idempotent receiver | Charge an order once, however many times it is sent. Before: <idempotent-message-validator/> with no configuration uses the correlationId as the message ID, so a retrying client is charged twice and different orders sent from one For Each are rejected as duplicates. After: idExpression="#[payload.orderId]" with an explicit object store (24-hour TTL) |
eip/idempotent-receiver/ |
POST /eip/payments/before/charges, POST /eip/payments/after/charges, POST /eip/payments/{before|after}/charges/batch, GET /eip/payments/{orderId}/charges |
| Correlation identifier | Book meaning: after a charge, a payment request goes to a simulated provider carrying its own ID; the provider answers 202 and later calls back with that ID as correlationId, which is the only link back to the order. Callbacks can arrive in any order. The callback's status decides the outcome: APPROVED for the requested amount marks the order PAID, DECLINED marks it PAYMENT_DECLINED, and an APPROVED for another amount or any other status marks it PAYMENT_REVIEW (with reviewReason AMOUNT_MISMATCH or UNKNOWN_STATUS); all three answer 200. A callback with no pending request (unknown, expired, or a repeat of one already matched) gets 404. Mule meaning (kept separate): the HTTP requester sends the event's correlationId in X-CORRELATION-ID, unless sendCorrelationId="NEVER" |
eip/correlation-identifier/ |
POST /eip/provider/payments (provider), POST /eip/payments/callback, GET /eip/payments/{orderId}/status, GET /eip/correlation-id/call/{default|never}, GET /eip/correlation-id/echo |
| Publish-subscribe channel | Documented with Anypoint MQ configuration in the wiki, not runnable here: it needs an Anypoint MQ broker (exchange and bound queues) | — | — |
| Dead letter channel | Documented with Anypoint MQ configuration in the wiki, not runnable here: it needs an Anypoint MQ broker (queue with a dead-letter queue and a maximum delivery count) | — | — |
Wave 2, routing:
| Pattern | Scenario | Code | Endpoints |
|---|---|---|---|
| Content-based router | A Choice sends each order to exactly one handler, by content: amount at or above eip.router.reviewThreshold (1000) to manual review, a country other than eip.orders.homeCountry (BR) to international fulfilment, everything else to domestic fulfilment. The first matching when wins, so a high-value international order goes to review; otherwise makes the router total |
eip/content-based-router/ |
POST /eip/orders/route |
| Message filter | Only PAID orders may reserve stock. Before: the Choice "discards" an unpaid order, but the reservation sits after the Choice, so it still runs: the caller is told DISCARDED and the stock is reserved anyway. In the book a Message Filter is a Content-Based Router with a single output; a Mule Choice route ends and the flow continues. Mule 4 replaces Mule 3 filters with the Validation module (validation:is-true and the other validators raise VALIDATION errors), or a raise-error handled by on-error-continue. After: the reservation is inside the route that lets the order through |
eip/message-filter/ |
POST /eip/stock/{before|after}/reservations, GET /eip/stock/{orderId}/reservations |
| Splitter | An order is split into its items and each item's stock is checked. For Each: sequential; after the scope the payload is the original order again, results are collected in a variable, and variables set inside are still visible. Parallel For Each: concurrent; after the scope the payload is the list of route messages in input order, and variables set inside the routes are not visible | eip/splitter/ |
POST /eip/stock/availability/for-each, POST /eip/stock/availability/parallel-for-each |
| Aggregator | The warehouse confirms an order's items one request at a time; a group-based aggregator (Aggregators module 1.1.5) groups them by orderId with groupSize from the item's itemCount, and the item that completes the group gets the invoice (201; the others 202). An item for a finished group gets 409 (AGGREGATORS:GROUP_COMPLETED); a group whose items stop arriving times out after eip.invoice.groupTimeoutSeconds (5) and an aggregator listener records the invoice INCOMPLETE; its missing item then gets 409 (AGGREGATORS:GROUP_TIMED_OUT) |
eip/aggregator/ |
POST /eip/invoices/items, POST /eip/invoices/orders (client that splits an order and posts its items), GET /eip/invoices/{orderId} |
| Recipient list | The recipients of a new order are computed from it (invoicing always; warehouse for physical items; digital delivery for digital items; customs for physical items shipped abroad), and a For Each sends the order to each one over HTTP, with the path looked up in configuration (eip.recipientList.paths). Each delivery is in its own Try, so one failing recipient is recorded FAILED and the others are still notified. A partial failure still answers 200, with allDelivered: false: each notification stands on its own, the ones delivered are done and the failed ones are reported for a retry. Scatter-gather answers 503 instead, because its decision cannot be made without every reply. In the book, Scatter-Gather is a Recipient List plus an Aggregator |
eip/recipient-list/ |
POST /eip/orders/notifications, POST /eip/recipients/{recipient}/orders (simulated downstream systems), GET /eip/orders/{orderId}/notifications |
| Scatter-gather | Before an order is accepted, a Scatter-Gather runs three checks in parallel against simulated services (pricing, stock, fraud) and combines them: REJECTED when an item is out of stock, REVIEW for a high-risk customer, else ACCEPTED. When one route fails, the others still finish and Scatter-Gather raises MULE:COMPOSITE_ROUTING; its error.errorMessage.payload holds the results of the successful routes and the failures of the failed one, by route key, and the endpoint answers 503 with that partial result (INCOMPLETE) |
eip/scatter-gather/ |
POST /eip/orders/checks, POST /eip/services/{pricing|stock|fraud} (simulated services) |
Wave 3, transformation:
| Pattern | Scenario | Code | Endpoints |
|---|---|---|---|
| Message translator | A partner sends an order as CSV, one row per line; a Transform Message (DataWeave) turns it into this app's JSON order. The translation changes the structure (rows become one order with an items array), the types (CSV strings become numbers, cents become units) and the codes (partner product codes become SKUs through a lookup module). A row whose product code has no SKU, or rows of two orders, make the request 400 instead of producing an order that looks valid and is wrong | eip/message-translator/ |
POST /eip/orders/translate (application/csv) |
| Canonical data model | Orders arrive from the marketplace (JSON) and from a partner (XML purchase order) and go to invoicing (JSON) and the warehouse (CSV pick list). Every source translates into one canonical order and every target from it: N + M translators, as DataWeave modules in src/main/resources/dwl/canonical/, instead of N × M. With two and two both are 4; the gain is the next one, since a third source needs one translator, not one per target. The canonical order is validated once, whatever the source |
eip/canonical-data-model/ |
POST /eip/canonical/orders/marketplace, POST /eip/canonical/orders/partner (application/xml) |
| Content filter | A paid order carries the card, contact details, item costs, suppliers and internal notes. Before it goes to the (simulated) invoicing system, a Transform keeps only what invoicing needs, flattened (customerId, customerName, taxId, billingCountry, paymentReference, lines). It selects and flattens and computes nothing: no total, which is invoicing's job. The filter is an allow list: a field the order gains later does not reach invoicing until someone adds it to the transform |
eip/content-filter/ |
POST /eip/billing/invoice-requests, POST /eip/invoicing/invoices and GET /eip/invoicing/invoices/{orderId} (simulated invoicing) |
| Claim check | An order's document (base64) is checked in to an object store under a claim key; only { orderId, claimKey, fileName } is sent to the (simulated) archive, which checks the document out with the key. Checking out does not remove it: the claim is valid until the store's entryTtl (eip.claimCheck.ttlSeconds, 3 seconds here) ends, and then the key gets 404 (OS:KEY_NOT_FOUND turned into APP:NOT_FOUND) |
eip/claim-check/ |
POST /eip/orders/documents, GET /eip/orders/documents/{claimKey}, POST /eip/archive/documents (simulated archive) |
| Content enricher | An order with only a customerId gets the customer's name, tier and shipping address from a (simulated) CRM. Mule 4 has no enricher scope: the HTTP request stores the answer in a target variable (target="vCustomer", targetValue="#[payload.customer]"), so the order is still the payload after the call and a Transform merges the two. When the CRM is down (503, unreachable or timed out) the target variable is not set and the order is not passed on: 503 with enrichment: FAILED. An unknown customer is 400 |
eip/content-enricher/ |
POST /eip/orders/enrich, GET /eip/services/customers/{customerId} (simulated CRM; CUS-DOWN... answers 503) |
Wave 4, endpoints:
| Pattern | Scenario | Code | Endpoints |
|---|---|---|---|
| Request-reply | A stock reservation asked of the simulated warehouse two ways. Synchronous: the requestor's HTTP request waits for the reply, which is the HTTP response; the requestor's caller waits too (at least the warehouse's 300 ms, waitedMillis). Asynchronous: the requestor answers 202 PENDING; the warehouse answers 202 and later, from an Async scope, posts the reservation to a reply channel fixed in configuration (eip.requestReply.replyChannelPath). Matching uses the orderId, and a second reply for an order is rejected with 409. A reply the warehouse fails to send is lost and the request stays PENDING (no timeout or retry here); the full matching problem is the wave-1 correlation identifier, not duplicated here |
eip/request-reply/ |
POST /eip/stock/reservations/{sync|async}, POST /eip/warehouse/reservations, POST /eip/warehouse/reservation-requests (simulated warehouse), POST /eip/stock/reservation-replies (reply channel), GET /eip/stock/reservation-replies/{orderId} |
| Return address | The invoice request carries the requestor's reply channel in X-Reply-To; invoicing answers 202 and posts the invoice to that address, so two requestors (checkout, backoffice) each get their own invoice. Only a path of the form /eip/requestors/{requestor}/invoice-replies is accepted (400 otherwise, an absolute URL included), so the return address cannot make the replier call anywhere else |
eip/return-address/ |
POST /eip/invoice-requests, POST|GET /eip/requestors/{requestor}/invoice-replies |
| Event-driven consumer | The HTTP Listener is the trigger: each payment event runs the flow when it arrives, and the order is PAID by the time the sender has its 200. Each event overwrites the state and nothing checks eventId (a repeat is applied again; see the wave-1 idempotent receiver). VM Listener and Anypoint MQ Subscriber are the messaging equivalents (no broker simulated) |
eip/event-driven-consumer/ |
POST /eip/payment-events, GET /eip/payment-events/{orderId} |
| Polling consumer | A Scheduler (fixed-frequency 1000 ms, disallowConcurrentExecution) polls a pending-orders list in an object store. The list is never emptied; a watermark (highest sequence processed) in another object store makes each order allocated once, however many polls run. The watermark is stored after processing, so the orders of a failed poll are read again, including any already allocated (at least once). maxConcurrency="1" on the add flow keeps sequence numbers unique on one worker only |
eip/polling-consumer/ |
POST /eip/stock/pending-orders, GET /eip/stock/polled-orders |
| Competing consumers | Several consumers (Parallel For Each; consumer flow maxConcurrency = eip.competingConsumers.consumers, 3) walk one shared invoice work list; each item is claimed with os:store failIfPresent="true", so the first consumer owns it and the others get OS:KEY_ALREADY_EXISTS and skip it; a failed invoice releases the claim, so a later run retries the item. In the book the consumers share one point-to-point channel and the messaging system delivers each message to one of them; here the claims store stands in for that single delivery |
eip/competing-consumers/ |
POST /eip/invoice-work/items, POST /eip/invoice-work/runs, POST /eip/invoice-work/consumers/{consumerId}/runs, GET /eip/invoice-work/items/{orderId} |
| Channel adapter | The HTTP Request connector reads an order from a simulated legacy ERP that answers ;-separated CSV with cents and one-letter status codes, and DataWeave turns it into the order message (amount, status PAID/PAYMENT_DECLINED/REFUNDED, else UNKNOWN with legacyStatusCode). The legacy plain-text 404 becomes the app's JSON 404; a legacy 5xx or connection failure becomes 503. A connector can act as a source or as an operation; here it is an operation |
eip/channel-adapter/ |
GET /eip/adapters/legacy-orders/{orderId}, GET /eip/legacy/orders/{orderId} (simulated legacy ERP) |
Wave 5, reliability:
| Pattern | Scenario | Code | Endpoints |
|---|---|---|---|
| Guaranteed delivery | A new order is written to an object store declared persistent="true" (the outbox) and only then answered 202 STORED; an orderId already stored, delivered or dead gets 409. A forwarder started by a Scheduler (eip.guaranteedDelivery.forwardFrequencyMillis, 1000 ms) sends each stored order to a simulated fulfilment system and removes it from the outbox only after a 2xx; while fulfilment answers 503 the order stays stored with its attempts and lastError and is sent again on the next run. Each order is forwarded inside its own error handling, so one failure never stops the pass: an order fulfilment rejects with a 4xx, or still failing after eip.guaranteedDelivery.maxAttempts (5) attempts, is moved aside to a quarantine store (DEAD, with the reason). Delivery is at least once: a failure after fulfilment accepted the order and before it left the outbox sends it again; the simulated fulfilment processes each orderId once (os:store with failIfPresent) and answers a repeat 200 DUPLICATE. Surviving a restart is the Object Store connector's guarantee for a persistent store, not shown by MUnit. With Anypoint MQ (unacknowledged messages go back to the queue) or a persistent VM queue the broker does this; no broker is simulated here |
eip/guaranteed-delivery/ |
POST /eip/orders/guaranteed, GET /eip/orders/guaranteed/{orderId}; simulated fulfilment: POST /eip/fulfilment/orders, PUT /eip/fulfilment/availability, GET /eip/fulfilment/orders/{orderId} |
| Transactional client | A payment writes a ledger debit and marks the order PAID: both or neither. Before: the two writes inside a Try with transactionalAction="ALWAYS_BEGIN"; the Try holds no transactional resource (Object Store operations do not support transactions), so ALWAYS_BEGIN starts a transaction nothing joins: when the second write fails the caller gets 500 and the debit stays, as it would without the attribute. After: the same writes with a compensation: the debit is undone and the caller gets 503; if the undo fails too, 500 compensation failed. Both refuse a second payment of the same order with 409. Real transactions need a transactional connector inside the Try (Database, JMS, VM; XA across them); this app has none, so the compensation is what is left, and it is not atomic |
eip/transactional-client/ |
POST /eip/payments/transactional/before, POST /eip/payments/transactional/after, GET /eip/payments/transactional/{orderId} |
| Message expiration | A stock reservation request carries expiresAt and waits in a queue (an object store) until a consumer started by a Scheduler (eip.messageExpiration.consumerFrequencyMillis, 1000 ms) takes it: a request still valid is RESERVED, an expired one is moved to the expired requests (EXPIRED, with the reason) and reserves nothing. quantity, when given, must be a positive integer (400 otherwise). Expiry is checked when the message is consumed, so a request valid when sent can expire while it waits. Each request is consumed inside its own error handling: one that fails (for example a stored expiresAt that no longer parses) is moved aside as FAILED and the run goes on. Anypoint MQ's queue-level message TTL is the broker equivalent |
eip/message-expiration/ |
POST /eip/stock/expiring-requests, GET /eip/stock/expiring-requests/{orderId} |
| Invalid message channel | Invoice requests that are not JSON, are malformed JSON, are not an object or miss a field (orderId, positive amount, 3-letter currency) are kept in an invalid-message store with the reason, content type and body as received, never invoiced, and answered 400 naming the invalid message. A valid one is 202 ACCEPTED; a valid one whose orderId was already accepted is 409. The content type is compared in lower case (Application/JSON is JSON). Not the dead letter channel (the Dead Letter Channel page of the series, Anypoint MQ, no code here): that one receives valid messages that could not be delivered |
eip/invalid-message-channel/ |
POST /eip/invoices/requests, GET /eip/invoices/requests/{orderId}, GET /eip/invoices/invalid-messages |
| Point-to-point channel | Documented with Anypoint MQ configuration in the wiki, not runnable here: one queue, each message consumed by one receiver, is a broker feature; in this app it would mean simulating a broker with VM queues. The one-receiver-per-message effect over a shared work list is shown by competing consumers (wave 4) | — | — |
The MUnit suites are in src/test/munit/eip/, one per pattern. What the wave 1 suites show, observed on Mule 4.9.17 with MUnit 3.7.1:
- With no
objectStore, the idempotent validator keeps IDs in memory for 5 minutes (the defaults in the 4.9.17 runtime'sIdempotentMessageValidator). Three different orders posted over HTTP from one For Each giveaccepted,duplicate,duplicate; withidExpression="#[payload.orderId]"they giveaccepted,accepted,accepted. - The validator stores the ID when it accepts the message and does not remove it when the flow fails afterwards: a retry of an order whose charge failed is rejected as a duplicate and never charged (
idempotent-after-failed-charge-keeps-the-id-so-the-retry-is-a-duplicate). - The out-of-order test sends the provider callbacks itself (B before A), so the order is deterministic; the end-to-end test lets the simulated provider call back on its own, from an
asyncscope. - The simulated provider always approves. The DECLINED, amount-mismatch, unknown-status and repeated-callback tests send the callback themselves.
- The callback flow removes (claims) the pending request before it updates the order. A repeated or late callback, sent after the first one was matched, finds no pending request and gets 404 (
cid-repeated-callback-returns-404-and-keeps-the-first-outcome). The flow also turnsOS:KEY_NOT_FOUNDfrom the remove into 404, so a concurrent copy that read the pending request but loses the claim to another copy gets 404 instead of 500, and is logged as a lost claim (cid-callback-that-loses-the-claim-returns-404-and-changes-nothing). This is not a lock. With the in-memory stores used here, outside a Mule cluster each worker has its own store, so a callback that reaches a worker that did not store the request gets 404. When the stores are persistent (Object Store v2 on CloudHub), two copies handled at the same moment by two workers can still both update the order: Object Store v2 has no distributed lock to prevent it.
What the wave 2 suites show, observed on Mule 4.9.17 with MUnit 3.7.1 and Aggregators 1.1.5:
- A Choice does not end the flow. In the message filter "before" version an order with status
PAYMENT_DECLINEDis answeredDISCARDEDand still gets one stock reservation (filter-before-unpaid-order-is-reported-discarded-but-stock-is-reserved); in the "after" version it gets none. - After For Each the payload is the original order and a variable set inside the scope keeps its last value; after Parallel For Each the payload is a list of three messages (
payload[0].payloadis the first item's result), in input order even though theSKU-BOOKcheck (300 ms) finishes after theSKU-MUGone (theircheckedAt), and a variable set inside a route isnullafter the scope. - The aggregator answers 202, 202, 201 to the three items of an order sent by the client, and groups interleaved items of two orders by
orderId. The timeout test waits for the group to time out (5 seconds, checked by the module once a second), so the aggregator suite takes about 9 seconds. - With the fraud route failing (
HTTP:SERVICE_UNAVAILABLE), the pricing and stock routes still run once each, and their results are read from theMULE:COMPOSITE_ROUTINGerror (sg-failed-route-raises-composite-routing-with-the-other-results). The 503 body names each failed check by error type only; the error description is logged, not returned. - In the recipient list, the simulated warehouse answers 503 to an order whose
orderIdstarts withORD-WAREHOUSE-DOWN: its delivery is recordedFAILEDwithHTTP:SERVICE_UNAVAILABLE, and the recipient after it is still notified (rl-failed-recipient-does-not-stop-the-others).
What the wave 3 suites show, observed on Mule 4.9.17 with MUnit 3.7.1:
- The message translator turns two CSV rows into one order with numeric quantities (
2, not"2") and prices in units (1250cents become12.5); an unknown product code is rejected by name (unknown product code: LAMP-99). - The same order sent by the marketplace as JSON and by a partner as XML gives the same canonical items and the same invoicing lines, total and warehouse rows; only the ids and
sourcediffer (cdm-partner-xml-gives-the-same-target-documents). The target translators are called with a canonical order alone, without any source, and they take theCanonicalOrdertype, so an order without a currency fails at the call (cdm-module-target-translator-rejects-an-order-that-is-not-canonical). An order without a currency, or with lines in two currencies, gets 400; no currency is assumed. - What the simulated invoicing system received has exactly the seven allowed fields; the card number and the email do not appear anywhere in it, and neither does a
loyaltyCardNumberadded to the order (cf-field-added-to-the-order-later-does-not-leak). - For a 200,000-byte document the archive receives a message with three fields (
orderId,claimKey,fileName) and still archives 200,000 bytes, fetched with the key. The same key works again after the archive has used it, and 5 seconds after check-in it gets 404 (cc-claim-expires-after-its-time-to-live), so the claim check suite takes about 6 seconds. Over real HTTP, check-in answers 202 and check-out answers 200 with the document asapplication/json(cc-check-out-does-not-remove-the-document). - In the content enricher, a spy before the merge sees the order as payload after the CRM call, and
vars.vCustomerholds only the customer part of the answer (nometa), while the payload still has only the order's keys (orderId,customerId,items). When the CRM answers 503, a value thatvCustomerheld before the call is still there afterwards (ce-crm-down-sets-no-target-variable-and-returns-503), the merge does not run and the endpoint answers 503;HTTP:CONNECTIVITYandHTTP:TIMEOUTgive the same 503 (ce-crm-unreachable-returns-503,ce-crm-timeout-returns-503).
What the wave 4 suites show, observed on Mule 4.9.17 with MUnit 3.7.1 (most calls go over HTTP to the app and assert status codes and content types; the failure tests mock one processor and call the flow with a flow-ref):
- Request-reply: the synchronous response already holds the reservation and
waitedMillisis at least 300; the asynchronous one is 202PENDING, a GET right after it is stillPENDING, and the reply arrives later, sent once to the reply channel. A reply nobody asked for gets 404; a second reply for the same order gets 409 and the first one is kept. - Return address: invoices requested with
X-Reply-To/eip/requestors/checkout/...and/eip/requestors/backoffice/...land each in its own inbox only, one reply per request. No header, an absolute URL or another path of the app (/eip/payments/callback) get 400 and no reply is sent. - Event-driven consumer: the GET made right after the event's 200 already shows the new state; two events sent one after the other run the flow twice and the last one decides the state; the same event sent twice is applied twice (no check on
eventId). - Polling consumer: an order added between polls is not allocated until the next poll; three polls over orders A, B (poll 1, 2) and C (poll 3) store three allocations and the watermark twice, each order
timesAllocated1. With the Scheduler source enabled, an order is allocated with no request to the poller. When allocation fails on the second of two new orders, the watermark does not move and the next poll allocates the first order again (timesAllocated2) and the second once. - Competing consumers: three consumers over nine items process all nine, none twice, nine invoices issued, each claimed by the consumer that reports it. More than one consumer processes items and their runs meet each other's claims, which is evidence, not proof, that they ran concurrently; the skipped-item test pre-seeds the claim so the
OS:KEY_ALREADY_EXISTSpath is deterministic. An item already claimed in the claims store is skipped and not invoiced. When invoicing fails, the claim is released and the next run claims and invoices the item once. The claim is atomic per key on one worker; across workers only in a Mule cluster (Object Store connector reference). - Channel adapter: the legacy endpoint answers
application/csv(ORD-LEGACY-1001;4250;BRL;P), the adapter answersapplication/json{ orderId, amount: 42.5, currency, status: PAID, source }. A legacy 500, 503 or connection failure (mocked) gives 503{ error: "Legacy ERP unavailable" }.
What the wave 5 suites show, observed on Mule 4.9.17 with MUnit 3.7.1 (calls go over HTTP to the app; the Scheduler-started flows run one pass at a time with a flow-ref, and one test per Scheduler enables the source):
- Guaranteed delivery: the 202 comes back with the whole order already in the persistent outbox (read directly with
os:retrieve) and not yet at fulfilment (404). With fulfilment down two passes leave itSTORED,attempts2,lastErrorHTTP:SERVICE_UNAVAILABLE; the pass after fulfilment is back delivers it (attempts3, received once). When removing it from the outbox fails after delivery (mocked, as a crash at that point would), the next pass sends it again and fulfilment receives it twice and processes it once. Fulfilment answering 400 to order A moves A to the dead store (DEAD,HTTP:BAD_REQUEST) while order B is delivered in the same pass; with fulfilment down the fifth failed attempt moves the order to the dead store and a later pass does not send it. A second POST of a stored or deliveredorderIdgets 409. The Scheduler test polls the status (Until Successful) instead of sleeping. The order is in a store declaredpersistent="true"; surviving a restart is the connector's guarantee, not shown by MUnit. - Transactional client: with the second write mocked to fail with
OS:STORE_NOT_AVAILABLE, the Try withALWAYS_BEGINanswers 500 and leaves the 50.00 debit with the order unpaid; the compensated version answers 503 and leaves neither record; with the undo also mocked to fail it answers 500compensation failedand the debit stays. The 500 of the "before" Try shows the outcome, not an effect ofALWAYS_BEGIN: the Try holds no transactional resource. A second payment of a paid order gets 409 from both endpoints. - Message expiration: a request that expires in a minute is
QUEUEDuntil the consumer runs and thenRESERVED; one sent already expired becomesEXPIREDwith the reason and no reservation; one queued withexpiresAtalready past (the queue does not check it, only the consumer does, so no sleep is needed) isEXPIREDwhile one with a minute left in the same pass isRESERVED. A stored request whoseexpiresAtno longer parses becomesFAILEDand the request after it is stillRESERVEDin the same run. Aquantityof 0, -3, 1.5 or"two"gets 400. The Scheduler test polls (Until Successful) instead of sleeping. - Invalid message channel: malformed JSON, a schema violation (all three listed in the reason), an XML body and a JSON array each get 400 and one entry in the invalid channel with the reason and the body as received; the 400 names the same invalid-message id, and the request is not stored. A valid request is 202 and leaves the channel empty, also when sent as
Application/JSON; charset=UTF-8; a second valid request with the sameorderIdgets 409 and the first is kept.
Notes:
- Object stores are in memory so every run starts clean, except the guaranteed delivery outbox, delivered and dead stores, declared persistent because surviving a restart is the point (the connector's guarantee; the suite clears them before each test). In production use a persistent, shared store (Object Store v2 on CloudHub): an in-memory store is lost on restart and is not shared between replicas.
- VM queues are not used to stand in for a broker: the VM connector is an in-application queue, not a message broker.
- The aggregator keeps its groups in an object store too. In production, and always in a cluster, use a persistent, shared store, or a group whose items reach different workers never completes.
src/main/mule/
global.xml shared: HTTP listener, properties, error handler (400/404/409/503 as JSON)
solid/ SOLID in MuleSoft
srp/ ocp/ lsp/ isp/ dip/
<principle>-before.xml
<principle>-after.xml
eip/ Enterprise Integration Patterns
eip-global.xml HTTP requester the examples use to call this same app
idempotent-receiver/
idempotent-receiver-before.xml
idempotent-receiver-after.xml
payment-common.xml simulated charge, ledger, batch client
correlation-identifier/
correlation-identifier.xml book meaning: provider request, callback, status
mule-correlation-id.xml Mule meaning: X-CORRELATION-ID propagation
content-based-router/
content-based-router.xml Choice routes an order to review, international or domestic fulfilment
message-filter/
message-filter-before.xml Choice as a filter, with the reservation after it (runs for every order)
message-filter-after.xml the reservation inside the route that lets paid orders through
stock-reservation-common.xml simulated reservation, ledger, validation
splitter/
splitter.xml For Each and Parallel For Each over an order's items
aggregator/
aggregator.xml group-based aggregator, timed-out group listener, invoice lookup
aggregator-client.xml client that splits an order and posts its items over HTTP
recipient-list/
recipient-list.xml computed recipients, For Each with a Try per delivery
recipient-list-recipients.xml simulated downstream systems and their inbox
scatter-gather/
scatter-gather.xml parallel order checks, MULE:COMPOSITE_ROUTING handler
scatter-gather-services.xml simulated pricing, stock and fraud services
message-translator/
message-translator.xml partner CSV order lines translated into the app's order
canonical-data-model/
canonical-data-model.xml two source flows, one canonical order, two target documents
content-filter/
content-filter.xml paid order filtered down to an invoice request
content-filter-invoicing.xml simulated invoicing system that keeps what it received
claim-check/
claim-check.xml check in, claim message, check out, store with a time to live
claim-check-archive.xml simulated archive that checks the document out with the key
content-enricher/
content-enricher.xml HTTP request with a target variable, merge, CRM-down handling
content-enricher-crm.xml simulated customer service (CRM)
request-reply/
request-reply.xml synchronous and asynchronous requestor, reply channel, reply lookup
request-reply-warehouse.xml simulated warehouse: synchronous and asynchronous replier
return-address/
return-address.xml replier that uses X-Reply-To, requestor reply channels and inboxes
event-driven-consumer/
event-driven-consumer.xml HTTP Listener-triggered payment event consumer
polling-consumer/
polling-consumer.xml Scheduler poller with an object store watermark, pending list producer
competing-consumers/
competing-consumers.xml shared work list, consumers with an atomic claim, Parallel For Each runner
channel-adapter/
channel-adapter.xml HTTP Request + DataWeave adapter from legacy CSV to the order message
legacy-erp.xml simulated legacy ERP answering CSV
guaranteed-delivery/
guaranteed-delivery.xml persistent outbox, Scheduler forwarder with redelivery, status
guaranteed-delivery-fulfilment.xml simulated fulfilment that can be switched off
transactional-client/
transactional-client.xml Try with ALWAYS_BEGIN (no rollback) vs compensation
message-expiration/
message-expiration.xml queued requests with expiresAt, Scheduler consumer, expired requests
invalid-message-channel/
invalid-message-channel.xml parse and field checks, invalid-message store with the reason
src/main/resources/config/app.yaml port, OCP payment routing, DIP repository choice, EIP self-call host, callback path, routing, claim check, request-reply, polling and competing consumers, guaranteed delivery and message expiration settings
src/main/resources/dwl/
translator/PartnerProducts.dwl partner product codes -> SKUs (message translator)
canonical/ CanonicalOrder (type, validation), FromMarketplace, FromPartnerXml, ToInvoicing, ToWarehouse
src/test/munit/
solid/<principle>-test-suite.xml
eip/<pattern>-test-suite.xml
docs/contracts/
solid/ RAML types for the LSP and ISP contracts
requests.http sample requests for every endpoint
MIT © Bruno Pinto de Souza