skip to content
Jerrie Pelser's Blog

Serializing audit information with System.Text.Json

/ 14 min read

Introduction

I recently overhauled the job processing engine in Cloudpress to use JSON documents for configuring the export jobs (more on that in a future blog post). One of the things I wanted to add was writing audit information when serializing the content to JSON.

I initially tried to do this with a custom JSON serializer but ran into issues that made it more complex than necessary. Specifically, I ran into issues with recursion as described here and elsewhere.

After taking a step back and looking at the documentation, I came across the section about customizing the JSON contract. The following section from the documentation describes this better:

The System.Text.Json library constructs a JSON contract for each .NET type, which defines how the type should be serialized and deserialized

[…]

Starting in .NET 7, you can customize these JSON contracts to provide more control over how types are converted into JSON and vice versa.

Creating a basic modifier

To understand how the concept works, let’s start off with a basic modifier to add additional auditing properties when serializing an object.

using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
var jsonSerializerOptions = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true,
    TypeInfoResolver = new DefaultJsonTypeInfoResolver()
.WithAddedModifier(jsonTypeInfo =>
{
            if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object)
                return;
            var createdBy = jsonTypeInfo.CreateJsonPropertyInfo(typeof(string), "CreatedBy");
            createdBy.Get = _ => "Jerrie Pelser";
            var createdAt = jsonTypeInfo.CreateJsonPropertyInfo(typeof(DateTimeOffset), "CreatedAt");
            createdAt.Get = _ => DateTimeOffset.UtcNow;
            jsonTypeInfo.Properties.Add(createdBy);
            jsonTypeInfo.Properties.Add(createdAt);
})
};
Console.WriteLine(JsonSerializer.Serialize(new Person("Jerrie", "Pelser"), jsonSerializerOptions));
public record Person(string FirstName, string LastName);

In the code above, I created custom JSON serialization options with a custom modifier attached to DefaultJsonTypeInfoResolver. The modifier first checks to see that we are serializing an object; if not, it aborts. After that, we create two JsonPropertyInfo instances for the two auditing fields with custom getters that set their values (the user and date).

Running this code produces the following output:

{
"firstName": "Jerrie",
"lastName": "Pelser",
"CreatedBy": "Jerrie Pelser",
"CreatedAt": "2024-09-23T05:48:42.3262695+00:00"
}

This is a good start, but there is room for improvement. Let’s see how to build on this to create something more flexible.

Applying the naming policy

You’ll notice that in the serialized JSON document, the properties from the Person record are converted to camel case - due to the PropertyNamingPolicy policy specified in the JsonSerializerOptions instance. Our two auditing properties are, however, Pascal case.

The naming policy is applied internally by the DefaultJsonTypeInfoResolver, so if we want our auditing properties to conform to it, we’ll need to apply it before we add them to the JsonTypeInfo passed into the modifier.

We can simply name these properties createdBy and createdAt, but that means that if we ever decide on a new naming policy, these two properties will remain in camel case.

We can access the property naming policy from inside our modifier. Let’s update our code by adding a new ApplyPropertyNamingPolicy method to apply the naming policy and update our code to use this new method.

var jsonSerializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
.WithAddedModifier(jsonTypeInfo =>
{
string ApplyPropertyNamingPolicy(string propertyName)
{
if (jsonTypeInfo.Options.PropertyNamingPolicy == null)
{
return propertyName;
}
return jsonTypeInfo.Options.PropertyNamingPolicy.ConvertName(propertyName);
}
if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object)
return;
var createdBy = jsonTypeInfo.CreateJsonPropertyInfo(typeof(string), ApplyPropertyNamingPolicy("CreatedBy"));
createdBy.Get = _ => "Jerrie Pelser";
var createdAt = jsonTypeInfo.CreateJsonPropertyInfo(typeof(DateTimeOffset), ApplyPropertyNamingPolicy("CreatedAt"));
createdAt.Get = _ => DateTimeOffset.UtcNow;
jsonTypeInfo.Properties.Add(createdBy);
jsonTypeInfo.Properties.Add(createdAt);
})
};
Console.WriteLine(JsonSerializer.Serialize(new Person("Jerrie", "Pelser"), jsonSerializerOptions));
public record Person( string FirstName, string LastName);

With this in place, the naming policy is applied to our audit properties.

{
  "firstName": "Jerrie",
  "lastName": "Pelser",
  "createdBy": "Jerrie Pelser",
  "createdAt": "2024-09-23T06:05:59.248567+00:00"
}

Excluding certain classes from auditing

Let’s add a new record called Company and update the Person record to reference it.

public record Person(string FirstName, string LastName, Company Company);
public record Company(string Name, string Website);

Let’s also update the object we are serializing to pass in an instance of the company.

Console.WriteLine(JsonSerializer.Serialize(
    new Person("Jerrie", "Pelser", new Company("Cloudpress", "https://www.usecloudpress.com")),
    jsonSerializerOptions));

Executing the update code produces the following JSON document:

{
  "firstName": "Jerrie",
  "lastName": "Pelser",
  "company": {
    "name": "Cloudpress",
    "website": "https://www.usecloudpress.com",
    "createdBy": "Jerrie Pelser",
    "createdAt": "2024-09-23T06:16:21.8483121+00:00"
},
  "createdBy": "Jerrie Pelser",
  "createdAt": "2024-09-23T06:16:21.849166+00:00"
}

You can see that we are also appending the auditing properties to the company. Let’s assume we only want these properties attached to the Person class.

We can fix this by adding a check for the object’s type and aborting if it is not a Person instance.

var jsonSerializerOptions = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true,
    TypeInfoResolver = new DefaultJsonTypeInfoResolver()
.WithAddedModifier(jsonTypeInfo =>
{
            string ApplyPropertyNamingPolicy(string propertyName)
{
// ... omitted for brevity
}
            if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object)
{
                return;
}
            if (jsonTypeInfo.Type != typeof(Person))
{
                return;
}
            var createdBy = jsonTypeInfo.CreateJsonPropertyInfo(typeof(string), ApplyPropertyNamingPolicy("CreatedBy"));
            createdBy.Get = _ => "Jerrie Pelser";
            var createdAt = jsonTypeInfo.CreateJsonPropertyInfo(typeof(DateTimeOffset), ApplyPropertyNamingPolicy("CreatedAt"));
            createdAt.Get = _ => DateTimeOffset.UtcNow;
            jsonTypeInfo.Properties.Add(createdBy);
            jsonTypeInfo.Properties.Add(createdAt);
})
};

Executing our application produces the following output:

{
  "firstName": "Jerrie",
  "lastName": "Pelser",
  "company": {
    "name": "Cloudpress",
    "website": "https://www.usecloudpress.com"
},
  "createdBy": "Jerrie Pelser",
  "createdAt": "2024-09-23T06:20:46.5108677+00:00"
}

The change gives us the desired result, but the fact that it is hard-coded to the Person type makes it inflexible. We can update it to include other types in the future, but it would be more flexible if we could add an indicator to identify the types we want to be audited.

The most common approach to this is to add a marker interface or attribute that can be applied to types we want to be audited. I am a fan of the latter, but I’ll quickly show how to use a marker interface to achieve the same result.

Using a marker interface

To use a marker interface, declare an interface named IAuditable and ensure that the types you want to be audited implement it. Below, you can see the declaration of this interface and its implementation in the Person record.

public record Person(string FirstName, string LastName, Company Company) : IAuditable;
public record Company(string Name, string Website);
public interface IAuditable {}

The code that previously checked explicitly for the Person type can now be updated to check whether the type implements the IAuditable interface.

var jsonSerializerOptions = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true,
    TypeInfoResolver = new DefaultJsonTypeInfoResolver()
.WithAddedModifier(jsonTypeInfo =>
{
            if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object)
{
                return;
}
            if (!typeof(IAuditable).IsAssignableFrom(jsonTypeInfo.Type))
{
                return;
}
// ... some code omitted for brevity
})
};

When executing this code, you will get the same result, with the auditing attributes added only to the Person objects.

Using a marker attribute

Since many aspects of JSON serialization can be controlled by attributes, I am a fan of this approach. Let’s create a marker attribute named JsonAuditableAttribute.

[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class JsonAuditableAttribute : JsonAttribute
{
}

We also need to apply this attribute to the Person record.

[JsonAuditable]
public record Person(string FirstName, string LastName, Company Company);

Finally, we can update the code for the modifier to add the audit attributes only to types with the [JsonAuditable] attribute applied.

var jsonSerializerOptions = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true,
    TypeInfoResolver = new DefaultJsonTypeInfoResolver()
.WithAddedModifier(jsonTypeInfo =>
{
            if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object)
{
                return;
}
            if (!jsonTypeInfo.Type.IsDefined(typeof(JsonAuditableAttribute), inherit: false))
{
                return;
}
// ... some code omitted for brevity
})
};

This will give us the same result but is a more flexible solution as we can add the [JsonAuditable] attribute to any class we want to be audited.

Configuring JsonOptions via DI

So far, we have created a new JsonSerializerOptions instance every time we serialize the objects to JSON. In a typical application, the JsonSerializerOptions will likely be configured with Dependency Injection.

For this example, I created an ASP.NET Core Minimal API project. We have API endpoints returning JSON payloads, and we want the auditing applied to them.

JSON serialization can be configured for Minimal API projects using the ConfigureHttpJsonOptions extension method. We can configure the JSON serializer options and plug our auditing modifier into the serialization pipeline.

using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver()
.WithAddedModifier(jsonTypeInfo =>
{
//... same code as before. Omitted for brevity.
});
});
var app = builder.Build();
app.UseHttpsRedirection();
app.MapGet("/people", () => { return new[] { new Person("Jerrie", "Pelser"), new Person("John", "Doe") }; });
app.Run();
[JsonAuditable]
public record Person(string FirstName, string LastName);
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class JsonAuditableAttribute : JsonAttribute
{
}

Calling the /people endpoint will return the list of people and apply the auditing attributes.

[
{
    "firstName": "Jerrie",
    "lastName": "Pelser",
    "createdBy": "Jerrie Pelser",
    "createdAt": "2024-09-24T00:50:46.2599211+00:00"
},
{
    "firstName": "John",
    "lastName": "Doe",
    "createdBy": "Jerrie Pelser",
    "createdAt": "2024-09-24T00:50:46.260663+00:00"
}
]

Injecting services into JsonOptions

Until now, the createdBy auditing property has hard-coded. In a real-world system, we must access the HttpContext to obtain the user.

For this example, I created a Minimal API project and added authentication via JSON Web Tokens. I’ll use the dotnet user-jwts tool to generate a JWT for local testing and demonstration purposes.

We will want to access the HttpContext to obtain the user, so we must inject the IHttpContextAccessor with the DI container (see the docs for more info).

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpContextAccessor();
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();

Since we want to access IHttpContextAccessor from the DI container while configuring the JSON serialization options, we will create a class that inherits from IConfigureOptions<TOptions> and register that with the DI container (once again, see the docs for more info).

You will see that the code for configuring the JSON serialization options is similar to before, but this time, we are using the HttpContext to obtain the current user’s name.

public class ConfigureJsonOptions(IHttpContextAccessor httpContextAccessor) : IConfigureOptions<JsonOptions>
{
    public void Configure(JsonOptions options)
{
        options.SerializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver()
.WithAddedModifier(jsonTypeInfo =>
{
                string ApplyPropertyNamingPolicy(string propertyName)
{
                    if (jsonTypeInfo.Options.PropertyNamingPolicy == null)
{
                        return propertyName;
}
                    return jsonTypeInfo.Options.PropertyNamingPolicy.ConvertName(propertyName);
}
                if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object)
{
                    return;
}
                if (!jsonTypeInfo.Type.IsDefined(typeof(JsonAuditableAttribute), inherit: false))
{
                    return;
}
                var createdBy = jsonTypeInfo.CreateJsonPropertyInfo(typeof(string), ApplyPropertyNamingPolicy("CreatedBy"));
                createdBy.Get = _ => httpContextAccessor.HttpContext?.User.Identity?.Name ?? "Anonymous";
                var createdAt = jsonTypeInfo.CreateJsonPropertyInfo(typeof(DateTimeOffset), ApplyPropertyNamingPolicy("CreatedAt"));
                createdAt.Get = _ => DateTimeOffset.UtcNow;
                jsonTypeInfo.Properties.Add(createdBy);
                jsonTypeInfo.Properties.Add(createdAt);
});
}
}

Next, we need to configure the options with the DI container.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpContextAccessor();
builder.Services.ConfigureOptions<ConfigureJsonOptions>();
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();

With this in place, when we call the /people endpoint, the auditing attributes are applied using the username of the user making the request.

[
{
    "firstName": "Jerrie",
    "lastName": "Pelser",
    "createdBy": "jerriep",
    "createdAt": "2024-09-23T13:39:42.7578454+00:00"
},
{
    "firstName": "John",
    "lastName": "Doe",
    "createdBy": "jerriep",
    "createdAt": "2024-09-23T13:39:42.7578525+00:00"
}
]

Downsides

Overall, I find this to be quite an elegant method of modifying the serialization contract without resorting to writing custom serializers. However, it is not without downsides.

One downside I found was that you cannot alter the contract on a per-instance basis. For example, I showed you how to add the auditing attributes only to certain types using a marker attribute. However, it will add them to all instances of that type.

In the examples above, I opted the Person type into the auditing behaviour, so all instances of Person will have those attributes.

One way you may work around this on a per-instance basis is to return null values from the getters and then set the DefaultIgnoreCondition to JsonIgnoreCondition.WhenWritingNull.

public class ConfigureJsonOptions(IHttpContextAccessor httpContextAccessor) : IConfigureOptions<JsonOptions>
{
    public void Configure(JsonOptions options)
{
        options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
        options.SerializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver()
.WithAddedModifier(jsonTypeInfo =>
{
// ... some code omitted for brevity
                var createdBy = jsonTypeInfo.CreateJsonPropertyInfo(typeof(string), ApplyPropertyNamingPolicy("CreatedBy"));
                createdBy.Get = (o) =>
{
                    if (o is Person { FirstName: "Jerrie" })
{
                        return null;
}
                    return httpContextAccessor.HttpContext?.User.Identity?.Name ?? "Anonymous";
};
                var createdAt = jsonTypeInfo.CreateJsonPropertyInfo(typeof(DateTimeOffset?), ApplyPropertyNamingPolicy("CreatedAt"));
                createdAt.Get = o =>
{
                    if (o is Person { FirstName: "Jerrie" })
{
                        return null;
}
                    return DateTimeOffset.UtcNow;
};
                jsonTypeInfo.Properties.Add(createdBy);
                jsonTypeInfo.Properties.Add(createdAt);
});
}
}

Since we are returning null for the auditing properties of “Jerrie”, those properties will not be serialized.

{
    "firstName": "Jerrie",
    "lastName": "Pelser"
},
{
    "firstName": "John",
    "lastName": "Doe",
    "createdBy": "jerriep",
    "createdAt": "2024-09-24T01:35:01.2451591+00:00"
}
]

Conclusion

In this blog post, I showed how to customise the JSON serialization contract using the DefaultJsonTypeInfoResolver and adding modifiers. I demonstrated this by using an example of adding auditing properties to the serialized JSON documents.

I showed how you can control this behaviour by adding a marker attribute. Finally, I showed how you can add the JSON serialization options to the DI container and inject other services into our JSON serialization options.

The code for the sample projects I created for this blog post can be found at https://github.com/jerriepelser-blog/system-text-json-write-audit-information.