Struct vs Map JSON Mapping in Ballerina Service Contracts: A Decision Guide
Deciding between typed records and map-based JSON handling in Ballerina services: a comparison of compile-time safety versus runtime flexibility, with working examples and validation steps.
24 Feb 2026, 15:46 UTC

When you build a Ballerina service that accepts or returns JSON, you face an early design decision: bind payloads to typed records (structs) or handle them as open maps. The choice determines where errors surface — at compile time or in production — and how painful schema changes become. This guide compares both approaches and shows how to validate whichever you pick.
The decision and its constraints
Typed records give you compile-time validation: if a required field is missing or has the wrong type, bal check fails before deployment. Maps (map<json> or plain json) accept any shape at runtime, which suits dynamic or third-party payloads you do not control, but pushes failures to runtime when expected keys are absent.
The real constraint is schema ownership. If your team defines the contract and it changes rarely, records win. If the payload comes from an external system with unpredictable fields, maps are safer to integrate but demand defensive coding.
Comparison of supported options
| Criterion | Record-based binding | Map-based handling |
|---|---|---|
| Error detection | Compile time via bal check | Runtime (missing keys, wrong types) |
| Adding a field | Code change and recompile | No change needed |
| Unknown payload shapes | Rejected or requires optional fields | Accepted natively |
| Refactoring safety | High — compiler finds all usages | Low — string keys are unchecked |
| Best fit | Owned, stable contracts | External, evolving, or sparse payloads |
Trade-offs in practice
Records shine for internal APIs. Ballerina's data-binding automatically converts JSON to a record when the resource parameter is typed, and mismatches produce a 400 Bad Request rather than a silent failure. The cost is rigidity: adding a field means touching the type definition, and if clients send extra fields you may need to configure open vs closed records deliberately.
Maps shine for webhooks and integration glue where the sender controls the shape. The cost is that every access needs a nil/type check, and a typo in a key string compiles fine but fails at runtime. Mixing both styles in one service raises cognitive load — pick a default and document exceptions.
One caution regardless of choice: schema evolution. Adding a required field to a record breaks existing clients. Prefer optional fields (string? name) or version your endpoints.
Concrete implementation
A record-based resource (Ballerina Swan Lake; run in your project directory, no special permissions needed):
import ballerina/http;
type Order record {|
string orderId;
int quantity;
string? note;
|};
service /shop on new http:Listener(8080) {
resource function post orders(Order payload) returns string {
return payload.orderId + " x" + payload.quantity.toString();
}
}The equivalent map-based version for unknown payloads:
resource function post events(json payload) returns string|error {
map<json> m = check payload.ensureType();
json kind = m["type"] ?: "unknown";
return kind.toString();
}How to validate
Run bal check to confirm the record binding compiles and the annotations resolve. Then start the service with bal run and send a sample payload:
curl -X POST localhost:8080/shop/orders \
-H "Content-Type: application/json" \
-d '{"orderId":"A1","quantity":2}'Expect the concatenated string back. Then send a payload missing orderId — you should get a 400, proving compile-time-shaped validation reaches the wire. For the map version, omit type and confirm the fallback unknown is returned. Add a bal test case per branch so regressions are caught in CI.
Limitations
Record binding behavior around extra fields and default values has evolved across Ballerina releases; verify against your distribution's release notes if you rely on closed-record strictness. Deeply nested dynamic JSON is awkward in both styles — consider generating record types from a JSON Schema sample rather than hand-writing maps.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.