Skip to content

1.1 changed the C# decimal and VO-null wire format by default with no opt-out; make it a setting and emit null for VO members #454

Closed

Description

@dmealing

⚠️ Written by Claude (AI) on behalf of Doug Mealing. It may contain errors — take it with a grain of salt and verify before acting.

Summary

1.1.0 changed two parts of the C# wire contract by default, with no opt-out:

  1. Decimals are now JSON strings (ADR-0019 Amendment 1, W1). Every field.decimal property gets a [JsonConverter(typeof(DecimalWire.…))] or [DecimalWire.Declared(p, s)] attribute, plus a DecimalWire.g.cs per namespace.
  2. Value-object members that are null are now left out of the JSON (CHANGELOG [1.1.0]: "C# and Java: a value object omits an unset member on the wire instead of sending null, as the other ports do"). Every nullable VO member gets [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)].

Both changes come from normalizing the ports, which was the goal. But the existing C# adopters are the ones whose wire changed. A brownfield C# adopter that upgrades from 1.0.4 to 1.1.1 changes the JSON its live clients read (12.5 becomes "12.5", and {"x": null} becomes {}) without touching a line of metadata. Nothing fails at build or test time. The only way to keep the old contract today is to post-process the generated text: strip the attributes with a regex and drop DecimalWire.g.cs. That is what we had to do, and it is brittle.

Ask 1: make the wire format a project setting, like naming

naming in 1.1 is the right pattern. The default changed, one key in .metaobjects/config.json keeps the old behavior, every port reads it, and verify names the setting on a mismatch. The wire format should work the same way, for example:

{
  "schema_version": 1,
  "wire": {
    "decimals": "string",
    "nullMembers": "emit"
  }
}
  • wire.decimals: "string" (the 1.1 default, W1–W4) or "number" (the 1.0 C#/Java/Kotlin behavior). This lets a brownfield project move to strings deliberately, as its own consumer-reviewed change, instead of as a side effect of a version bump.
  • wire.nullMembers: see Ask 2 for the default.

Like naming, this must be implemented in every port. A port that ignores the key would silently disagree, which is the same trap the 1.1 upgrade guide warns about for naming.

Ask 2: emit null for value-object members, in every port (revisit the default)

Leaving nulls out looks like the wrong rule to normalize on:

  • It contradicts the decimal rule in the same release. W1 says "Null is null, with the key present." So entity fields keep the key while VO members drop it: two null rules in one payload.
  • It loses the absent-versus-null distinction that the generated PATCH contract relies on. The CHANGELOG states the contract: an absent field is untouched, and an explicit null clears it. If a client reads a VO with nulls left out and sends it back, it can no longer say "clear".
  • It breaks clients. === null checks stop matching, and TypeScript consumers see optional keys (x?: T) instead of x: T | null.
  • It changes stored data, not only API responses. A VO persisted as jsonb through the System.Text.Json converter (the generated ObjectConverter<TModel>) now writes documents without those keys. Key-existence queries (?, jsonb_exists) then behave differently on old and new rows.

Suggested resolution: always write the key with null (T | null) in all five ports, and treat an absent key as "not provided" on input only. That changes TS and Python instead of C# and Java, so it is breaking too, which is another reason it belongs behind Ask 1's setting.

Related bug: DecimalWire.g.cs collides in a single output directory

EntityGenerator.EmitDecimalWireFile always writes the bare path "DecimalWire.g.cs", once per namespace that has a decimal property (EntityGenerator.cs, around line 186 and line 1316). If a project maps objects to several namespaces through TypeOverrides / Convention but writes them all into one OutDir, the copies get the same path and CodegenRunner.Run throws:

System.InvalidOperationException: duplicate generated output path "DecimalWire.g.cs" — emitted by both "entity" and "entity".

MetaNetValidation.g.cs (EmitNetValidationFile) has the same latent problem. Possible fixes: put the namespace in the file name (DecimalWire.<Namespace>.g.cs), or emit one copy in the common ancestor namespace.

Repro / versions

  • Packages: MetaObjects, MetaObjects.Codegen, MetaObjects.Render 1.1.1, upgraded from 1.0.4; EF Core 8, PostgreSQL.
  • Codegen is hosted (CodegenRunner.Run with a subclassed EntityGenerator). ASP.NET MVC output uses System.Text.Json (AddJsonOptions), so the per-property attributes apply to every entity- or VO-shaped response.

Activity

  1. dmealing commented on Oct 24, 2026

    @dmealing
    MemberAuthor

    PR open: #457 (for 1.1.2).

    The JSON a generated API writes is now the project setting wire in .metaobjects/config.json, read the way naming is. Both keys default to native, which is what that language's standard serializer writes:

    • With no setting, C#, Java and Kotlin write decimals as JSON numbers and a value object's null members as null, as in 1.0.x.
    • The 1.1.0 form is opt-in: "wire": { "decimals": "string", "nullMembers": "omit" }.
    • Input rules are unchanged in every mode.
    • Every port's codegen drift gate names the wire block that reproduces committed output, instead of telling you to regenerate.
    • The C# DecimalWire.g.cs collision across namespaces is fixed.

    The PR body and docs/features/api-contract.md ("The wire setting") have the per-port grid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions