Object oriented JSON mapping

Arbre
Arbre, 1911 - Piet Mondrian

Sometimes we need to transform JSON files from one schema to another. These transformations can be straightforward, or there can be complex rules. This is one of the problems developers usually solve by writing procedural code, even in object oriented languages like Java. When we first think about it, it seems we should just go through each JSON field one by one, applying transformation procedures and building a new JSON. But that is computer thinking, not design thinking. If we take a step back, we realize a JSON is just a composition of smaller JSONs and primitive elements, a composition which can be perfectly expressed in object oriented code, a composition which can be simply declared, rather than assembled in a series of procedural steps.

Let’s look at a simple example, imagine how it could be solved by a more procedurally inclined developer, and then how object oriented composition would look like.

Bilbo wants to buy some fireworks

One semi-realistic example could be a bank having to map a payment instruction in some external schema, e.g. PSD2 / Open Banking, to its internal ISO 20022 format. Let’s say Bilbo Baggins wants to pay Gandalf for fireworks. A simplified, OB-ish external instruction could look like this:

{
  "Data": {
    "ConsentId": "9f457cbb-6438-4083-9a31-157cb5c3456e",
    "Initiation": {
      "Instrument": "PonyExpress",
      "Purpose": "For fireworks.",
      "InstructedAmount": {
        "Amount": "3.00",
        "Currency": "Silver"
      },
      "Debtor": {
        "Name": "Bilbo Baggins"
      },
      "DebtorAccount": {
        "SchemeName": "IBAN",
        "Identification": "SH0161331926819",
        "Name": "Party fund"
      },
      "Creditor": {
        "Name": "Gandalf the Gray"
      },
      "CreditorAccount": {
        "SchemeName": "GBAN",
        "Identification": "08023465436",
        "Name": "Secret projects"
      }
    }
  }
}

Transformed into simplified ISO 20022-ish internal schema it would look like this:

{
  "GroupHeader" : {
    "MessageIdentification" : "9f457cbb-6438-4083-9a31-157cb5c3456e",
    "CreationDateTime" : "2020-11-11T17:46:44.358450800",
    "NumberOfTransactions" : "1"
  },
  "PaymentInformation" : {
    "PaymentId" : "563946d3-d0de-4075-b967-df9e5ad9eb96",
    "PaymentMethod" : "PonyExpress",
    "PaymentPurpose" : "For fireworks.",
    "Debtor" : "Bilbo Baggins",
    "DebtorAccount" : {
      "IBAN" : "SH0161331926819"
    },
    "Settlement": {
      "Amount": "3.00",
      "Currency": "Silver"
    },
    "Creditor" : "Gandalf the Gray",
    "CreditorAccount" : {
      "Other" : {
        "Identification" : "08023465436",
        "SchemeName" : "GBAN"
      }
    }
  }
}

There are a number of rules for mapping one imaginary schema to another. To make this a more realistic example, these rules are varied, and some of them are relatively complex. I will list them here for a reference, but you can skip them now because we will go through them one by one when we compare the procedural approach versus the object oriented one.

Mapping rules:

  1. GroupHeader comprises the following fields:
    1. MessageIdentification is ConsentId in original message.
    2. CreationDateTime is the time when instruction was received.
    3. NumberOfTransactions is 1, since there is only one payment.
  2. PaymentInformation comprises the following fields:
    1. PaymentId is any ID, uniquely identifying the new message.
    2. PaymentMethod is Instrument in original message.
    3. PaymentPurpose is Purpose.
    4. Debtor is Debtor/Name.
    5. DebtorAccount has complex mapping rules which we will discuss later.
    6. Settlement comprises the following fields:
      1. Amount is Amount in the original message’s InstructedAmount node.
      2. Currency is Currency in the original message’s InstructedAmount node.
    7. Creditor is Creditor/Name.
    8. CreditorAccount has complex mapping rules which we will discuss later as well.

Procedural mapping

Let’s implement this procedurally first. This is how we would create the GroupHeader:

MutableJson groupHeader = new MutableJson();
groupHeader.with("MessageIdentification", openBankingInitiation.leaf("/Data/ConsentId"));
groupHeader.with("CreationDateTime", LocalDateTime.now().toString());
groupHeader.with("NumberOfTransactions", "1");

We take MessageIdentification from the ConsentId field - that’s a direct mapping. CreationDateTime is an independently generated value, and NumberOfTransactions is just a constant.

Let’s do PaymentInformation next:

MutableJson paymentInformation = new MutableJson();
paymentInformation.with("PaymentId", UUID.randomUUID().toString());
paymentInformation.with("PaymentMethod", openBankingInitiation.leaf("/Data/Initiation/Instrument"));
paymentInformation.with("PaymentPurpose", openBankingInitiation.leaf("/Data/Initiation/Purpose"));
paymentInformation.with("Debtor", openBankingInitiation.leaf("/Data/Initiation/Debtor/Name"));
paymentInformation.with("DebtorAccount", ...); // This gets more tricky...
paymentInformation.with("Settlement", ...); // We will need a separate code block to create Settlement...
paymentInformation.with("Creditor", openBankingInitiation.leaf("/Data/Initiation/Creditor/Name"));
paymentInformation.with("CreditorAccount", ...); // This as well.

PaymentId is another independently generated value. PaymentMethod, PaymentPurpose, Debtor and Creditor are direct mappings. Settlement is a nested JSON with values extracted from original message:

MutableJson settlement = new MutableJson();
settlement.with("Amount", openBankingInitiation.leaf("/Data/Initiation/InstructedAmount/Amount"));
settlement.with("Currency", openBankingInitiation.leaf("/Data/Initiation/InstructedAmount/Currency"));
paymentInformation.with("Settlement", settlement);

Now we get to DebtorAccount / CreditorAccount, which have more complex rules. To keep this example simple, let’s not get too realistic, and just define them like so:

  1. DebtorAccount/CreditorAccount:
    1. If debtor account’s SchemeName in the original request is “IBAN”, then it is just a JSON with the IBAN field.
    2. Otherwise it is a JSON with Other field, which in turn has two fields:
      1. Identification, which is account’s ID value.
      2. SchemeName, which is the name of the ID’s scheme (other than IBAN).

We can write a private method for this:

private static Json createAccount(
    String schemeName, String identification
) {
    MutableJson account = new MutableJson();
    if ("IBAN".equals(schemeName)) {
        account.with("IBAN", identification);
    } else {
        MutableJson other = new MutableJson();
        other.with("Identification", identification);
        other.with("SchemeName", schemeName);
        account.with("Other", other);
    }
    return account;
}

And then we can complete PaymentInformation mapping like this:

String debtorSchemeName = openBankingInitiation.leaf("/Data/Initiation/DebtorAccount/SchemeName");
String debtorIdentification = openBankingInitiation.leaf("/Data/Initiation/DebtorAccount/Identification");
paymentInformation.with("DebtorAccount", createAccount(debtorSchemeName, debtorIdentification));

...

String creditorSchemeName = openBankingInitiation.leaf("/Data/Initiation/CreditorAccount/SchemeName");
String creditorIdentification = openBankingInitiation.leaf("/Data/Initiation/CreditorAccount/Identification");
paymentInformation.with("CreditorAccount", createAccount(creditorSchemeName, creditorIdentification));

Finally, we assemble the result:

MutableJson paymentInitiation = new MutableJson();
paymentInitiation.with("GroupHeader", groupHeader);
paymentInitiation.with("PaymentInformation", paymentInformation);

The full class looks like this:

public class PaymentInitiationMapper {
    
    public Json mapObToIso(SmartJson openBankingInitiation) {

        // Create group header.

        MutableJson groupHeader = new MutableJson();
        groupHeader.with("MessageIdentification", openBankingInitiation.leaf("/Data/ConsentId"));
        groupHeader.with("CreationDateTime", LocalDateTime.now().toString());
        groupHeader.with("NumberOfTransactions", "1");

        // Create payment information.

        MutableJson paymentInformation = new MutableJson();

        paymentInformation.with("PaymentId", UUID.randomUUID().toString());
        paymentInformation.with("PaymentMethod", openBankingInitiation.leaf("/Data/Initiation/Instrument"));
        paymentInformation.with("PaymentPurpose", openBankingInitiation.leaf("/Data/Initiation/Purpose"));
        paymentInformation.with("Debtor", openBankingInitiation.leaf("/Data/Initiation/Debtor/Name"));

        String debtorSchemeName = openBankingInitiation.leaf("/Data/Initiation/DebtorAccount/SchemeName");
        String debtorIdentification = openBankingInitiation.leaf("/Data/Initiation/DebtorAccount/Identification");
        paymentInformation.with("DebtorAccount", createAccount(debtorSchemeName, debtorIdentification));

        MutableJson settlement = new MutableJson();
        settlement.with("Amount", openBankingInitiation.leaf("/Data/Initiation/InstructedAmount/Amount"));
        settlement.with("Currency", openBankingInitiation.leaf("/Data/Initiation/InstructedAmount/Currency"));
        paymentInformation.with("Settlement", settlement);

        paymentInformation.with("Creditor", openBankingInitiation.leaf("/Data/Initiation/Creditor/Name"));

        String creditorSchemeName = openBankingInitiation.leaf("/Data/Initiation/CreditorAccount/SchemeName");
        String creditorIdentification = openBankingInitiation.leaf("/Data/Initiation/CreditorAccount/Identification");
        paymentInformation.with("CreditorAccount", createAccount(creditorSchemeName, creditorIdentification));

        // Create final result.

        MutableJson paymentInitiation = new MutableJson();
        paymentInitiation.with("GroupHeader", groupHeader);
        paymentInitiation.with("PaymentInformation", paymentInformation);

        return paymentInitiation;
    }

    private static Json createAccount(String schemeName, String identification) {
        MutableJson account = new MutableJson();
        if ("IBAN".equals(schemeName)) {
            account.with("IBAN", identification);
        } else {
            MutableJson other = new MutableJson();
            other.with("Identification", identification);
            other.with("SchemeName", schemeName);
            account.with("Other", other);
        }
        return account;
    }
}

Object oriented mapping

See how the procedure above is basically a big blanket of code? The extraction of more complicated rules into a separate method did make the code a little clearer by introducing some sort of abstraction and eliminated some potential duplication, but the code is still very procedural. We could extract more methods to eliminate code comments and empty lines, but that would not help much, because those methods would not introduce any meaningful abstractions and would not eliminate any code duplication. The main method would still remain a list of imperative steps, i.e. a procedure.

What we can do instead is recognize the domain of this particular low level problem we are solving. The problem is JSON mapping, therefore the domain is JSONs, and JSONs are essentially composable objects. So let’s model our problem. Let’s review the mapping rules again:

  1. GroupHeader comprises the following fields:
    1. MessageIdentification is ConsentId in original message.
    2. CreationDateTime is the time when instruction was received.
    3. NumberOfTransactions is 1, since there is only one payment.
  2. PaymentInformation comprises the following fields:
    1. PaymentId is any ID, uniquely identifying the new message.
    2. PaymentMethod is Instrument in original message.
    3. PaymentPurpose is Purpose.
    4. Debtor is Debtor/Name.
    5. DebtorAccount too complex to state here briefly.
    6. Settlement comprises the following fields:
      1. Amount is Amount in the original message’s InstructedAmount node.
      2. Currency is Currency in the original message’s InstructedAmount node.
    7. Creditor is Creditor/Name.
    8. CreditorAccount too complex to state here briefly.

According to the rules, we need is an object like this:

new MutableJson()
    .with("GroupHeader", new MutableJson().with(...))
    .with("PaymentInformation", new MutableJson().with(...))

Ok, let’s model GroupHeader now. GroupHeader is a

new MutableJson()
    .with("MessageIdentification", message.leaf("/Data/ConsentId"))
    .with("CreationDateTime", LocalDateTime.now().toString())
    .with("NumberOfTransactions", "1")

And PaymentInformation is a

new MutableJson()
    .with("PaymentId", UUID.randomUUID().toString())
    .with("PaymentMethod", message.leaf("/Data/Initiation/Instrument"))
    .with("PaymentPurpose", message.leaf("/Data/Initiation/Purpose"))
    .with("Debtor", message.leaf("/Data/Initiation/Debtor/Name"))
    .with("DebtorAccount", new Account(...))
    .with("Settlement", new Settlement(message))
    .with("Creditor", message.leaf("/Data/Initiation/Creditor/Name"))
    .with("CreditorAccount", new Account(...))

Let’s dig deeper. We need an Account. Let’s remember what an Account JSON is:

  1. DebtorAccount/CreditorAccount:
    1. If debtor account’s SchemeName in the original request is “IBAN”, then it is just a JSON with the IBAN field.
    2. Otherwise it is a JSON with Other field, which in turn has two fields:
      1. Identification, which is account’s ID value.
      2. SchemeName, which is the name of the ID’s scheme (other than IBAN).

Alright. So an Account is

"IBAN".equals(account.leaf("SchemeName"))
    ? new IBAN(account)
    : new Other(account)

That was easy, but looks like we need to dig deeper and model IBAN and Other. IBAN is

new MutableJson().with("IBAN", account.leaf("Identification"))

and Other is

new MutableJson().with(
    "Other",
    new MutableJson()
        .with("Identification", account.leaf("Identification"))
        .with("SchemeName", account.leaf("SchemeName"))
)

Since these objects occur in more than one place in the final JSON, we can use the nereides-jackson library to define classes for them.

class IBAN extends JsonEnvelope {
    IBAN(SmartJson account) {
        super(new MutableJson().with(
            "IBAN", account.leaf("Identification")
        ));
    }
}
class Other extends JsonEnvelope {
    Other(SmartJson account) {
        super(
            new MutableJson().with(
                "Other",
                new MutableJson()
                    .with("Identification", account.leaf("Identification"))
                    .with("SchemeName", account.leaf("SchemeName"))
            )
        );
    }
}

And then Account:

class Account extends JsonEnvelope {
    public Account(SmartJson request, String name) {
        this(new SmartJson(new ObAccount(request, name)));
    }

    private Account(SmartJson account) {
        super(
            "IBAN".equals(account.leaf("SchemeName"))
                ? new IBAN(account)
                : new Other(account)
        );
    }
}

Notice the public constructor creates an intermediate ObAccount object (“OB” means Open Banking - the schema of the original request we are transforming) which it passes to the primary constructor. It’s just a trick to avoid specifying the full JSON path to the required leaf elements each time. It looks like this:

class ObAccount extends JsonEnvelope {
    ObAccount(SmartJson request, String name) {
        super(request.at("/Data/Initiation/" + name));
    }
}

Finally, we need Settlement. That’s pretty simple:

class Settlement extends JsonEnvelope {
    public Settlement(SmartJson message) {
        super(new MutableJson()
            .with("Amount", message.leaf("/Data/Initiation/InstructedAmount/Amount"))
            .with("Currency", message.leaf("/Data/Initiation/InstructedAmount/Currency"))
        );
    }
}

So the full model of our problem - the required JSON - is this:

class PaymentInitiation extends JsonEnvelope {
    public PaymentInitiation(Json message) {
        this(new SmartJson(message));
    }

    PaymentInitiation(SmartJson message) {
        super(new MutableJson()
            .with(
                "GroupHeader",
                new MutableJson()
                    .with("MessageIdentification", message.leaf("/Data/ConsentId"))
                    .with("CreationDateTime", LocalDateTime.now().toString())
                    .with("NumberOfTransactions", "1")
            )
            .with(
                "PaymentInformation",
                new MutableJson()
                    .with("PaymentId", UUID.randomUUID().toString())
                    .with("PaymentMethod", message.leaf("/Data/Initiation/Instrument"))
                    .with("PaymentPurpose", message.leaf("/Data/Initiation/Purpose"))
                    .with("Debtor", message.leaf("/Data/Initiation/Debtor/Name"))
                    .with("DebtorAccount", new Account(message, "DebtorAccount"))
                    .with("Settlement", new Settlement(message))
                    .with("Creditor", message.leaf("/Data/Initiation/Creditor/Name"))
                    .with("CreditorAccount", new Account(message, "CreditorAccount"))
            )
        );
    }
}

With Account being a combination of several smaller classes:

class Account extends JsonEnvelope {
    public Account(SmartJson request, String name) {
        this(new SmartJson(new ObAccount(request, name)));
    }

    private Account(SmartJson account) {
        super(
            "IBAN".equals(account.leaf("SchemeName"))
                ? new IBAN(account)
                : new Other(account)
        );
    }

    class ObAccount extends JsonEnvelope {
        ObAccount(SmartJson request, String name) {
            super(request.at("/Data/Initiation/" + name));
        }
    }

    class IBAN extends JsonEnvelope {
        IBAN(SmartJson account) {
            super(new MutableJson().with(
                "IBAN", account.leaf("Identification")
            ));
        }
    }

    class Other extends JsonEnvelope {
        Other(SmartJson account) {
            super(
                new MutableJson().with(
                    "Other",
                    new MutableJson()
                        .with("Identification", account.leaf("Identification"))
                        .with("SchemeName", account.leaf("SchemeName"))
                )
            );
        }
    }
}

And as we all know, the model of the problem, implemented in code, is just the solution.

The difference

The procedural implementation we wrote earlier is just that - a procedure in a mapper class. It is an imperative list of steps the computer has to make to convert the original JSON message into the one we needed. It comprises statements, going one after another, telling the computer what to do1. The alternative we just wrote, in contrast, is declarative and object oriented. Notice there are zero methods, and no statements going one after another. It is just definitions of objects and their composition - the actual result JSON we need.

Final thoughts

Notice we could have created dedicated classes for GroupHeader and PaymentInformation, like we did for Account and Settlement. Or we could have went the opposite way and not created any dedicated classes at all, simply writing a big explicit composition of nested MutableJsons. How can we decide how much nesting is ok, and how much is too much? I use the following rules of thumb:

  • If a JSON can be reused in different places (like Account in our example), create a class for it.
  • If nested code becomes too large to fit in a screen, extract classes to make it narrower.
  • If it starts to look too chaotic due to too many line breaks, extract classes to make it more tidy (that’s why I extracted Settlement).
  • Otherwise keep nesting.
  1. A statement like groupHeader.with("NumberOfTransactions", "1"); perhaps does not look too imperative, but that is only because it uses nereides-jackson JSON processing library, which was designed for applications written in declarative style. This statement written in some mainstream library would look more like groupHeader.put("NumberOfTransactions", "1"); Still, this is just naming, and has no essential difference for the structure of the application. 

Written on December 14, 2020