An abstraction comes with benefits, but also with challenges. One of the challenges is handling exceptions that occur in the infrastructure layer. This article will discuss how to handle exceptions in the infrastructure layer in a clean architecture.
Challenge
So let’s say you do have a service in your application layer that uses a Redis cache to update a job status. The service is called JobService and it has a method UpdateJobStatus. This method is called from a controller, and it updates the job status in the database and in the Redis cache.
public interface IJobStateService
{
string Name { get; }
Task<SearchJobState> RetrieveJobState(CancellationToken cancellationToken = default);
Task UpdateJobState(SearchJobState state, CancellationToken cancellationToken = default);
}
A naive redis implementation would look like:
internal class RedisCacheJobState<TJob> : IJobStateService
{
private readonly IRedisDatabase _database;
private readonly string _stateKey;
public RedisCacheJobState(IRedisClient client)
{
ArgumentNullException.ThrowIfNull(client, nameof(client));
_database = client.CacheDatabase();
_stateKey = $"JobState.{typeof(TJob).Name}";
Name = typeof(TJob).Name;
}
public string Name { get; }
public async Task<SearchJobState> RetrieveJobState(CancellationToken cancellationToken = default)
=> await _database.GetAsync<SearchJobState>(_stateKey) ?? SearchJobState.Default();
public async Task UpdateJobState(SearchJobState state, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(state, nameof(state));
await _database.AddAsync(_stateKey, state);
}
}
However this implementation has a problem. If the Redis cache is down, the UpdateJobState method will throw an exception. Since this This is a problem because the infrastructure layer should not throw exceptions. The infrastructure layer should be responsible for handling exceptions and returning a result to the application layer.
StackExchange.Redis.Extensions.Core.Implementations.RedisConnectionPoolManager Redis connection error SocketFailure
StackExchange.Redis.RedisConnectionException: SocketFailure (ReadSocketError/ConnectionReset, last-recv: 151) on localhost:6379/Interactive, Idle/Faulted, last: SET, origin: ReadFromPipe, outstanding: 1, last-read: 19s ago, last-write: 8s ago, unanswered-write: 8s ago, keep-alive: 60s, state: ConnectedEstablished, mgr: 22 of 32 available, in: 0, in-pipe: 0, out-pipe: 0, last-heartbeat: 0s ago, last-mbeat: 0s ago, global: 0s ago, v: 2.7.17.27058
---> Pipelines.Sockets.Unofficial.ConnectionResetException: An existing connection was forcibly closed by the remote host.
---> System.Net.Sockets.SocketException (10054): An existing connection was forcibly closed by the remote host.
at Pipelines.Sockets.Unofficial.Internal.Throw.Socket(Int32 errorCode) in /_/src/Pipelines.Sockets.Unofficial/Internal/Throw.cs:line 59
at Pipelines.Sockets.Unofficial.SocketConnection.DoReceiveAsync() in /_/src/Pipelines.Sockets.Unofficial/SocketConnection.Receive.cs:line 64
The way how you fix is to abstract exceptions in the infrastructure layer. This way you can handle exceptions in the application layer.
Depending on how much information you are willing to expose, you can create more exception types or a single one that contains some additional information as well. in this example we will go simple
public class JobStateServiceInfrastructureException : Exception
{
public JobStateServiceInfrastructureException(string message)
: base(message)
{
}
public JobStateServiceInfrastructureException(string message, Exception innerException)
: base(message, innerException)
{
}
}
And replace the exception in the RedisCacheJobState with the new exception type.
internal class RedisCacheJobState<TJob> : IJobStateService
{
private readonly IRedisDatabase _database;
private readonly string _stateKey;
public RedisCacheJobState(IRedisClient client)
{
ArgumentNullException.ThrowIfNull(client, nameof(client));
_database = client.CacheDatabase();
_stateKey = typeof(TJob).GetCustomAttribute<StaticCacheKeyAttribute>()?.KeyFormat ?? $"JobState.{typeof(TJob).Name}";
Name = typeof(TJob).GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? typeof(TJob).Name;
}
public string Name { get; }
public async Task<SearchJobState> RetrieveJobState(CancellationToken cancellationToken = default)
{
try
{
return await _database.GetAsync<SearchJobState>(_stateKey) ?? SearchJobState.Default();
}
catch (RedisConnectionException ex)
{
throw new JobStateServiceInfrastructureException($"Unable to retrieve the job state {Name}", ex);
}
}
public async Task UpdateJobState(SearchJobState state, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(state, nameof(state));
try
{
await _database.AddAsync(_stateKey, state);
}
catch (RedisConnectionException ex)
{
throw new JobStateServiceInfrastructureException($"Unable to update job state {Name}", ex);
}
}
}
We are catching the RedisConnectionException from the StackExchange.Redis library that is responsible with the Redis server communication.
We catch the exceptions that are in the documentation and depending on the behavior, we throw the equivalent exception.
Now the caller of the UpdateJobState method can handle the exception and add an eventual retry mechanism or handle the operation
Conclusions
Always handle exceptions from infrastructure layer every time when using Dependency Injection and Inversion of control. This way you can have a clean separation of concern of the positive and negative case