JOS.JOSON
1.3.6
See the version list below for details.
dotnet add package JOS.JOSON --version 1.3.6
NuGet\Install-Package JOS.JOSON -Version 1.3.6
<PackageReference Include="JOS.JOSON" Version="1.3.6" />
<PackageVersion Include="JOS.JOSON" Version="1.3.6" />
<PackageReference Include="JOS.JOSON" />
paket add JOS.JOSON --version 1.3.6
#r "nuget: JOS.JOSON, 1.3.6"
#:package JOS.JOSON@1.3.6
#addin nuget:?package=JOS.JOSON&version=1.3.6
#tool nuget:?package=JOS.JOSON&version=1.3.6
JOS.JOSON
Fluent System.Text.Json configuration for domain objects, without attributes.
Configure how your types serialize and deserialize in separate configuration classes, keeping your domain models clean. Inspired by Entity Framework Core's IEntityTypeConfiguration<T>.
Installation
dotnet add package JOS.JOSON
Usage
Domain model
public class Order
{
private decimal _originalAmount;
public required Guid Id { get; init; }
public required string CustomerName { get; init; }
public required OrderStatus Status { get; init; }
public required Money TotalAmount { get; init; }
public required string InternalNotes { get; init; } = string.Empty;
public required DateTime CreatedAt { get; init; }
public decimal? Discount { get; init; }
private Order() { }
public static Order Create(
string customerName,
OrderStatus status,
Money totalAmount,
string internalNotes,
decimal? discount = null) =>
new()
{
Id = Guid.NewGuid(),
CustomerName = customerName,
Status = status,
TotalAmount = totalAmount,
InternalNotes = internalNotes,
CreatedAt = DateTime.UtcNow,
Discount = discount,
_originalAmount = totalAmount.Amount
};
}
public record Money(decimal Amount, string Currency);
public enum OrderStatus { Pending, Confirmed, Shipped, Delivered }
Configuration
public class OrderConfiguration : IJsonTypeConfiguration<Order>
{
public void Configure(JsonEntityConfiguration<Order> builder)
{
builder
// Private parameterless constructor is found automatically,
// or call UsePrivateConstructor() to enforce it explicitly
.Ignore(o => o.InternalNotes, RequiredIgnoreHandling.UseDefault)
.PropertyName(o => o.CustomerName, "customer")
.Converter(o => o.Status, new JsonStringEnumConverter())
.Converter(o => o.TotalAmount, new MoneyConverter())
.ShouldSerialize(o => o.Discount, (_, d) => d.HasValue && d.Value > 0)
// Private fields must be referenced by name since lambda access is not possible
.IncludeField("_originalAmount", f => f.PropertyName("original_amount"))
.PropertyOrder(o => o.Id, 0);
}
}
Setup
var options = new JOSONJsonTypeInfoResolver()
.Apply(new OrderConfiguration())
.BuildOptions();
// Or auto-discover all IJsonTypeConfiguration<T> in an assembly
var options = new JOSONJsonTypeInfoResolver()
.ApplyConfigurationsFromAssembly(typeof(OrderConfiguration).Assembly)
.BuildOptions();
Serialization
var order = Order.Create("Jane Doe", OrderStatus.Confirmed, new Money(149.99m, "USD"), "internal");
var json = JsonSerializer.Serialize(order, options);
{
"id": "3fa85f64-...",
"customer": "Jane Doe",
"status": "Confirmed",
"totalAmount": { "amount": 149.99, "currency": "USD" },
"createdAt": "2024-01-15T10:30:00Z",
"original_amount": 149.99
}
InternalNotes is excluded, CustomerName is serialized as customer, Status as a string, Discount is omitted because it has no value, and the private field _originalAmount is included using the string overload since private fields are not accessible via lambda.
Deserialization
The same options are used for deserialization. The configuration is applied in reverse - customer maps back to CustomerName, the MoneyConverter reconstructs the Money record, and the private constructor is used to create the instance.
var order = JsonSerializer.Deserialize<Order>(json, options);
Configuration API
| Method | Description |
|---|---|
Ignore(selector) |
Exclude a property from serialization |
PropertyName(selector, name) |
Rename a property in JSON |
PropertyOrder(selector, order) |
Control position in JSON output |
Converter(selector, converter) |
Apply a custom JsonConverter to a property |
ShouldSerialize(selector, predicate) |
Conditionally include a property |
NumberHandling(selector, handling) |
Control number parsing behaviour |
ExtensionData(selector) |
Capture unknown JSON properties |
IncludeField(selector) |
Include a public/internal field |
IncludeField(name, configure?) |
Include a private field by name |
IncludeProperty(selector) |
Include a property with a private setter |
UsePrivateConstructor() |
Explicitly use a private parameterless constructor |
UsePrivateSetterProperties() |
Include all properties with private setters |
Property(selector) |
Access per-property configuration (e.g. HasConversion) |
Polymorphism(configure) |
Configure polymorphic type handling |
WithVersioning(configure) |
Enable JSON versioning with migration support |
Private setter properties
Properties with private set are not included by default. Use IncludeProperty to opt in per property, or UsePrivateSetterProperties to include all of them globally.
public class Session
{
public Instant Expires { get; private set; }
public Instant? Updated { get; private set; }
private Session() { }
}
Per property:
builder
.UsePrivateConstructor()
.IncludeProperty(x => x.Expires)
.IncludeProperty(x => x.Updated);
// With inline conversion
builder
.UsePrivateConstructor()
.IncludeProperty(x => x.Expires, c => c.HasConversion(
instant => instant.ToString(),
value => Instant.Parse(value)));
All private setter properties:
builder
.UsePrivateConstructor()
.UsePrivateSetterProperties();
Inline conversion
Use Property(selector).HasConversion to control how a property is serialized and deserialized without writing a separate JsonConverter class. The first delegate converts to the stored type, the second converts back.
public class Session
{
public DateOnly Date { get; init; }
private Session() { }
}
// Via Property() for init properties
builder.Property(x => x.Date, c => c.HasConversion(
date => date.ToString("yyyy-MM-dd"),
value => DateOnly.Parse(value)));
// Via IncludeProperty() for private setter properties
builder.IncludeProperty(x => x.Date, c => c.HasConversion(
date => date.ToString("yyyy-MM-dd"),
value => DateOnly.Parse(value)));
The stored type (string here) is inferred from the serialize delegate's return type. Any type that STJ can handle natively can be used as the stored type - string, int, Guid, etc.
Polymorphism
Configure polymorphic deserialization on the base type:
public abstract class SessionMetadata { }
public class OidcSessionMetadata : SessionMetadata
{
public required string ClientId { get; init; }
}
public class SessionMetadataConfiguration : IJsonTypeConfiguration<SessionMetadata>
{
public void Configure(JsonEntityConfiguration<SessionMetadata> builder)
{
builder.Polymorphism(p => p
.TypeDiscriminatorPropertyName("$type")
.DerivedType<OidcSessionMetadata>("oidc"));
}
}
The $type discriminator is written on serialization and used to resolve the correct derived type on deserialization.
Naming policy
Defaults to camelCase. Override with:
var options = new JOSONJsonTypeInfoResolver()
.WithNamingPolicy(JsonNamingPolicy.SnakeCaseLower)
.Apply(new OrderConfiguration())
.BuildOptions();
Versioning
When you serialize an object and store it - in a database, a message queue, an event log - you are making a promise about its shape. The moment you rename a field, split a property, or change a type, you break readers of older data.
Without versioning the common workarounds are keeping old and new field names alive at the same time, sprinkling conditional logic across converters, or silently losing data. Versioning gives you a better option: stamp a version number on every document at write time, and repair any older shapes before they ever reach your deserializer.
How it works
A __metadata block is written alongside the payload:
{ "__metadata": { "version": 2 }, "customer": "Jane Doe", "totalAmount": "149.99 USD" }
On read, if the stored version is lower than the current version, migration functions run in order until the document matches the current shape. Your domain model only ever sees version 2.
Documents with no __metadata are treated as the current version, which covers the initial rollout when existing data was written before versioning was added.
Renaming a field (v1 to v2)
Version 1 used customerName. Version 2 renamed it to customer.
builder.WithVersioning(v => v
.Version(2)
.MigrateFrom(1, payload =>
{
var migrated = payload.GetRawText().Replace("\"customerName\"", "\"customer\"");
return JsonDocument.Parse(migrated).RootElement.Clone();
}));
Chained migrations (v1 to v3 via v2)
You never need to write a migration that jumps multiple versions. Each step only handles one increment, and the chain runs automatically.
builder.WithVersioning(v => v
.Version(3)
.MigrateFrom(1, payload => // 1 -> 2: rename customerName to customer
{
var migrated = payload.GetRawText().Replace("\"customerName\"", "\"customer\"");
return JsonDocument.Parse(migrated).RootElement.Clone();
})
.MigrateFrom(2, payload => // 2 -> 3: rename amount to totalAmount
{
var migrated = payload.GetRawText().Replace("\"amount\"", "\"totalAmount\"");
return JsonDocument.Parse(migrated).RootElement.Clone();
}));
A document stored at version 1 will run both migrations. A document stored at version 2 will only run the second.
Retiring old versions
Once you are confident that no version 1 documents remain in storage, delete the MigrateFrom(1, ...) call. If a version 1 document turns up after that, it throws - which is the correct behaviour, since you have explicitly declared that version no longer supported.
Requirements
.NET 8 or later.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. net11.0 is compatible. |
-
net10.0
- No dependencies.
-
net11.0
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.4.4-beta-gd052b7a7df | 189 | 8/28/2026 |
| 1.4.3-beta-g2726e151cb | 96 | 8/28/2026 |
| 1.4.2-beta-gcbf7fd77b5 | 99 | 8/28/2026 |
| 1.3.6 | 98 | 8/28/2026 |
| 1.3.5-beta-g83c47d958c | 100 | 8/27/2026 |
| 1.3.4-beta-ge69b5aff82 | 94 | 8/27/2026 |
| 1.3.3-beta-g22e3fd0046 | 331 | 6/8/2026 |
| 1.3.2-beta-g0eb25a064c | 111 | 6/8/2026 |
| 1.2.13 | 122 | 6/8/2026 |
| 1.2.11-beta-g1995cf3cc5 | 108 | 6/8/2026 |
| 1.2.9-beta-gbba29fdb98 | 108 | 6/8/2026 |
| 1.2.8-beta-geac922a4f7 | 108 | 6/8/2026 |
| 1.2.7-beta-g571247605e | 115 | 6/4/2026 |
| 1.2.6-beta-gfe40f83e92 | 108 | 6/4/2026 |
| 1.2.5-beta-g787ad39885 | 101 | 6/4/2026 |
| 1.2.4-beta-g9e4ef7ed46 | 113 | 6/4/2026 |
| 1.2.3-beta-g05b764fd22 | 112 | 6/4/2026 |
| 1.2.2-beta-gaf074b8645 | 115 | 6/4/2026 |
| 1.1.2 | 121 | 6/4/2026 |
| 1.1.1-beta-gc133959bc0 | 112 | 6/4/2026 |