IBM i modernization · RPG, Java & data integrity
By Guido Zeuner · About 20 minutes
RPG to Java: Seven Type-Mapping Traps That Change Your Data
Follow one shipment through RPG, Db2 for i, Java, and JSON—and test whether its business meaning survives the trip.
Modernization often becomes visible at the edges: a browser replaces a terminal, an API opens up a workflow, or a Java service takes on a task that used to live entirely on IBM i. Underneath, the business still depends on familiar records—orders, prices, delivery dates, and customer references. Those records must mean the same thing after the architecture changes.
That sounds straightforward until a field leaves the system that supplied its rules. A packed decimal arrives in an object; fixed-width text becomes a string; a timestamp turns into an API value. The conversion succeeds, but a detail the business relies on has quietly disappeared.
RPG to Java type mapping is where those details need explicit decisions. The following shipment example makes seven of them visible, with expected results and counterexamples you can carry into your own acceptance tests.

Start with one shipment and a written contract
A workshop ships 2.375 meters of cooling hose at EUR 37.46 per meter. The charge should be EUR 88.97. One small change in the conversion code produces EUR 89.15 instead. Both calculations use exact decimal arithmetic. Neither throws an exception.
The same shipment can lose the leading zeros in its customer reference, shift a recorded event by an hour, or turn an undecided discount into an approved zero. The Java types can look perfectly reasonable while the shipment arriving at the other end is no longer the shipment you sent.
On IBM i, field definitions frequently travel through database columns, externally described files, data structures, and program interfaces. When that data reaches Java, some restrictions disappear from the type itself. A String has no built-in CHAR(8) width or CCSID. A BigDecimal does not inherit the capacity of a PACKED(11:2) field.
For this shipment, the contract is deliberately specific. The dates are synthetic test data; the German text exercises the encoding path. Keeping these values together makes it possible to follow the same business record through all seven failures.
| Field | Example | Contract |
|---|---|---|
| Length / unit price | 2.375 / 37.46 |
Three and two decimal places; round the product to cents using the agreed rule. |
| Amount | 88.97 |
DECIMAL(11,2); preserve two decimal places and enforce capacity. |
| Customer reference | 00427186 |
Exactly eight ASCII digits, including the leading zeros. |
| Delivery date | 2026-11-12 |
A calendar date. |
| Recorded local time | 2026-11-10T16:42:18.735219 |
Microsecond precision; interpret using the explicit business zone Europe/Berlin. |
| Description | Kühlleitung Größe 7 |
Preserve the characters through the tested CCSID 273 conversion path. |
| Label | ␠␠Tor␠C |
Seven logical characters, including two leading spaces, in a twelve-character storage field. Here, ␠ marks an ordinary space. |
| Discount | NULL or 0.00 |
Pending decision or a present zero value; nullable DECIMAL(5,2). |
The examples use Java 17 language features. The JSON checks use Jackson 2.20.0 to reproduce the investigated behavior. These version numbers identify the test setup; they are not a recommendation to deploy a particular historical release. Imports and surrounding setup are omitted from some excerpts.
1. Rounding the wrong value
The first trap survives the advice to “use BigDecimal for money.” The length uses PACKED(7:3), the price PACKED(9:2), and the amount PACKED(11:2). Each field has a different job.
Rounding the length changes the quantity being billed. Rounding the product changes the monetary result to the agreed currency precision.
BigDecimal quantity = new BigDecimal("2.375");
BigDecimal unitPrice = new BigDecimal("37.46");
// Wrong for this contract: change the quantity first.
BigDecimal wrong = quantity.setScale(2, RoundingMode.HALF_UP)
.multiply(unitPrice)
.setScale(2, RoundingMode.HALF_UP);
// Agreed rule: round the product to cents.
BigDecimal correct = quantity.multiply(unitPrice)
.setScale(2, RoundingMode.HALF_UP);
EUR 88.97Round the product
EUR 89.15Round the length first
EUR 0.18Unintended overcharge
The unrounded product is 88.96750. The prematurely rounded quantity produces 89.1548. The difference comes from changing an input, independently of binary floating-point approximation.
In the RPG example, the corresponding operation rounds the product:
correctAmount = %dech(shippedLength * unitPrice : 11 : 2);
HALF_UP is the chosen laboratory rule, including for negative halfway values. Existing pricing, tax, and accounting rules may specify something else. Agree on that rule before comparing implementations. IBM documents RPG half-adjust behavior and examples; Java documents the separate controls for scale, precision, and rounding. [1] [2]
Put the field limit back at the boundary
After the calculation, an amount entering DECIMAL(11,2) needs two decimal places and no more than eleven digits of precision. With that fixed scale, the maximum magnitude is 999999999.99. The local example checks both conditions explicitly:
public static BigDecimal requireDecimal(BigDecimal value, int precision, int scale) {
Objects.requireNonNull(value, "decimal value");
if (value.scale() != scale || value.precision() > precision) {
throw new IllegalArgumentException("Value does not fit the decimal contract");
}
return value;
}
This helper deliberately rejects a value with the wrong scale. It does not silently round an inbound message. The pricing calculation and the input validator have different responsibilities.
Also decide whether a test is checking numeric equality or representation. 88.97 and 88.970 compare numerically equal with compareTo, but their scales differ; BigDecimal.equals reflects that difference. The field contract determines which result is acceptable. [1]
Acceptance test: Expect 88.97, not merely agreement between two implementations. At a price of 37.48, expect 89.02; for the negative reversal, expect -89.02. Exercise both limits and reject the first value outside the field.
There is a database lesson here, too. In the recorded Db2 probe, a plain decimal cast produced 88.96; explicit SQL rounding was needed for the expected amount. In a separate overflow probe, the driver returned warnings rather than the exception the initial checker expected. Validate the result and inspect JDBC warning chains where relevant. An assumed failure mode is not a tested failure mode.
2. Turning a reference into a number
The customer reference contains digits, but no one is going to add two customer references together. Its eight-character representation is part of the interface.
String customer = "00427186";
String wrong = Integer.toString(Integer.parseInt(customer));
String correct = customerReference(customer);
public static String customerReference(String value) {
if (value == null || !value.matches("[0-9]{8}")) {
throw new IllegalArgumentException("Customer reference needs eight ASCII digits");
}
return value;
}
The numeric detour produces 427186. It has not changed the mathematical integer; it has changed the identifier the next system sees.
Use [0-9] when the contract means ASCII digits. Also resist the temptation to trim input before validation. If surrounding spaces are invalid, quietly removing them hides a contract violation.
Acceptance test: Assert both 00427186 and length eight. Reject seven-digit values, surrounding spaces, letters, and alternative Unicode digit forms. If another interface stores the reference numerically, specify its display width separately.
In a larger application, a customer-reference value object can carry the validation rule. The underlying Java representation can still be a string.
3. Assigning an instant before resolving local time
A delivery date answers “which day?” A local timestamp answers “what did the clock say?” An Instant identifies a position on the timeline. Those are different questions.
For this shipment, 2026-11-12 stays a LocalDate. The recorded timestamp stays a LocalDateTime until the explicit business zone is applied. Treating it as UTC creates 2026-11-10T16:42:18.735219Z. The expected instant in the Berlin example is 2026-11-10T15:42:18.735219Z.
A zone introduces two edge cases: some local clock readings do not exist during a spring transition; others occur twice during a fall transition. This adapter rejects both rather than silently selecting a resolution:
public static Instant uniqueInstant(LocalDateTime local, ZoneId zone) {
var offsets = zone.getRules().getValidOffsets(local);
return switch (offsets.size()) {
case 1 -> local.toInstant(offsets.get(0));
case 0 -> throw new IllegalArgumentException("Local time does not exist");
default -> throw new IllegalArgumentException("Local time is ambiguous");
};
}
getValidOffsets exposes those cases directly. By contrast, the convenient LocalDateTime.atZone method can move a gap time forward and chooses an offset for an overlap. That behavior is documented and may be useful; it still needs to match the business rule. [3] [4]
| Local time in Europe/Berlin | Valid offsets | Expected result |
|---|---|---|
2026-11-10T16:42:18.735219 |
One | 2026-11-10T15:42:18.735219Z |
2026-03-29T02:30 |
None | Reject: the local time does not exist. |
2026-10-25T02:30 |
Two | Reject: the local time is ambiguous. |
These are the offsets in the tested time-zone databases. Record the tzdb version with reproducibility evidence. If a system needs to preserve the exact instant of a past event, store that instant or an explicit offset as well; retaining only local time and a region name leaves the interpretation dependent on the applicable rules.
Check the driver before your own conversion
In the investigated JTOpen setup, calling getTimestamp with JVM zone Europe/Berlin had already normalized the spring gap to 03:30. A later validator could no longer see the original 02:30 value.
The corrected probe read the driver’s fixed timestamp text and parsed the local value before applying zone rules. That was a tested workaround for that setup, not a portable promise about every driver’s string format. Verify a typed JDBC retrieval path or the exact text format against your driver and configuration.
Acceptance test: Check the expected instant and all six fractional digits. Include the gap and overlap cases. For a TIMESTAMP(6) contract, reject Java values with finer-than-microsecond precision; value.getNano() % 1_000 == 0 checks that alignment. The wire format must also retain the agreed number of digits.
4. Accepting character substitution
Kühlleitung Größe 7 deliberately includes characters an ABC123 test would miss. A recognizable display is insufficient: the stored text must retain the characters needed for a parts search or a downstream document.
Java’s string-to-byte convenience method can replace characters that the target character set cannot represent. When replacement is unacceptable, use an encoder configured to report it:
Charset charset = Charset.forName("IBM273");
String text = "Kühlleitung Größe 7";
byte[] permissive = text.getBytes(charset);
ByteBuffer strict = charset.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(text));
In the local CCSID 273 check, the description encodes successfully. The euro sign does not; the strict encoder rejects it. An isolated surrogate is also rejected as malformed input. These are local codec observations, not a claim that an attempted Db2 write failed. The encoder’s error policies are described in the Java API. [5]
The expected IBM273 bytes for the unpadded description are:
D2D08893938589A3A4958740C7996AA18540F7
The same byte sequence appears in the recorded database return-path evidence. That independent comparison matters: an encode/decode round trip alone could agree with an incorrect choice of character set.
UTF-8 and EBCDIC byte sequences should differ. What must agree is the text they represent. For broader inputs, remember that encoded bytes, Java UTF-16 code units, Unicode code points, and displayed characters are different measures of length. A String.length() check alone cannot establish that an arbitrary string fits a byte-oriented field. [6]
Acceptance test: Check the exact Unicode content and the expected stored bytes for a known CCSID. Add an unrepresentable character and malformed input, and require explicit rejection where the contract forbids substitution.
5. Trimming away layout
The label’s two leading spaces position the printed text. The five trailing spaces fill its storage field. Treating all seven spaces as disposable destroys the layout.
String raw = " Tor C ";
String wrong = raw.trim();
String correct = logicalLabel(raw);
public static String logicalLabel(String raw) {
if (raw == null || raw.length() != 12
|| !raw.substring(7).equals(" ")) {
throw new IllegalArgumentException("Expected seven label characters and five pad blanks");
}
return raw.substring(0, 7);
}
trim() returns Tor C: five characters, no indentation. The field-specific adapter preserves ␠␠Tor␠C: seven characters, with the indentation intact. The ␠ symbols make spaces visible here; the Java string literals contain ordinary U+0020 spaces.
This is intentionally not a universal trimming helper. It knows that this particular contract has a seven-character logical portion followed by exactly five pad blanks. If another field permits meaningful trailing spaces or variable logical length, it needs a different contract.
SQL equality can also conceal the damage. Db2 for i compares character strings of unequal lengths using right-padding rules; strings that differ only in trailing blanks can compare equal. Empty text can compare equal to blank-filled text. Equality therefore does not prove identical length or layout. [7]
Acceptance test: Assert twelve stored characters, seven logical characters, and two leading spaces. Inspect length and bytes where layout matters. Do not accept “the SQL comparison passed” as the entire test.
6. Replacing “pending” with zero
For this discount field, SQL NULL means the decision is pending. A present 0.00 means the decision has been made and no discount applies. The distinction could control whether an order needs review.
// This primitive getter alone cannot distinguish SQL NULL from zero.
double wrong = rs.getDouble("DISCOUNT");
BigDecimal discount = rs.getBigDecimal("DISCOUNT");
boolean pending = rs.wasNull(); // Immediately after the relevant getter.
A primitive double cannot represent null. JDBC’s getDouble returns zero for SQL NULL; the caller must use wasNull() to distinguish it from a stored zero. getBigDecimal provides an object representation, while the immediate wasNull() call records the database null state. It refers to the last column read, so another getter must not intervene. [8]
In the recorded RPG return checker, the null indicator was -1 for the pending row and 0 for the row containing zero. A separate legacy adapter interprets the numeric sentinel -1.00; the modern message rejects it. Keep such translation at the legacy boundary rather than spreading it through pricing logic.
The same discipline applies to text. Missing, empty, and blank-filled values may have different meanings even if a database comparison treats some of them as equal. Decide those meanings field by field.
Acceptance test: Use separate pending and zero rows. Check both the value and null state immediately after retrieval. If you also support a legacy sentinel, prove that its adapter handles it and that the modern contract rejects it.
7. Trusting JSON conversion to enforce the contract
The Java object can be correct and still leave the next consumer room to misinterpret it. This example therefore defines a concrete JSON representation: the reference and amount are strings; the timestamp has six fractional digits; the business zone remains a separate field; the discount is an explicit null or a decimal string.
{
"schemaVersion": 1,
"customerNumber": "00427186",
"amount": "88.97",
"businessDate": "2026-11-12",
"localTimestamp": "2026-11-10T16:42:18.735219",
"businessZone": "Europe/Berlin",
"description": "Kühlleitung Größe 7",
"logicalLabel": " Tor C",
"discount": null
}
Strings are a deliberate choice for this interface, not a claim that JSON numbers are inherently inaccurate. RFC 8259 permits implementations to set limits on numeric range and precision. A consumer that routes an amount through binary double can introduce an approximation. Another consumer using a decimal parser can preserve a JSON number exactly within its supported range. Document the representation and test the actual consumer. [9]
Validate the incoming shape before values can be coerced
The original mapper accepted schemaVersion: 1.9 and produced integer 1. A record constructor checking for version 1 saw the already-converted value, not the invalid JSON token.
This behavior is reproducible with the tested Jackson 2.20.0 default mapper. Jackson documents floating-point-to-integer coercion through ACCEPT_FLOAT_AS_INT. Disabling that feature addresses this conversion; checking the full wire shape makes the rest of the contract explicit. [10]
public static ShipmentMessage readMessage(String json) throws IOException {
JsonNode root = MAPPER.readTree(json);
requireWireTypes(root);
return MAPPER.treeToValue(root, ShipmentMessage.class);
}
requireWireTypes is an application helper. It first requires a JSON object, then checks the version without truncation:
JsonNode version = root.get("schemaVersion");
if (version == null || !version.isIntegralNumber()
|| !version.canConvertToInt() || version.intValue() != 1) {
throw new IllegalArgumentException("schemaVersion must be the JSON integer 1");
}
It then requires strings for the reference, amount, date, local timestamp, zone, description, and label. The discount property must exist and contain either a string or JSON null. Missing and explicit null are different JSON inputs.
After those checks, the example validates the lexical formats, converts to the record, and enforces field rules: eight ASCII digits, decimal scale and capacity, valid dates, microsecond alignment, a resolvable business time, label length, and rejection of the legacy discount sentinel. The description’s target encoding and storage capacity remain the responsibility of the destination adapter.
A date pattern is not the whole date rule
The additional publication checks exposed another useful counterexample. With a custom date pattern, the tested mapper accepted February 30 and normalized it rather than rejecting it. The revised example explicitly requests strict parsing:
@JsonFormat(pattern = "uuuu-MM-dd", lenient = OptBoolean.FALSE)
LocalDate businessDate
The timestamp uses the corresponding strict pattern uuuu-MM-dd'T'HH:mm:ss.SSSSSS. Its explicit output format also preserves six fractional digits when the value ends in zeros, such as .735000. A general “microsecond-aligned” check alone would not guarantee that textual representation.
Calendar validity is only part of the check. Java’s date types permit years outside this interface’s range. The companion record also requires years 1 through 9999, excluding Java’s year zero. Check the actual RPG date format: IBM documents that range for *ISO, while two-digit-year formats have different limits. [11]
The accompanying reader also rejects unknown fields, duplicate property names, and trailing JSON input. Those are explicit choices for this versioned message. A system designed to accept additive fields may choose a different compatibility policy, but it should test that policy just as deliberately.
Acceptance test: Reject fractional or string versions, numeric reference and amount tokens, overflow, invalid calendar dates, unsupported precision, missing discount, and the legacy sentinel. Keep valid pending and zero messages in the same suite so stricter rejection does not break legitimate traffic.
Build a test that follows the data home
The recorded connected test followed this path:
- RPG wrote two synthetic source rows: one with a pending discount and one with a present zero.
- Java read the rows through JDBC and checked their field representations.
- The messages were serialized to UTF-8 JSON bytes and read back into Java.
- Java inserted separate return rows in Db2 for i.
- An independent RPG checker verified the returned shipment values and null indicators.
The returned amount, customer reference, date, timestamp, description, padded label, and discount states matched the expected values. The description used VARCHAR(64) in this return-path table; the earlier encoding probe used CHAR(40). Those are separate storage tests.
A round trip can still hide matching mistakes. One adapter might remove information and another might reconstruct a plausible replacement. The final checker needs independently specified expected values. Intermediate assertions then identify the transition responsible for a failure.
| Boundary | Positive case | Counterexample |
|---|---|---|
| Decimal calculation | 2.375 × 37.46 → 88.97 |
Premature rounding; negative halfway value; field overflow. |
| Reference | 00427186, length eight |
Numeric conversion; surrounding spaces; wrong digit alphabet. |
| Local time | The expected UTC instant and microseconds | UTC assumption; gap; overlap; unsupported fractional precision. |
| Character conversion | Exact description and known CCSID bytes | Unrepresentable character; malformed input; wrong character set. |
| Label | Two leading spaces; known logical and storage lengths | Full trimming; unexpected padding. |
| Nullable discount | Pending remains distinct from a present zero | Primitive default; misplaced wasNull(); legacy sentinel. |
| JSON reader | Valid pending and zero messages survive | Coerced tokens; missing state; overflow; normalized invalid date. |
What the evidence actually covers
The original investigation used IBM i 7.5, JTOpen 11.2, and Java running locally on Windows. The recorded final return-path run used Amazon Corretto 17.0.14 and Jackson 2.20.0 with Java Time support. Java was not running on IBM i.
JDBC accessed Db2 tables. A separate direct ProgramCall exchanged CHAR(8) and PACKED(11:2) parameters. That direct call has its own byte-layout and CCSID evidence; JDBC results alone would not establish its correctness.
For this publication, the companion examples were compiled for Java 17 and executed locally on Corretto 17.0.20.1+12-LTS, Jackson 2.20.0, and tzdb 2026d. All 79 local checks passed. They cover the calculations, identifiers, time resolution, codec behavior, label handling, and message acceptance and rejection rules.
The local null demonstration uses a JDK CachedRowSet to exercise JDBC getter semantics. It is not a new JTOpen measurement. The IBM i results above come from the retained integration evidence, not from a newly executed system run.
The lab did not establish HTTP behavior, production recovery, transactional guarantees, or every possible driver configuration. Those require tests of the actual deployment path.
Your mapping acceptance checklist
- Write down the field meaning. Identify quantities, references, money, calendar dates, local times, and decision states before choosing Java types.
- Specify the boundary rules. Include width, precision, scale, rounding point, encoding, padding, zone policy, and missing-value behavior.
- Make the expected result independent. Supply known amounts, reference strings, instants, byte sequences, and null states rather than comparing two implementations blindly.
- Give every rule a counterexample. Include negatives, halfway cases, limits, invalid dates, time transitions, unsupported characters, and incorrect JSON token types.
- Check what happens on failure. Test exceptions, warnings, rejection, and normalization. Confirm that invalid values cannot quietly become valid-looking inputs.
- Verify the real return path. Record runtime, driver, tzdb, connection route, and storage definitions. Recheck the contract when any of them changes.
A type table is a useful starting point. A tested field contract tells you whether the business survived the conversion.
For this shipment, the final question is concrete: Did the customer reference, charge, timestamp, description, label, and discount decision arrive home unchanged? Build your acceptance tests so they can answer it.
Sources and further reading
The example, test narrative, and editorial wording were developed from the author’s shipment lab. The following primary sources document the APIs and comparison rules used in the analysis. Sources checked for this edition on October 17, 2026.
- Java SE 17: BigDecimal — arithmetic, scale, precision, rounding, and equality.
- IBM ILE RPG: %DECH examples — decimal conversion with half adjust. The shipment’s negative and field-boundary results are supported separately by its IBM i 7.5 lab records.
- Java SE 17: ZoneRules.getValidOffsets — valid offsets for normal times, gaps, and overlaps.
- Java SE 17: LocalDateTime.atZone — default gap and overlap resolution.
- Java SE 17: CharsetEncoder — malformed and unmappable input policies.
- Java SE 17: String — encoding, trimming, and UTF-16 indexing.
- IBM Db2 for i 7.5: String comparisons — padding, trailing blanks, and empty strings.
- Java SE 17: ResultSet — numeric getters and the last-column semantics of wasNull.
- RFC 8259, Section 6: Numbers — numeric representation and implementation limits.
- FasterXML Jackson 2.20.0: DeserializationFeature — including ACCEPT_FLOAT_AS_INT. Other message-reader and date-parsing results are observations reproduced by the companion checks.
- IBM ILE RPG 7.5: Date Data Type — format-dependent date ranges, including the four-digit-year ISO range.
For the broader type comparison, see IBM RPG and Java: Data Type Comparison. For the surrounding architecture, see IBM RPG vs. Java on IBM i.
About the author: Guido Zeuner is a software developer and IT consultant working with RPG, Java, Db2 for i, APIs, and enterprise modernization. He publishes technical articles at tiny-tool.de. Research, wording, translation, and example checks for this edition were assisted by AI tools; the named documentation and recorded test results are the technical evidence.
Transparency Notice
Content published on tiny-tool.de is carefully researched, editorially reviewed, and updated regularly. Sources and quotations are provided in a transparent and traceable manner wherever possible. However, we do not guarantee the accuracy, completeness, or timeliness of the information provided. Errors cannot be ruled out.
Editorial Responsibility and Use of AI: Digital tools – including AI-based assistance systems – may be used to support research, structuring, language refinement, translation, code analysis, or visual production. Published content is editorially reviewed, revised, and approved by Guido Zeuner. Content selection, contextualisation, and publication remain under human responsibility. AI-generated output is not treated as an independent source. AI systems are not responsible authors or editors. More information about our use of AI in texts, images, videos, and digital personas is available on our AI Transparency page, which is currently available in German.
Audience Measurement (VG WORT / METIS): VG WORT tracking markers may be used to measure the reach of individual texts. As part of the METIS audience measurement process, a client ID may be generated and a so-called “METIS Session Cookie” may be set. The measurement is used to determine statistical access figures and as a basis for possible VG WORT remuneration. According to VG WORT, no personal usage profiles are created, and the measurement is not used for advertising or marketing tracking. More information is available in our Privacy Notice, which is currently available in German.
Please note: The content provided on this website is for general informational purposes only and does not constitute professional advice, including legal, tax, medical, technical, financial, or other specialist advice. Use of the content is at your own responsibility.
Advertising and Affiliate Links: Some posts may contain promotional references or affiliate links. These are identified accordingly. Clicking such links does not result in any additional cost to you; we may receive a small commission.
Trademark Notice: All brand names, logos, and product names are the property of their respective rights holders and are used solely for identification and descriptive purposes. No affiliation with the companies mentioned is implied unless explicitly stated.
External Links: This website contains links to external third-party websites. Although links are reviewed carefully, we assume no responsibility for the content of external websites. If we become aware of unlawful content, the respective links will be reviewed and removed where appropriate.



tiny-tool.de
tiny-tool.de