One Ugly XML Feed, One Clean Report

The capstone: take a namespaced XML order feed with repeated elements, mixed date formats, and money as strings, and build a normalized JSON report end to end — selectors, a helper module, dates, coercion, groupBy+reduce, and conditional keys, in a few reasoned steps.

Everything so far has been one idea at a time. Selectors in one post, dates in another, groupBy in a third. Real integrations don’t arrive one idea at a time — they arrive as a single ugly payload that needs all of it at once. So let’s do that. Here is the kind of feed a shipping partner actually sends, and here is the clean report the warehouse actually wants, and the whole of both series is the bridge between them.

The input

A namespaced XML batch of orders. Note everything wrong with it, because each flaw is a technique:

<?xml version="1.0" encoding="UTF-8"?>
<ord:orderBatch xmlns:ord="http://shipping.acme.com/orders">
  <ord:order id="SO-4471">
    <ord:placedAt>2026-06-14</ord:placedAt>
    <ord:customer tier="gold">[email protected]</ord:customer>
    <ord:line>
      <ord:sku>KB-01</ord:sku>
      <ord:category>peripherals</ord:category>
      <ord:price>79.00</ord:price>
      <ord:qty>1</ord:qty>
    </ord:line>
    <ord:line>
      <ord:sku>MS-02</ord:sku>
      <ord:category>peripherals</ord:category>
      <ord:price>29.50</ord:price>
      <ord:qty>2</ord:qty>
    </ord:line>
  </ord:order>
  <ord:order id="SO-4472">
    <ord:placedAt>06/16/2026</ord:placedAt>
    <ord:customer tier="standard">[email protected]</ord:customer>
    <ord:line>
      <ord:sku>DK-09</ord:sku>
      <ord:category>furniture</ord:category>
      <ord:price>240.00</ord:price>
      <ord:qty>1</ord:qty>
    </ord:line>
  </ord:order>
</ord:orderBatch>

Four traps. Everything is in the ord namespace, so unqualified selectors find nothing. <ord:line> repeats: twice on the first order, once on the second, and the single-line one is the classic place a naive selector returns an object instead of an array. The dates are in two different formats — ISO on the first order, US MM/dd/yyyy on the second. And price and qty are element text, so they arrive as strings, not numbers.

Step 1 — get past the namespace and the repetition

Declare the namespace in the header with ns, then select the orders. The multi-value selector .*ord#order is the fix for the repeated-element trap: it always returns an array, whether there’s one order or a hundred, and the same goes for .*ord#line inside each order.

%dw 2.0
ns ord http://shipping.acme.com/orders
output application/json
---
payload.ord#orderBatch.*ord#order map (order) -> {
  id: order.@id,
  lines: order.*ord#line map (line) -> {
    sku: line.ord#sku,
    category: line.ord#category
  }
}

order.@id reads the attribute; line.ord#sku reads a namespaced child’s text. We’re not done, but the shape is real — an array of orders, each with an array of lines — and nothing collapsed.

Step 2 — a helper module for the messy bits

The date and money problems are logic, not shape, and logic belongs in a module (the pattern from post 6). Two functions: one that parses either date format, one that turns a price string into a number.

%dw 2.0

/**
 * Parse ISO (yyyy-MM-dd) or US (MM/dd/yyyy) into a Date.
 */
fun parseDate(s: String): Date =
  if (s matches /\d{4}-\d{2}-\d{2}/)
    (s as Date { format: "yyyy-MM-dd" })
  else
    (s as Date { format: "MM/dd/yyyy" })

/**
 * Element text like "29.50" into a real Number.
 */
fun money(s: String): Number = s as Number

parseDate uses matches with a regex to sniff the format, then coerces with the matching format schema, exactly the date handling from the temporal post, now earning its keep on real garbage. money is a one-liner, but naming it means the intent (this string is currency) is written down, and if the feed ever sends $29.50 there’s one place to fix it. Save it at src/main/resources/orders/Feed.dwl as orders::Feed.

Step 3 — normalize each order

Import the module, coerce the leaves, compute a line total, and add the two touches that make a report useful: a lineTotal per line, an orderTotal per order via sumBy, and a conditional key: vip appears only for gold-tier customers, so downstream code can key off its presence.

%dw 2.0
ns ord http://shipping.acme.com/orders
import parseDate, money from orders::Feed
import sumBy from dw::core::Arrays
output application/json
---
payload.ord#orderBatch.*ord#order map (order) -> {
  id: order.@id,
  placedAt: parseDate(order.ord#placedAt) as String { format: "yyyy-MM-dd" },
  customer: order.ord#customer,
  (vip: true) if (order.ord#customer.@tier == "gold"),
  lines: order.*ord#line map (line) -> {
    sku: line.ord#sku,
    category: line.ord#category,
    price: money(line.ord#price),
    qty: line.ord#qty as Number,
    lineTotal: money(line.ord#price) * (line.ord#qty as Number)
  },
  orderTotal: (order.*ord#line sumBy (line) -> money(line.ord#price) * (line.ord#qty as Number))
}

Every date now writes back as a normalized ISO string, regardless of how it came in. Every price and quantity is a real Number, so orderTotal is arithmetic and not string concatenation. And (vip: true) if (...) is the conditional-key syntax — the pair appears in the output object only when the condition holds, so SO-4471 gets a vip flag and SO-4472 simply doesn’t have the key.

Step 4 — the batch-level summary

The warehouse wants revenue by category across the whole batch, not per order. That’s the reshaping toolkit: flatten every line out of every order, groupBy category, then collapse each group to a sum with sumBy. Folding it into the final script gives the whole transform.

%dw 2.0
ns ord http://shipping.acme.com/orders
import parseDate, money from orders::Feed
import sumBy from dw::core::Arrays
output application/json
---
{
  orders: payload.ord#orderBatch.*ord#order map (order) -> {
    id: order.@id,
    placedAt: parseDate(order.ord#placedAt) as String { format: "yyyy-MM-dd" },
    customer: order.ord#customer,
    (vip: true) if (order.ord#customer.@tier == "gold"),
    lines: order.*ord#line map (line) -> {
      sku: line.ord#sku,
      category: line.ord#category,
      price: money(line.ord#price),
      qty: line.ord#qty as Number,
      lineTotal: money(line.ord#price) * (line.ord#qty as Number)
    },
    orderTotal: order.*ord#line sumBy (line) ->
      money(line.ord#price) * (line.ord#qty as Number)
  },
  revenueByCategory:
    (flatten(payload.ord#orderBatch.*ord#order.*ord#line)
      groupBy (line) -> line.ord#category)
    mapObject (lines, category) -> {
      (category): lines sumBy (line) -> money(line.ord#price) * (line.ord#qty as Number)
    }
}

flatten(...*ord#line) pulls every line from every order into one array, groupBy buckets them by category, and mapObject rewrites each bucket as category: total. The dynamic key (category) puts the category value in the key position — a plain category: would literally emit the word “category”.

The output

{
  "orders": [
    {
      "id": "SO-4471",
      "placedAt": "2026-06-14",
      "customer": "[email protected]",
      "vip": true,
      "lines": [
        { "sku": "KB-01", "category": "peripherals", "price": 79.0, "qty": 1, "lineTotal": 79.0 },
        { "sku": "MS-02", "category": "peripherals", "price": 29.5, "qty": 2, "lineTotal": 59.0 }
      ],
      "orderTotal": 138.0
    },
    {
      "id": "SO-4472",
      "placedAt": "2026-06-16",
      "customer": "[email protected]",
      "lines": [
        { "sku": "DK-09", "category": "furniture", "price": 240.0, "qty": 1, "lineTotal": 240.0 }
      ],
      "orderTotal": 240.0
    }
  ],
  "revenueByCategory": {
    "peripherals": 138.0,
    "furniture": 240.0
  }
}

A namespaced XML mess with two date formats and stringly-typed money became a normalized report — sorted shapes, real numbers, ISO dates, a conditional flag, and a rolled-up summary. Read the script again and notice that no single part is clever. It’s selectors, a helper module, a coercion, a groupBy, a sumBy, and a conditional key — each of them a post you’ve already read — stacked without fighting each other.

Final thoughts

That composability is the whole argument. When people meet DataWeave inside a mapping GUI, every one of these problems feels like a special case — a plugin, a checkbox, a forum answer to copy. Learned as a language, they’re just expressions you already know, snapping together the way expressions do. The namespace fix and the date fix and the grouping don’t interfere with each other because none of them mutate anything; each is a value flowing into the next.

Two series to arrive here, and the destination is unglamorous on purpose: an integration transform is usually the part everyone dreads and nobody tests. It doesn’t have to be. DataWeave rewards being learned as a real functional language — small, pure, composable — and once it is, the transform stops being the scary part of the pipeline and becomes the pleasant one. That’s the end of the road. Go point it at your own ugly feed.

Comments