In this article, we will learn how to serialize exceptions as JSON in .NET.
Good exception handling is one of the key aspects of a successful enterprise application. And when we talk about an API, it’s important that we provide relevant failure details to consumer applications. As such, we often need to serialize exceptions as JSON.
Let’s start.
The Problem in Serializing Exceptions
Let’s assume we want to handle a bad operation exception and serialize the exception using the native System.Text.Json
library:
try { throw new InvalidOperationException("Bad operation"); } catch(Exception ex) { Assert.ThrowsAny<NotSupportedException>(() => JsonSerializer.Serialize(ex)); }
As we try invoking JsonSerializer.Serialize()
on the exception object, we encounter a NotSupportedException
:
Serialization and deserialization of 'System.Reflection.MethodBase' instances are not supported. Path: $.TargetSite.
We can see the point of failure which is the TargetSite
property of the base Exception
class. This may differ depending on the version of System.Text.Json
package. But all such failures lie in one common fact – the serialization (and deserialization) of many of the classes from the C# Reflection ecosystem are not supported because of potential security risks. That includes System.Type
and other derivatives of MemberInfo
.
Under this circumstance, one might expect to instruct the library to exclude such unsupported properties of Exception
and get the serialized output of supported properties instead. Unfortunately, there is no direct built-in way to do that with the current native library. That said, we have several ways to get around this problem.
Serialize Exceptions Using Newtonsoft.Json
The quickest solution is to use the serialization routines from Newtonsoft.Json
library:
try { throw new InvalidOperationException("Bad operation"); } catch (Exception ex) { var json = JsonConvert.SerializeObject(ex)!; Assert.NotEmpty(json); }
This time with the help of JsonConvert.SerializeObject()
method, we successfully get serialized exception details:
{ "ClassName": "System.InvalidOperationException", "Message": "Bad operation", "Data": null, "InnerException": null, "StackTraceString": "..." // Omitted for the brevity }
The output contains all the exception details including ClassName
, Message
, StackTraceString
, etc.
Custom Exception
The generated JSON tree varies depending on the exception class we use. For example, we may have a custom exception class that has additional properties:
public class CustomException : Exception { public string Mitigation { get; } public CustomException(string message, string mitigation) : this(message, mitigation, null) { } public CustomException(string message, string mitigation, Exception? innerException) : base(message, innerException) { Mitigation = mitigation; } }
Serialization of such exception:
try { throw new CustomException("Custom error", "Try later"); } catch (CustomException ex) { var json = JsonConvert.SerializeObject(ex)!; Assert.Contains("Mitigation", json); }
Produces the output including the additional properties (Mitigation
in our case) but may also lack some information as compared to the base exception class:
{ "Mitigation": "Try later", "Message": "Custom error", "Data": {}, "InnerException": null, "StackTrace": "..." }
As we see, the output does not include the ClassName
anymore. However, in most cases, the available information of Message
, StackTrace
, InnerException
and/or any direct properties of the derived exception class are all that we want. That said, if we do need the missing information, we have options to get it.
For an exception class that we own, we can mitigate this issue by decorating with SerializableAttribute
:
[Serializable] public class CustomVerboseException : CustomException { public CustomVerboseException(string message, string mitigation) : base(message, mitigation) { } }
But to deal with a third-party exception class that does not have the SerializableAttribute
applied, we will need a custom JsonConverter where we can build the JSON tree from exception detail on our own.
Serialize Exceptions By Custom JsonConverter
The Newtonsoft.Json
library offers a straightforward approach to getting JSON output of exceptions.
But is there any built-in way to achieve this? Yes, this is where a custom JSON converter using the native library comes into play.
In fact, using a custom converter proves to be more effective in many cases. For example, the stack trace of an exception is not user-friendly and may possess a potential security risk. So, we may opt out of StackTrace
and other unwanted details from the output. Also, we may choose our own standard JSON model to represent the exception details. All such fine tunings are achievable by a custom converter.
Implement Simple ExceptionConverter
So, let’s implement a simple exception converter using the System.Text.Json
library:
public class SimpleExceptionConverter : JsonConverter<Exception> { public override Exception? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { return new NotImplementedException(); } public override void Write(Utf8JsonWriter writer, Exception value, JsonSerializerOptions options) { writer.WriteStartObject(); writer.WriteString("Error", value.Message); writer.WriteString("Type", value.GetType().Name); if (value.InnerException is { } innerException) { writer.WritePropertyName("InnerException"); Write(writer, innerException, options); } writer.WriteEndObject(); } }
We start by extending the inbuilt JsonConverter<T>
. This tells us to implement two abstract Read()
and Write()
methods. Let’s skip Read()
as we are not talking about deserialization.
Inside the Write()
method, we have an Utf8JsonWriter
instance that offers a low-level API to write the JSON tree, node by node. We first write the starting node (“{“) of the JSON object. Then we write the value of Message
naming it as an “Error” node. The WriteString()
method is the most convenient choice for writing such text nodes. Similarly, we write the name of the exception type as a “Type” node.
We also want to expose the details of InnerException
if it exists. As part of it, we first write the property name. For writing the value of the node, since this is also an exception object, a recursive call to the Write
method does the job.
Finally, we wrap up the JSON tree by writing the end node (“}”). That’s all for a simple converter where we only want basic exception details.
Using our Custom Converter
Our custom converter is ready to use. There are several ways to tell the framework to use this converter for serializing exceptions.
One way to do that is to decorate the exception class with JsonConverterAttribute
.
In the case of ASP.NET Core, registering the converter in the dependency injection pipeline is another viable option.
We can simply use the converter in an inline serialization call as well:
try { var innerException = new InvalidOperationException("Bad operation"); throw new CustomException("Custom error", "Try later", innerException); } catch (Exception ex) { JsonSerializerOptions options = new(JsonSerializerOptions.Default); options.Converters.Add(new SimpleExceptionConverter()); var json = JsonSerializer.Serialize(ex, options); Assert.NotEmpty(json); }
So we want to serialize a CustomException
which also holds a reference to an inner exception. An instance of JsonSerializerOptions
allows us to add a custom converter to its Converters
collection. Subsequently, we pass the options
during the serialization call which produces:
{ "Error": "Custom error", "Type": "CustomException", "InnerException": { "Error": "Bad operation", "Type": "InvalidOperationException" } }
The exception details are in our desired JSON format.
Implement Detail ExceptionConverter
Our simple converter provides us with the exception message and type which is enough for many practical scenarios. That said, it’s also possible to implement a converter that generates full exception details:
public class DetailExceptionConverter : JsonConverter<Exception> { public override void Write(Utf8JsonWriter writer, Exception value, JsonSerializerOptions options) { writer.WriteStartObject(); var exceptionType = value.GetType(); writer.WriteString("ClassName", exceptionType.FullName); var properties = exceptionType.GetProperties() .Where(e => e.PropertyType != typeof(Type)) .Where(e => e.PropertyType.Namespace != typeof(MemberInfo).Namespace) .ToList(); foreach (var property in properties) { var propertyValue = property.GetValue(value, null); if (options.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull && propertyValue == null) continue; writer.WritePropertyName(property.Name); JsonSerializer.Serialize(writer, propertyValue, property.PropertyType, options); } writer.WriteEndObject(); } }
Again we start and end by writing the opening and closing JSON nodes, respectively. In between, we aim to write all serializable properties of the exception.
Since we want full details of the exception, writing out the full name of the exception type is a reasonable beginning. Next, we iterate over all properties of the exception excluding the non-serializable ones. This is pretty much aligned with our previous finding of non-serializable reflection types. If we want to filter out any other unwanted properties, we can do it here as well.
Inside the iteration block, we first retrieve the property value. We can discard a null value completely. Even better if we discard it based on the DefaultIgnoreCondition
value of the supplied JsonSerializerOptions
. When it comes to writing the property to the JSON node, we write the name followed by its value. And since the propertyValue
can be of any object type, it’s important to write it consistently based on serializer options. That’s why we call an overload of JsonSerializer.Serialize()
method which writes an object to a writer
instance appropriately. That’s it.
As we use the detail converter for serializing the same exception:
{ "ClassName": "JsonSerializeExceptions.CustomException", "Mitigation": "Try later", "Message": "Custom error", "Data": {}, "InnerException": { "ClassName": "System.InvalidOperationException", "Message": "Bad operation" // Omitted }, "HelpLink": null, "Source": "JsonSerializeExceptions.Tests", "HResult": -2146233088, "StackTrace": "..." }
We find a detailed output similar to the one we get with the Newtonsoft.Json
library.
A custom converter offers us a highly flexible and encapsulated way to deal with exception serialization.
Serialize Exceptions By Object Transformation
Another convenient way to serialize an exception is to transform it to an intermediate object and serialize that object instead:
try { throw new InvalidOperationException("Bad operation"); } catch (Exception ex) { var interimObject = new { ex.Message, ex.StackTrace }; var json = JsonSerializer.Serialize(interimObject); Assert.NotEmpty(json); }
By preparing an anonymous object from the exception details, we easily expose the error details by serializing like any regular object.
However, using an anonymous object does not work if we want the chain of InnerException
. In that case, we need a wrapper CLR object:
public class ExceptionObject { public string Message { get; set; } public string? StackTrace { get; set; } public ExceptionObject? InnerException { get; set; } public ExceptionObject(Exception exception) { Message = exception.Message; StackTrace = exception.StackTrace; if (exception.InnerException is { } innerException) InnerException = new ExceptionObject(innerException); } }
We populate the ExceptionObject
model from the exception instance via its constructor. Populating InnerException
is now just a matter of a recursive constructor call.
Calling the serialization over this wrapper of exception has no points to fail:
try { var innerException = new InvalidOperationException("Bad operation"); throw new CustomException("Custom error", "Try later", innerException); } catch (Exception ex) { var interimObject = new ExceptionObject(ex); var json = JsonSerializer.Serialize(interimObject); Assert.NotEmpty(json); }
This approach of using an intermediate object is much more convenient for shaping up the output JSON on demand.
Conclusion
In this article, we have learned a few different ways to serialize exceptions as JSON. We have learned that while the popular Newtonsoft.Json library supports such serialization out of the box, the native library does not. As such, we have explored several workaround methods. Among them, the custom converter approach is the recommended one in most cases since it offers better encapsulation of logic as well as consistent consideration of JsonSerializerOptions
.