Summary#
A Json member holds a JSON document — an object, an array, a number, whatever the row needs. It is stored as
real jsonb, so the database holds structure rather than a blob of text.
entity Job {
[Required, MaxLength(50)] string Name;
Json Detail; // whatever this job's handler wants to record
}There are two ways in, and the shape of your data picks between them. When the document has a shape worth naming,
declare a class and use JsonSerializer's Serialize — it takes any value: a scalar, a list, a map, a class
instance, an entity. When the shape is decided at the call site, write it inline as new { … }, which is itself a
Json value. Either way you read it back with Deserialize.
Signature#
Json Detail; // required — a JSON document has no honest empty value
Json? Detail; // optional — absent until something writes itDescription#
How do I write a document and read it back?#
The pair is the whole surface, and it is the ordinary C# shape. A class is the usual choice when the document has a
shape worth naming — but it is a choice, not a requirement:
class Detail { public int Attempt; public string Reason; }
entity Job { [Required, MaxLength(50)] string Name; Json Detail; }
void Record() {
var j = new Job { Name = "j1", Detail = JsonSerializer.Serialize(new Detail { Attempt = 2, Reason = "retry" }) };
}
int Attempts(Job job) {
return JsonSerializer.Deserialize<Detail>(job.Detail).Attempt;
}Anything serializable goes in, not just a class:
Json Tags = JsonSerializer.Serialize(["urgent", "billing"]); // an array
Json Reading = JsonSerializer.Serialize(42); // a scalar
Json Row = JsonSerializer.Serialize(order); // an entity, as its shallow shape⚠ The compiler does not check the text, but the database does. A Json column is jsonb, so Postgres rejects
anything that is not well-formed JSON — the failure arrives at the WRITE, not at the compile. Assembling the text by
hand is therefore not merely awkward, it is how you get a runtime error out of a program that compiled; Serialize
cannot produce malformed output.
A null where the class says it cannot be one is refused, naming the key#
Deserialize reads a document INTO a declared shape, so a null for a member that cannot hold one — an int, a
decimal, a bool, a DateTime, an enum — is refused, not turned into a default your code then trusts:
class Line { public string Sku; public int Qty; }
class Order { public List<Line> Lines = []; public Dictionary<string, Line> ByShelf = []; }
JsonSerializer.Deserialize<Order>("""{"Lines":[{"Sku":"a","Qty":1},{"Sku":"b","Qty":null}]}""");
// JsonException: JsonSerializer.Deserialize<Order>: `Lines[1].Qty` is `null` in the JSON, and it is declared
// `int`, which cannot be null. Declare it `int?` to accept null.The path is the document's own — dots for members, [i] for a list position, the key for a dictionary entry
(ByShelf.top.Qty) — so a large payload says exactly where the hole is. The browser reads it the same way and
refuses with the same sentence. A member declared int? takes the null; a member of a class type, or a string,
is optional in Osy# and takes it too.
…and so is a null ELEMENT, where the element type cannot be one#
The same rule holds one level down. List<int> and List<int?> are different types: the first refuses [1, null]
as C# does, naming the position; the second reads the null. A Dictionary's VALUE follows its value type the same
way, and so does an array's element (int[] against int?[]), a set's, and a nested list's:
enum Reading { Low, High }
class Sensor {
public List<int> Counts; // `{"Counts":[1,null]}` is refused: `Counts[1]`
public List<int?> Samples; // `{"Samples":[1,null]}` reads the null
public Dictionary<string, decimal> Rates; // `{"Rates":{"eur":null}}` is refused: `Rates.eur`
public Dictionary<string, decimal?> Extra; // `{"Extra":{"eur":null}}` reads it
public List<Reading?> Levels; // an enum element takes `?` the same way
public List<string> Labels; // a string element reads a null either way
}
int FirstCount(string json) {
var s = JsonSerializer.Deserialize<Sensor>(json);
return s.Counts[0]; // an int, never a null wearing one
}
int? FirstSample(string json) {
var s = JsonSerializer.Deserialize<Sensor>(json);
return s.Samples[0];
}The refusal names the element's type and the path to it — `Counts[1]` is `null` in the JSON, and it is declared `int`, which cannot be null. Declare it `int?` to accept null. — and a T named at the call refuses the same way:
JsonSerializer.Deserialize<List<int>>("[1,null]") says `[1]`. A string or a class element is optional in Osy#
and reads a null as null whatever it is declared.
When the shape is decided at the call site: new { … }#
A class is the right answer when the document has a shape worth naming and reusing. When it does not — a diagnostic
detail, a webhook body you are assembling, one row's worth of context — write the document inline:
entity Job { [Required, MaxLength(50)] string Name; Json Detail; }
void Record(int attempt, string reason) {
var j = new Job { Name = "j1", Detail = new { attempt = attempt, reason = reason, at = DateTime.UtcNow } };
}A name can be left out only where the value supplies one — new { v.Label } means Label = v.Label. A bare local
does not, and the compiler says so rather than inventing a key.
new { … } is a Json value — Osy# has no anonymous types, it has a document type — so it goes anywhere a
Json is accepted: a property, a local, an argument, a return.
Documents nest, and a nested one stays a document rather than becoming a quoted string:
Json outer = new { code = "E17", inner = new { retries = 3, fatal = false } };
// → {"code":"E17","inner":{"retries":3,"fatal":false}}Two things the compiler refuses, both because a document has one value per key: a repeated key
(new { a = 1, a = 2 }), and the dictionary spelling (new { ["a"] = 1 } — a document key is a name).
⚠ It is not an escape from the type system. A document is a Json value and nothing else, so
Runs = new { a = 1 } on an int column is refused exactly as any other type mismatch would be.
It is required by default, like every type with no honest zero#
A number defaults to 0 and a bool to false, and those are real answers. A document has no equivalent, so a bare
Json member is required and a new Job { … } that omits it is refused. Write Json? when a job may genuinely
not have one yet — see Optional and required members.
How do I cap how big a document may get?#
A JSON member has no natural limit, so [MaxBytes] is how you give it one:
[MaxBytes(1048576)] Json Payload; // at most 1 MB of stored JSONWorth doing on anything an outside system writes into. See constraints.
When NOT to use it#
The storage is structured, but Osy# has no way to reach inside it: you cannot filter, sort or group by something
within the document, secure a field of it, or bind one to a grid column. What you can do is read the whole document
out and deserialize it. So a Json member is not a shortcut for properties you were going to query.
Declare real properties whenever the shape is known — even a long one. Reach for Json when it genuinely varies per
row: a webhook body you received, a provider-specific configuration block, a handler's own diagnostic detail. If you
find yourself deserializing to the same class everywhere and filtering its fields in memory, those fields wanted to be
properties.
Examples#
Recording a provider-specific configuration whose shape differs per provider, with the known parts declared and only the varying part left as a document:
enum Provider { GitHub, GitLab }
class GitHubConfig { public string Owner; public string Repo; public bool UseChecks; }
entity Connection {
[Required, MaxLength(100)] string Name;
Provider Provider; // declared — queried, filtered, shown
[MaxBytes(65536)] Json ProviderConfig; // varies per provider
}
void ConnectGitHub(string name, string owner, string repo) {
var c = new Connection {
Name = name,
Provider = Provider.GitHub,
ProviderConfig = JsonSerializer.Serialize(new GitHubConfig { Owner = owner, Repo = repo, UseChecks = true }),
};
}Provider is a real property because every connection has one and you will filter on it. ProviderConfig is a
document because GitHub's settings and GitLab's have nothing in common.
The same job when the shape is one call site's business — a failure record nobody else consumes, so declaring a class for it would be ceremony:
entity Delivery {
[Required, MaxLength(100)] string Endpoint;
int Attempts;
Json? LastFailure; // absent until something goes wrong
}
void RecordFailure(string endpoint, int status, string body) {
var d = Delivery.Where(x => x.Endpoint == endpoint).First();
d.Attempts = d.Attempts + 1;
d.LastFailure = new {
status = status,
body = body,
attempt = d.Attempts,
context = new { endpoint = endpoint, at = DateTime.UtcNow },
};
}context nests as a document, not as a quoted string — so a consumer reading LastFailure back gets structure all
the way down.
See also#
- JsonSerializer —
Serialize/Deserialize, which are how aJsonmember is written and read - Markdown — the other member type whose value is a document rather than a scalar
- Optional and required members — why a bare
Jsonis required, and when to writeJson? - constraints —
[MaxBytes], for bounding a document's stored size - entity members — declaring members in general