Series MapLesson 07 / 32
Build CoreOrdered learning track

Learn Java Data Mapper Json Xml Validation Part 007 Type Conversion Semantics

14 min read2790 words
PrevNext
Lesson 0732 lesson track0718 Build Core

title: Learn Java Data Mapper, JSON/XML Processing & Validation - Part 007 description: Type conversion semantics untuk Java boundary engineering: String, number, boolean, enum, date-time, money, identifier, Jackson, MapStruct, dan validation. series: learn-java-data-mapper-json-xml-validation seriesTitle: Learn Java Data Mapper, JSON/XML Processing & Validation order: 7 partTitle: Type Conversion Semantics tags:

  • java
  • data-mapper
  • type-conversion
  • jackson
  • mapstruct
  • jakarta-validation
  • serialization
  • deserialization
  • contract date: 2026-06-29

Part 007 — Type Conversion Semantics

Target skill: mampu mendesain, membaca, menguji, dan mempertahankan aturan konversi tipe antar boundary dengan presisi tinggi: JSON/XML/API/event/database-facing DTO/domain model/read model.

Di level beginner, type conversion sering dianggap sebagai hal kecil: String ke int, String ke UUID, String ke LocalDate, Enum.valueOf(), BigDecimal, dan seterusnya.

Di sistem enterprise, type conversion adalah contract policy.

Satu keputusan kecil seperti:

Integer.parseInt("00123")

bisa menghilangkan leading zero yang ternyata adalah bagian dari identitas bisnis.

Satu keputusan seperti:

new BigDecimal(0.1)

bisa menghasilkan nilai yang tidak sama dengan nilai uang yang dikirim client.

Satu keputusan seperti:

Enum.valueOf(Status.class, input)

bisa membuat sistem gagal saat provider menambahkan enum baru yang sebenarnya seharusnya masih bisa diterima sebagai UNKNOWN.

Satu keputusan seperti:

LocalDateTime.parse("2026-06-29T10:00:00")

bisa menyebabkan ambiguity karena tidak ada timezone/offset.

Di part ini kita tidak belajar “cara convert tipe” saja. Kita belajar cara berpikir agar conversion rule tidak menjadi hidden bug di boundary.


1. Kaufman Deconstruction

Berdasarkan pendekatan Josh Kaufman, skill ini kita pecah menjadi subskill kecil yang bisa dilatih cepat:

SubskillKemampuan yang harus dikuasai
Identify conversion boundaryTahu di mana data berubah bentuk: JSON/XML input, DTO/domain, mapper, validation, output
Classify conversion riskMembedakan lossless, lossy, ambiguous, locale-sensitive, timezone-sensitive
Define canonical representationMenentukan bentuk resmi untuk identifier, date-time, amount, enum, code
Enforce conversion policyMenggunakan Jackson, MapStruct, constructor, validator, atau custom converter dengan sengaja
Test conversion behaviorMembuat fixture untuk valid, invalid, edge, unknown, null, absence, overflow
Evolve conversion safelyMenambah alias/enum/code/date format tanpa merusak consumer

Kaufman-style practice untuk part ini:

  1. Ambil satu DTO yang punya String, BigDecimal, Enum, LocalDate, UUID.
  2. Tulis 10 payload valid dan 10 payload invalid.
  3. Jalankan deserialization, mapping, validation.
  4. Catat perilaku aktual.
  5. Ubah konfigurasi Jackson/MapStruct/validator.
  6. Pastikan perilaku berubah sesuai policy, bukan kebetulan.

2. Mental Model: Conversion Is Policy

Conversion bukan sekadar operasi teknis.

Conversion adalah keputusan tentang:

Contoh:

{
  "customerId": "000123",
  "amount": "100000.00",
  "currency": "IDR",
  "status": "approved",
  "effectiveDate": "2026-06-29"
}

Pertanyaan yang harus dijawab:

FieldPertanyaan conversion policy
customerIdApakah leading zero bermakna? Jika iya, jangan convert ke number.
amountApakah dikirim sebagai string atau number? Berapa scale yang valid?
currencyApakah ISO-4217? Apakah case-sensitive?
statusApakah menerima lowercase? Apakah unknown status boleh masuk?
effectiveDateApakah tanggal lokal? Apakah timezone diperlukan?

Top engineer tidak bertanya “bisa diconvert atau tidak?”. Mereka bertanya:

“Conversion ini mempertahankan meaning atau diam-diam mengubah meaning?”


3. Conversion Taxonomy

3.1 Lossless Conversion

Conversion disebut lossless jika informasi tidak hilang.

Contoh relatif aman:

String raw = "123";
int value = Integer.parseInt(raw);
String back = String.valueOf(value); // "123"

Tetapi ini tidak selalu lossless.

String raw = "00123";
int value = Integer.parseInt(raw);
String back = String.valueOf(value); // "123"

"00123" menjadi "123". Untuk angka matematika, mungkin aman. Untuk customer code, branch code, account suffix, atau regulatory reference number, ini bisa fatal.

3.2 Lossy Conversion

Conversion lossy membuang informasi.

Contoh:

BigDecimal amount = new BigDecimal("100.1200");
BigDecimal normalized = amount.stripTrailingZeros();

Secara numeric sama, tetapi scale berubah. Dalam beberapa domain, scale adalah metadata penting.

Contoh lain:

OffsetDateTime odt = OffsetDateTime.parse("2026-06-29T10:00:00+07:00");
LocalDateTime ldt = odt.toLocalDateTime();

Offset +07:00 hilang.

3.3 Ambiguous Conversion

Conversion ambiguous terjadi ketika satu input bisa punya banyak interpretasi.

01/02/2026

Bisa berarti:

  • 1 Februari 2026
  • 2 Januari 2026

Ambiguity seperti ini sebaiknya tidak diselesaikan oleh “kebiasaan library”. Harus ada contract.

3.4 Locale-Sensitive Conversion

Contoh:

1,234

Bisa berarti:

  • seribu dua ratus tiga puluh empat
  • satu koma dua tiga empat

Untuk API contract, hindari format angka lokal. Gunakan canonical machine-readable format.

3.5 Timezone-Sensitive Conversion

Date-time adalah sumber bug besar.

2026-06-29T10:00:00

Tanpa offset/timezone, ini hanya local date-time. Ia belum menunjuk instant global.

Untuk event timestamp, gunakan bentuk seperti:

2026-06-29T03:00:00Z
2026-06-29T10:00:00+07:00

Untuk tanggal bisnis lokal, gunakan LocalDate.


4. Conversion Pipeline

Pisahkan tahapan berikut:

4.1 Structural Parse

Apakah payload berbentuk valid?

JSON valid:

{ "amount": "100.00" }

JSON invalid:

{ "amount": "100.00", }

Ini failure di parser level.

4.2 Type Parse

Apakah raw value bisa dibaca menjadi tipe target?

{ "amount": "abc" }

Ini valid JSON, tetapi invalid amount.

4.3 Normalization

Apakah input perlu distandarkan?

String normalized = input.trim().toUpperCase(Locale.ROOT);

Normalization harus eksplisit. Jangan tersebar di banyak mapper.

4.4 Validation

Apakah nilai valid secara business/contract?

@DecimalMin("0.00")
@Digits(integer = 18, fraction = 2)
BigDecimal amount

4.5 Domain Construction

Apakah nilai bisa menjadi domain value object?

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        if (amount == null) throw new IllegalArgumentException("amount is required");
        if (currency == null) throw new IllegalArgumentException("currency is required");
        if (amount.scale() > 2) throw new IllegalArgumentException("scale must be <= 2");
    }
}

5. String Conversion

String adalah tipe paling berbahaya karena terlihat fleksibel.

5.1 String as Text

Gunakan String untuk text bebas:

public record CustomerRequest(
    String fullName,
    String note
) {}

Policy yang perlu jelas:

ConcernRule
trimApakah whitespace pinggir dihapus?
blankApakah "" dan " " valid?
normalizationApakah case disamakan?
lengthDiukur character, code point, atau byte?
encodingApakah output system mendukung Unicode?

5.2 String as Code

Banyak field terlihat seperti angka, tetapi sebenarnya code:

public record CustomerDto(
    String customerNumber,
    String branchCode,
    String productCode
) {}

Jangan convert ke number bila:

  • leading zero penting
  • panjang tetap penting
  • ada prefix/suffix
  • bukan operasi matematika
  • format diatur eksternal

Contoh buruk:

int branchCode = Integer.parseInt("001");

Contoh benar:

public record BranchCode(String value) {
    public BranchCode {
        if (value == null || !value.matches("\\d{3}")) {
            throw new IllegalArgumentException("branch code must be exactly 3 digits");
        }
    }
}

5.3 String Normalization

Buat normalization policy eksplisit.

public final class StringNormalizers {
    private StringNormalizers() {}

    public static String trimToNull(String value) {
        if (value == null) {
            return null;
        }
        String trimmed = value.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }

    public static String upperCode(String value) {
        String normalized = trimToNull(value);
        return normalized == null ? null : normalized.toUpperCase(Locale.ROOT);
    }
}

Hindari normalization yang diam-diam tersebar:

target.setCode(source.getCode().trim().toUpperCase());

Lebih baik centralize:

target.setCode(StringNormalizers.upperCode(source.getCode()));

6. Number Conversion

6.1 Integer vs Long vs BigInteger

Gunakan tipe berdasarkan semantic range, bukan convenience.

TipeCocok untuk
int / Integersmall bounded quantity
long / Longid teknis internal, count besar, epoch millis
BigIntegerangka sangat besar, cryptographic-like numeric, arbitrary precision
Stringidentifier numerik yang bukan angka matematika

Jangan memakai Long untuk semua identifier hanya karena database id bertipe bigint. API contract bisa berbeda dari storage shape.

6.2 Overflow Risk

Input:

{ "count": 999999999999999999999999 }

Jika target int, failure harus jelas.

Contoh boundary DTO:

public record QuantityRequest(
    @NotNull
    @Min(1)
    @Max(9999)
    Integer quantity
) {}

Di sini parsing dan validation punya tugas berbeda:

TahapFailure
Jackson parsevalue bukan integer atau overflow
Validationvalue integer tapi di luar business bound

6.3 BigDecimal for Money

Gunakan BigDecimal untuk money-like decimal.

Hindari:

BigDecimal amount = new BigDecimal(0.1);

Lebih baik:

BigDecimal amount = new BigDecimal("0.10");

Atau dari JSON string:

{
  "amount": "100.00",
  "currency": "IDR"
}

Kenapa string untuk money sering dipilih?

  • menghindari representasi floating point consumer
  • menjaga scale
  • mengurangi ambiguity number parser lintas bahasa
  • lebih eksplisit sebagai decimal exact

6.4 Scale Policy

Money tidak cukup hanya BigDecimal.

public record MoneyAmount(BigDecimal value) {
    public MoneyAmount {
        if (value == null) {
            throw new IllegalArgumentException("amount is required");
        }
        if (value.scale() > 2) {
            throw new IllegalArgumentException("amount scale must be <= 2");
        }
        if (value.signum() < 0) {
            throw new IllegalArgumentException("amount must be positive or zero");
        }
    }
}

Pertanyaan policy:

ConcernPilihan
scale lebih dari 2reject atau round?
trailing zerospreserve atau normalize?
negative amountvalid untuk refund/reversal atau tidak?
zero amountvalid atau tidak?
max valuedibatasi oleh contract?

Jangan rounding diam-diam di mapper.

Buruk:

amount.setScale(2, RoundingMode.HALF_UP);

Jika rounding adalah business rule, letakkan di domain service atau explicit policy, bukan accidental mapper.


7. Boolean Conversion

Boolean terlihat sederhana, tetapi input eksternal sering tidak bersih.

Contoh variasi:

true
false
Y
N
YES
NO
1
0
ACTIVE
INACTIVE

Untuk public API, paling aman gunakan boolean JSON native:

{ "active": true }

Untuk legacy XML/file/integration, sering perlu converter.

public final class BooleanCodeConverter {
    public boolean fromYn(String value) {
        if ("Y".equalsIgnoreCase(value)) return true;
        if ("N".equalsIgnoreCase(value)) return false;
        throw new IllegalArgumentException("expected Y or N");
    }

    public String toYn(boolean value) {
        return value ? "Y" : "N";
    }
}

Bahaya terbesar adalah defaulting.

boolean active;

Primitive boolean default ke false.

Kalau input absence berarti “tidak dikirim”, gunakan wrapper:

Boolean active;

Jangan membuat absence menjadi false tanpa sengaja.


8. Enum Conversion

Enum adalah contract hotspot.

8.1 Simple Enum

public enum OrderStatus {
    PENDING,
    APPROVED,
    REJECTED
}

Payload:

{ "status": "APPROVED" }

Ini mudah, tetapi fragile jika consumer/provider punya casing berbeda.

8.2 External Code vs Java Enum Name

Jangan selalu mengekspos Java enum name sebagai wire contract.

public enum PaymentStatus {
    WAITING_FOR_CUSTOMER("WAITING"),
    PAID("PAID"),
    FAILED("FAILED");

    private final String code;

    PaymentStatus(String code) {
        this.code = code;
    }

    @JsonValue
    public String code() {
        return code;
    }

    @JsonCreator
    public static PaymentStatus fromCode(String raw) {
        if (raw == null) return null;
        String normalized = raw.trim().toUpperCase(Locale.ROOT);
        return switch (normalized) {
            case "WAITING" -> WAITING_FOR_CUSTOMER;
            case "PAID" -> PAID;
            case "FAILED" -> FAILED;
            default -> throw new IllegalArgumentException("Unknown payment status: " + raw);
        };
    }
}

8.3 Unknown Enum Strategy

Ada tiga strategi:

StrategyKapan cocok
reject unknowncommand/request dari client internal yang harus strict
map to UNKNOWNevent/read model dari provider yang bisa evolve
store raw coderegulatory/audit/integration yang perlu preserve unknown value

Contoh raw-preserving model:

public record ExternalStatus(
    String rawCode,
    KnownStatus knownStatus
) {
    public enum KnownStatus {
        ACTIVE,
        SUSPENDED,
        CLOSED,
        UNRECOGNIZED
    }
}

Dengan ini raw input tetap tersimpan.

8.4 MapStruct Enum Mapping

MapStruct mendukung enum mapping compile-time. Untuk mapping enum yang tidak identik, gunakan mapping eksplisit.

@Mapper
public interface StatusMapper {
    @ValueMapping(source = "WAITING_FOR_CUSTOMER", target = "PENDING")
    @ValueMapping(source = "PAID", target = "COMPLETED")
    @ValueMapping(source = "FAILED", target = "REJECTED")
    OrderStatus toOrderStatus(PaymentStatus source);
}

Policy penting:

  • jangan mengandalkan nama enum sama jika meaning berbeda
  • buat mapping eksplisit untuk external enum
  • test semua enum constant
  • gagal build/test jika enum baru belum dimapping

9. Date-Time Conversion

Date-time harus dimodelkan berdasarkan semantic need.

Java TypeCocok untuk
Instantmachine timestamp, event occurrence, audit time
OffsetDateTimetimestamp dengan offset dari payload
ZonedDateTimewaktu dengan timezone rules, misalnya jadwal regional
LocalDateTimewaktu lokal tanpa offset; hati-hati untuk event global
LocalDatetanggal bisnis, due date, birth date
YearMonthbilling cycle, statement period
Durationlama waktu machine-based
Periodperiode kalender

9.1 Event Timestamp

Untuk event/audit:

public record AuditEvent(
    Instant occurredAt
) {}

Payload:

{
  "occurredAt": "2026-06-29T03:00:00Z"
}

9.2 Business Date

Untuk due date:

public record InvoiceRequest(
    LocalDate dueDate
) {}

Payload:

{
  "dueDate": "2026-07-31"
}

Jangan pakai Instant jika yang dimaksud tanggal bisnis lokal.

9.3 Offset Preservation

Jika offset dari provider penting untuk audit, jangan langsung convert ke Instant tanpa menyimpan context.

public record ProviderTimestamp(
    OffsetDateTime observedAt
) {}

2026-06-29T10:00:00+07:00 dan 2026-06-29T03:00:00Z menunjuk instant yang sama, tetapi representasi offset berbeda.

9.4 Jackson Java Time

Untuk Java 8+ date-time types, register Java Time module di konfigurasi ObjectMapper. Di banyak framework modern ini sudah dikonfigurasi, tetapi untuk library/internal mapper jangan berasumsi.

Contoh konfigurasi eksplisit:

ObjectMapper mapper = JsonMapper.builder()
    .addModule(new JavaTimeModule())
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
    .build();

Contract yang baik menentukan format, bukan membiarkan default framework berubah.


10. Money Conversion

Money bukan sekadar amount.

public record Money(
    BigDecimal amount,
    Currency currency
) {
    public Money {
        if (amount == null) {
            throw new IllegalArgumentException("amount is required");
        }
        if (currency == null) {
            throw new IllegalArgumentException("currency is required");
        }
        if (amount.scale() > currency.getDefaultFractionDigits()) {
            throw new IllegalArgumentException("invalid scale for currency");
        }
    }
}

10.1 JSON Shape

Pilihan umum:

{
  "amount": "100000.00",
  "currency": "IDR"
}

Atau minor unit:

{
  "minorUnits": 10000000,
  "currency": "IDR"
}

Trade-off:

ShapeKelebihanRisiko
decimal stringhuman-readable, exact decimalperlu scale validation
JSON numbersimpleconsumer floating point risk
minor unit integerexact, common payment systemsperlu currency fraction policy
object moneyexplicitlebih verbose

10.2 Do Not Hide Monetary Policy in Mapper

Buruk:

@Mapping(target = "amount", expression = "java(dto.amount().setScale(2, RoundingMode.HALF_UP))")
Payment toDomain(PaymentRequest dto);

Lebih baik:

@Mapping(target = "money", expression = "java(Money.of(dto.amount(), dto.currency()))")
Payment toDomain(PaymentRequest dto);

Lalu policy ada di Money.


11. Identifier Conversion

Identifier bisa punya banyak bentuk:

IdentifierRecommended boundary type
UUIDUUID atau canonical string
database idbiasanya jangan expose langsung
business codevalue object/string
composite idexplicit object
external provider idstring, preserve raw
correlation idstring/UUID tergantung standard internal

11.1 UUID

public record CustomerRequest(
    UUID customerId
) {}

Payload:

{
  "customerId": "018ff6c1-780d-7b43-bf76-7faabf1d2a11"
}

Jika UUID harus version tertentu, jangan cukup mengandalkan UUID.fromString.

public record RequestId(UUID value) {
    public RequestId {
        if (value == null) {
            throw new IllegalArgumentException("request id is required");
        }
        if (value.version() != 7) {
            throw new IllegalArgumentException("request id must be UUIDv7");
        }
    }
}

11.2 Composite Identifier

Buruk:

String accountKey = bankCode + "-" + branchCode + "-" + accountNumber;

Lebih baik:

public record AccountKey(
    String bankCode,
    String branchCode,
    String accountNumber
) {}

Dengan validation:

public record AccountKey(
    @Pattern(regexp = "\\d{3}") String bankCode,
    @Pattern(regexp = "\\d{3}") String branchCode,
    @Pattern(regexp = "\\d{10,16}") String accountNumber
) {}

12. Jackson Conversion Control

Jackson melakukan banyak conversion secara otomatis. Ini berguna, tetapi di boundary high-stakes harus dikendalikan.

12.1 Strict Unknown Property Policy

Untuk command/request internal, sering lebih aman strict:

ObjectMapper mapper = JsonMapper.builder()
    .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    .build();

Untuk event consumer yang harus forward-compatible, unknown field bisa diabaikan atau ditangkap.

@JsonIgnoreProperties(ignoreUnknown = true)
public record ProviderEvent(
    String eventId,
    String eventType
) {}

Atau gunakan extension bucket:

public final class ProviderEvent {
    private String eventId;
    private String eventType;
    private final Map<String, JsonNode> extensions = new LinkedHashMap<>();

    @JsonAnySetter
    public void putExtension(String name, JsonNode value) {
        extensions.put(name, value);
    }

    @JsonAnyGetter
    public Map<String, JsonNode> extensions() {
        return extensions;
    }
}

12.2 Enum Handling

Tentukan apakah unknown enum:

  • reject
  • null
  • default value
  • raw-preserved

Jangan pilih karena library feature terlihat mudah. Pilih berdasarkan compatibility policy.

12.3 Coercion

Contoh coercion berisiko:

{
  "quantity": "10",
  "active": "true"
}

Apakah ini valid?

Untuk public API baru, lebih baik strict:

{
  "quantity": 10,
  "active": true
}

Untuk legacy integration, string coercion mungkin diterima tetapi harus explicit dan tested.


13. MapStruct Conversion Control

MapStruct bisa melakukan implicit conversion untuk beberapa tipe. Ini membantu boilerplate, tetapi harus diawasi.

13.1 Prefer Explicit Conversion for Semantic Types

@Mapper(uses = {MoneyMapper.class, IdentifierMapper.class})
public interface PaymentMapper {
    Payment toDomain(PaymentRequest request);
}
public class MoneyMapper {
    public Money toMoney(MoneyDto dto) {
        return new Money(dto.amount(), Currency.getInstance(dto.currency()));
    }
}

13.2 Date-Time Mapping

@Mapper
public interface DateMapper {
    default Instant toInstant(String value) {
        return value == null ? null : OffsetDateTime.parse(value).toInstant();
    }

    default String fromInstant(Instant value) {
        return value == null ? null : DateTimeFormatter.ISO_INSTANT.format(value);
    }
}

13.3 Fail When Target Is Unmapped

Gunakan policy ketat:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CustomerMapper {
    Customer toDomain(CustomerRequest request);
}

Ini tidak menyelesaikan semua semantic bug, tetapi mencegah field baru diam-diam tidak dimapping.


14. Validation and Conversion Order

Urutan yang sering benar:

Tetapi ada kasus lain:

CaseOrder
JSON malformedparser fails before validation
wrong primitive typedeserialization fails before validation
syntactically valid but semantically wrongvalidation/domain fails
external raw code unknown but must be preserveddeserialize raw string first, interpret later
patch with absence/null distinctionparse with presence-aware wrapper before validation

Contoh:

public record CreatePaymentRequest(
    @NotNull
    @DecimalMin("0.01")
    @Digits(integer = 18, fraction = 2)
    BigDecimal amount,

    @NotBlank
    @Pattern(regexp = "[A-Z]{3}")
    String currency
) {}

Jika payload:

{ "amount": "abc", "currency": "IDR" }

Maka amount gagal di deserialization.

Jika payload:

{ "amount": "-10.00", "currency": "IDR" }

Maka deserialization sukses, validation gagal.


15. Conversion Error Design

Jangan expose raw exception internal.

Buruk:

{
  "message": "Cannot deserialize value of type `java.math.BigDecimal` from String \"abc\""
}

Lebih baik:

{
  "code": "INVALID_FIELD_TYPE",
  "message": "Field amount must be a decimal number.",
  "field": "amount",
  "rejectedValue": "abc"
}

Untuk high-stakes systems, error model harus:

PropertyReason
stable codeconsumer bisa automate
field pathuser/support tahu lokasi
safe rejected valuejangan leak secret/PII
categoryparse/type/validation/business
correlation idtraceable

16. Production Decision Matrix

Field KindBoundary RepresentationDomain RepresentationValidation
human namestringvalue object/stringlength, blank, allowed chars if needed
branch codestringBranchCodeexact pattern
quantityintegerint/value objectmin/max
moneydecimal string/objectMoneyscale, currency, range
enum statusstring codeenum/value objectknown/unknown strategy
timestampISO offset/instant stringInstant/OffsetDateTimerequired, temporal bound
business dateISO date stringLocalDaterange/business calendar
UUIDcanonical stringUUID/value objectformat/version
composite idobjectvalue objectcomponent constraints

17. Anti-Patterns

17.1 “Everything Is String”

public record PaymentRequest(
    String amount,
    String currency,
    String createdAt,
    String active
) {}

Ini menunda semua error ke tempat yang tidak jelas.

17.2 “Everything Is Domain Type at API Edge”

public record PaymentRequest(
    Money money,
    Account account,
    Customer customer
) {}

Ini membuat deserialization layer tahu terlalu banyak tentang domain graph.

17.3 Silent Defaulting

int quantity;      // absence -> 0
boolean active;    // absence -> false

Gunakan wrapper untuk input boundary jika absence bermakna.

17.4 Mapper as Business Rule Dumping Ground

Mapper boleh mengubah bentuk. Mapper tidak seharusnya menjadi tempat tersembunyi untuk business decision seperti fee, rounding, eligibility, state transition.

17.5 Locale-Dependent Parsing in API

NumberFormat.getInstance().parse(input);

Untuk machine contract, gunakan canonical format.


18. Testing Strategy

18.1 Golden Conversion Fixtures

Buat fixture:

valid-money-decimal-string.json
invalid-money-too-many-fraction-digits.json
invalid-money-negative.json
valid-business-date.json
invalid-ambiguous-date.json
unknown-enum-provider-event.json

18.2 Test Matrix

CaseExpected
"00123" customer codepreserved as "00123"
"00123" quantityrejected or parsed? explicit
100.00 money JSON numberaccepted/rejected based on contract
"100.001" moneyrejected if scale > 2
"approved" enumaccepted if case-insensitive policy
"NEW_PROVIDER_STATUS" enumrejected/UNKNOWN/preserved based on boundary
"2026-06-29T10:00:00" timestamprejected if instant required
"2026-06-29" business dateaccepted for LocalDate
missing booleanabsence preserved
null booleannull preserved/rejected based on validation

18.3 Property-Based Conversion Checks

Untuk conversion yang harus round-trip:

String raw = generator.nextValidBranchCode();
BranchCode code = BranchCode.parse(raw);
assertEquals(raw, code.value());

Untuk canonicalization:

CurrencyCode code = CurrencyCode.parse(" idr ");
assertEquals("IDR", code.value());

19. Mini Case Study: Payment Create Request

19.1 DTO

public record CreatePaymentRequest(
    @NotBlank
    @Pattern(regexp = "[A-Z0-9]{8,32}")
    String requestId,

    @NotNull
    @DecimalMin("0.01")
    @Digits(integer = 18, fraction = 2)
    BigDecimal amount,

    @NotBlank
    @Pattern(regexp = "[A-Z]{3}")
    String currency,

    @NotNull
    LocalDate valueDate,

    @NotNull
    PaymentMethod method
) {}

19.2 Domain Command

public record CreatePaymentCommand(
    RequestId requestId,
    Money money,
    LocalDate valueDate,
    PaymentMethod method
) {}

19.3 Mapper

@Mapper(
    unmappedTargetPolicy = ReportingPolicy.ERROR,
    uses = {MoneyMapper.class, RequestIdMapper.class}
)
public interface PaymentCommandMapper {
    @Mapping(target = "money", source = ".")
    CreatePaymentCommand toCommand(CreatePaymentRequest request);
}
public class MoneyMapper {
    public Money toMoney(CreatePaymentRequest request) {
        return new Money(
            request.amount(),
            Currency.getInstance(request.currency())
        );
    }
}

19.4 Why This Shape Works

  • DTO is contract-facing.
  • Validation checks request-level syntax and shape.
  • Mapper composes domain value objects.
  • Domain value objects enforce deeper invariant.
  • Conversion policy is not hidden inside random setters.

20. Engineering Checklist

Sebelum merge code yang mengubah conversion:

  • Apakah field ini code, number, text, date, timestamp, amount, atau identifier?
  • Apakah conversion lossless?
  • Apakah leading zero, scale, offset, casing, atau raw code perlu dipertahankan?
  • Apakah unknown enum harus reject, default, unknown, atau preserve raw?
  • Apakah null dan absence dibedakan?
  • Apakah Jackson coercion sesuai contract?
  • Apakah MapStruct implicit conversion aman?
  • Apakah validation terjadi di layer yang benar?
  • Apakah error response stabil dan aman?
  • Apakah ada fixture untuk edge case?
  • Apakah perubahan ini backward/forward compatible?

21. Practice Drill

Buat DTO berikut:

public record ExternalTransferRequest(
    String transferId,
    String sourceAccount,
    String destinationAccount,
    String amount,
    String currency,
    String transferType,
    String requestedAt
) {}

Tugas:

  1. Tentukan field mana yang tetap string.
  2. Tentukan value object domain.
  3. Tentukan enum strategy untuk transferType.
  4. Tentukan apakah requestedAt harus Instant, OffsetDateTime, atau LocalDateTime.
  5. Tulis invalid fixtures:
    • amount negative
    • amount scale terlalu panjang
    • currency lowercase
    • unknown transfer type
    • timestamp tanpa offset
    • account number leading zero
  6. Tulis mapper MapStruct.
  7. Tulis validation rules.
  8. Jelaskan mana yang parsing error, validation error, dan domain invariant error.

22. Summary

Type conversion adalah salah satu tempat paling umum bug boundary muncul.

Mental model utama:

Do not convert type until you understand the meaning of the value.

Rules yang harus diingat:

  1. Identifier yang terlihat numerik belum tentu number.
  2. Money butuh amount + currency + scale policy.
  3. Date-time harus dipilih berdasarkan semantic: instant, local date, offset time, zoned time.
  4. Enum wire contract sebaiknya tidak bergantung membabi buta pada Java enum name.
  5. Unknown enum adalah compatibility decision.
  6. Null, absence, default, dan empty value harus dibedakan.
  7. Mapper bukan tempat tersembunyi untuk business policy.
  8. Conversion harus punya fixture test.

Part berikutnya membahas object graph, identity, cycles, dan reference handling: masalah yang muncul ketika model Java berbentuk graph tetapi payload JSON/XML biasanya berbentuk tree.


References

Lesson Recap

You just completed lesson 07 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.