SaFi Bank Space : Working with BigDecimal

TODO This is just draft right now. → Techlead discussion is expected.

Since we need high precision for our computation with money we decided to utilize java.math.BigDecimal.

Working with it is not that straightforward so that’s why this document exists.

BigDecimal

BigDecimal represents an immutable arbitrary-precision signed decimal number. It consists of two parts:

  • Unscaled value – an arbitrary precision integer

  • Scale – a 32-bit integer representing the number of digits to the right of the decimal point

For example, the BigDecimal 3.14 has the unscaled value of 314 and the scale of 2.

Creation

Problem

Doubles aren’t accurate but BigDecimals try to represent them as accurate as possible.

assert(BigDecimal(0.1) > BigDecimal("0.1")) // true

Reason

The results of BigDecimal(double val) constructor can be somewhat unpredictable. One might assume that writing new BigDecimal(0.1) in Java creates a BigDecimal which is exactly equal to 0.1 (an unscaled value of 1, with a scale of 1), but it is actually equal to 0.1000000000000000055511151231257827021181583404541015625. This is because 0.1 cannot be represented exactly as a double (or, for that matter, as a binary fraction of any finite length). Thus, the value that is being passed in to the constructor is not exactly equal to 0.1, appearances notwithstanding.

Solution

Use only BigDecimal(String val) constructor.

If necessary to use double/float as source for BigDecimal use string conversion like

public static BigDecimal valueOf(double val)

Operations

Using operators

BigDecimal was designed to use methods rather then operators. With operators we can see some unexpected behavior:

For division the scale is taken from first argument.

println(BigDecimal("1.000") / BigDecimal("3.00"))  // 0.333
println(BigDecimal("1.00") / BigDecimal("3.000"))  // 0.33
println(BigDecimal("0.05") / BigDecimal("2"))      // 0.02

For rest of the operations it seems fine.

println(BigDecimal("1.23") + BigDecimal("1.2345"))  // 2.4645
println(BigDecimal("1.23") - BigDecimal("2.345"))   // -1.115
println(BigDecimal("1.234") * BigDecimal("2.3"))    // 2.8382

Division

Problem

BigDecimals have arbitrary-precision, but some number needs infinite precision.

BigDecimal("1").divide(BigDecimal("3"))

This will cause

java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.

Reason

BigDecimal throws this exception in case it cannot represent the number exactly and it’s up to the programmer to solve the inexact representation.

Solution

Pass MathContext to the division in all cases. Especially when the numbers are parametrized.

BigDecimal("1").divide(BigDecimal("3"), MathContext.DECIMAL128)

or

BigDecimal("1").divide(BigDecimal("3"), MathContext(20, RoundingMode.HALF_UP))

20 in example above represents the number of significant digits.

MathContext

https://docs.oracle.com/javase/8/docs/api/java/math/MathContext.html

  • Precision: The number of digits to be used for an operation; this accuracy is rounded to results.

  • RoundingMode: a RoundingMode object that determines the rounding algorithm to be used.

MathContext doesn’t work with number of decimal places but with number of significant digits.

Rounding

We covered rounding topic a bit in “Division“ section. But what if we need to round for decimal places?

Problem

Using MathContext doesn’t solve rounding for specific number of decimal places.

val rounded = BigDecimal("0.0725").round(MathContext(3, RoundingMode.HALF_UP)) // 0.0725

Reason

MathContext doesn’t work with number of decimal places but with number of significant digits.

Solution

Use BigDecimal .setScale instead of BigDecimal.round.

val rounded = BigDecimal("0.0725").setScale(3, RoundingMode.HALF_UP) // 0.073

Testing

Problem

Equals shouldn’t be used for comparing BigDecimal values.

assertEquals(BigDecimal("1000.00"), BigDecimal("1000"))     // false
assertTrue(BigDecimal("1000.00").equals(BigDecimal("1000")) // false
assertTrue(BigDecimal("1000.00") == BigDecimal("1000"))     // false

Reason

The equals method considers two BigDecimal objects as equal only if they are equal in value and scale.

Solution

Use compareTo if you are not interested in equal scale.

assertTrue(BigDecimal("1000.00").compareTo(BigDecimal("1000") == 0)) // true

Serialization

Problem

We want to serialize BigDecimal as string (API output).

Reason

We don’t want loose precision during conversions from/to floats.

Solution

We can use serializer of object mapper in Application.kt.

@Factory
@Replaces(ObjectMapperFactory::class)
internal class CustomObjectMapperFactory : ObjectMapperFactory() {
    /**
     * Overrides Jackson object mapper, so we can set BigDecimal serialization globally.
     */
    @Singleton
    @Replaces(ObjectMapper::class)
    override fun objectMapper(
        jacksonConfiguration: JacksonConfiguration?,
        jsonFactory: JsonFactory?
    ): ObjectMapper {
        val mapper: ObjectMapper = super.objectMapper(jacksonConfiguration, jsonFactory)
        return mapper.registerModule(SimpleModule().addSerializer(BigDecimal::class.java, ToStringSerializer()))
    }
}

Or we set set @JsonFormat annotation globally in Application.kt (older solution).

@Factory
@Replaces(ObjectMapperFactory::class)
internal class CustomObjectMapperFactory : ObjectMapperFactory() {
    /**
     * Overrides Jackson object mapper, so we can set annotations globally.
     */
    @Singleton
    @Replaces(ObjectMapper::class)
    override fun objectMapper(
        jacksonConfiguration: JacksonConfiguration?,
        jsonFactory: JsonFactory?
    ): ObjectMapper {
        val mapper: ObjectMapper = super.objectMapper(jacksonConfiguration, jsonFactory)
        return mapper.configOverride(BigDecimal::class.java).setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.STRING))
    }
}

Open Questions

  • Is there a way how to set MathContext globally?

  • What maximum precision we will use?

  • Can we enforce BigDecimal scale on global level (Juraj Machac wants to represent amounts with predefined scale BigDecimal(BigInteger("20"), 5))?

Sources