
Now that our operation result structure is materializing, let’s update our last iteration to support message severity.First, we need a severity indicator. An enum is a good candidate for this kind of work, but it could also be something else. In our case, we leverage an enum that we name OperationResultSeverity.Then we need a message class to encapsulate both the message and the severity level; let’s name that class OperationResultMessage. The new code looks like this:
namespace OperationResult.WithSeverity;
public record class OperationResultMessage
{
public OperationResultMessage(string message, OperationResultSeverity severity)
{
Message = message ??
throw new ArgumentNullException(nameof(message));
Severity = severity;
}
public string Message { get; }
public OperationResultSeverity Severity { get; }
}
public enum OperationResultSeverity
{
Information = 0,
Warning = 1,
Error = 2
}
As you can see, we have a simple data structure to replace our string messages.To ensure the enum gets serialized as string and make the output easier to read and consume, we must register the following converter:
builder.Services
.Configure<JsonOptions>(o
=> o.SerializerOptions.Converters.Add(
new JsonStringEnumConverter()))
;
Then we need to update the OperationResult class to use that new OperationResultMessage class instead. We then need to ensure that the operation result indicates a success only when there is no OperationResultSeverity.Error, allowing it to transmit the OperationResultSeverity.Information and OperationResultSeverity.Warnings messages:
namespace OperationResult.WithSeverity;
public record class OperationResult
{
public OperationResult()
{
Messages = ImmutableList<OperationResultMessage>.Empty;
}
public OperationResult(params OperationResultMessage[] messages)
{
Messages = messages.ToImmutableList();
}
public bool Succeeded => !HasErrors();
public int?
Value { get; init; }
public ImmutableList<OperationResultMessage> Messages { get; init; }
public bool HasErrors()
{
return FindErrors().Any();
}
private IEnumerable<OperationResultMessage> FindErrors()
=> Messages.Where(x => x.Severity == OperationResultSeverity.Error);
}
The highlighted lines represent the updated logic that sets the success state of the operation. The operation is successful only when no error exists in the Messages list. The FindErrors method returns messages with an Error severity, while the HasErrors method bases its decision on that method’s output.
The HasErrors method logic can be anything. In this case, this works.
With that in place, the Executor class is also revamped. Let’s have a look at those changes:
namespace OperationResult.WithSeverity;
public class Executor
{
public OperationResult Operation()
{
// Randomize the success indicator
// This should be real logic
var randomNumber = Random.Shared.Next(100);
var success = randomNumber % 2 == 0;
// Some information message
var information = new OperationResultMessage(
“This should be very informative!”,
OperationResultSeverity.Information
);
// Return the operation result
if (success)
{
var warning = new OperationResultMessage(
“Something went wrong, but we will try again later automatically until it works!”,
OperationResultSeverity.Warning
);
return new OperationResult(information, warning) { Value = randomNumber };
}
else
{
var error = new OperationResultMessage(
$”Something went wrong with the number ‘{randomNumber}’.”,
OperationResultSeverity.Error
);
return new OperationResult(information, error) { Value = randomNumber };
}
}
}
In the preceding code, we removed the tertiary operator. The Operation method also uses all severity levels.
You should always aim to write code that is easy to read. It is OK to use language features, but nesting statements over statements on a single line has limits and can quickly become a mess.
In that last code block, both successes and failures return two messages:
- When the operation succeeds, the method returns an information and a warning message.
- When the operation fails, the method returns an information and an error message.
From the consumer standpoint, we have a placeholder if-else block and return the operation result directly. Of course, we could handle this differently in a real application that needs to know about those messages, but in this case, all we want to see are those results, so this does it:
app.MapGet(“/multiple-errors-with-value-and-severity”, (OperationResult.WithSeverity.Executor executor) =>
{
var result = executor.Operation();
if (result.Succeeded)
{
// Handle the success
}
else
{
// Handle the failure
}
return result;
});
As you can see, it is still as easy to use, but now with more flexibility added to it. We can do something with the different types of messages, such as displaying them to the user, retrying the operation, and more.For now, when running the application and calling this endpoint, successful calls return a JSON string that looks like the following:
{
“succeeded”: true,
“value”: 56,
“messages”: [
{
“message”: “This should be very informative!”,
“severity”: “Information”
},
{
“message”: “Something went wrong, but we will try again later automatically until it works!”,
“severity”: “Warning”
}
]
}
Failures return a JSON string that looks like this:
{
“succeeded”: false,
“value”: 19,
“messages”: [
{
“message”: “This should be very informative!”,
“severity”: “Information”
},
{
“message”: “Something went wrong with the number ’19’.”,
“severity”: “Error”
}
]
}
Another idea to improve this design would be adding a Status property that returns a complex success result based on each message’s severity level. To do that, we could create another enum:
public enum OperationStatus { Success, Failure, PartialSuccess }
Then we could access that value through a new property named Status, on the OperationResult class. With this, a consumer could handle partial success without digging into the messages. I will leave you to play with this one on your own; for example, the Status property could replace the Succeeded property, or the Succeeded property could leverage the Status property similarly to what we did with the errors. The most important part is to define what would be a success, a partial success, and a failure. Think of a database transaction, for example; one failure could lead to the rollback of the transaction, while in another case, one failure could be acceptable.Now that we’ve expanded our simple example into this, what happens if we want the Value to be optional? To do that, we could create multiple operation result classes holding more or less information (properties); let’s try that next.