Object oriented JSON mapping
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:
GroupHeader
comprises the following fields:MessageIdentification
isConsentId
in original message.CreationDateTime
is the time when instruction was received.NumberOfTransactions
is1
, since there is only one payment.
PaymentInformation
comprises the following fields:PaymentId
is any ID, uniquely identifying the new message.PaymentMethod
isInstrument
in original message.PaymentPurpose
isPurpose
.Debtor
isDebtor/Name
.DebtorAccount
has complex mapping rules which we will discuss later.Settlement
comprises the following fields:Amount
isAmount
in the original message’sInstructedAmount
node.Currency
isCurrency
in the original message’sInstructedAmount
node.
Creditor
isCreditor/Name
.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:
DebtorAccount
/CreditorAccount
:- If debtor account’s
SchemeName
in the original request is “IBAN”, then it is just a JSON with theIBAN
field. - Otherwise it is a JSON with
Other
field, which in turn has two fields:Identification
, which is account’s ID value.SchemeName
, which is the name of the ID’s scheme (other than IBAN).
- If debtor account’s
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:
GroupHeader
comprises the following fields:MessageIdentification
isConsentId
in original message.CreationDateTime
is the time when instruction was received.NumberOfTransactions
is1
, since there is only one payment.
PaymentInformation
comprises the following fields:PaymentId
is any ID, uniquely identifying the new message.PaymentMethod
isInstrument
in original message.PaymentPurpose
isPurpose
.Debtor
isDebtor/Name
.DebtorAccount
too complex to state here briefly.Settlement
comprises the following fields:Amount
isAmount
in the original message’sInstructedAmount
node.Currency
isCurrency
in the original message’sInstructedAmount
node.
Creditor
isCreditor/Name
.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:
DebtorAccount
/CreditorAccount
:- If debtor account’s
SchemeName
in the original request is “IBAN”, then it is just a JSON with theIBAN
field. - Otherwise it is a JSON with
Other
field, which in turn has two fields:Identification
, which is account’s ID value.SchemeName
, which is the name of the ID’s scheme (other than IBAN).
- If debtor account’s
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 MutableJson
s. 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.
-
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 likegroupHeader.put("NumberOfTransactions", "1");
Still, this is just naming, and has no essential difference for the structure of the application. ↩