Skip to content

Exception Handling in ConnectSoft Microservice Template

Purpose & Overview

Exception Handling in the ConnectSoft Microservice Template is a first-class architectural concern designed to provide consistent, observable, and client-friendly error handling across all layers and communication channels. It ensures that exceptions are handled centrally, logged with rich context, and mapped to appropriate transport-specific responses.

Why Structured Exception Handling?

Exception handling in the template offers several key benefits:

  • Centralized Management: Consistent error handling logic across REST, gRPC, messaging, and background jobs
  • Context-Rich Logging: Every exception is logged with correlation IDs, trace IDs, and flow names for full observability
  • Framework-Aware: Integrated with ASP.NET Core ProblemDetails, gRPC interceptors, and messaging frameworks
  • Domain-Aware: Strongly-typed domain exceptions express business rule violations clearly
  • Client-Friendly: Users and services receive clean, structured error contracts (ProblemDetails, gRPC Status)
  • Observable: Failures are captured as metrics, logs, and traces for monitoring and alerting

Exception Handling Philosophy

The template enables developers to focus on business logic, knowing that infrastructure handles errors consistently and observably across all layers.

Architecture Overview

Exception handling is integrated across all layers of Clean Architecture:

Client Request
API Layer (REST/gRPC)
    ├── ProblemDetails Middleware (REST)
    └── gRPC Interceptor
Application Layer
    ├── Use Case Processors
    └── Validators (FluentValidation)
Domain Layer
    └── Domain Exceptions (Business Rules)
Infrastructure Layer
    ├── External APIs (Retry/Circuit Breaker)
    ├── Messaging (Retry/DLQ)
    └── Background Jobs (Retry/Failure Handling)
Logging & Observability
    ├── Structured Logging (Serilog)
    ├── Distributed Tracing (OpenTelemetry)
    └── Metrics & Alerts

Key Integration Points

Layer Component Responsibility
DomainModel Domain Exceptions Typed business rule violations
ApplicationModel Exception Propagation Rethrow or wrap domain exceptions
API Layer ProblemDetails/gRPC Interceptors Map exceptions to client responses
Infrastructure Retry/Circuit Breaker Policies Handle external dependency failures
Cross-Cutting Logging & Tracing Observability for all exceptions

Core Components

1. Domain-Level Exceptions

Domain exceptions represent business rule violations and are framework-agnostic:

// Base exception class
public abstract class DomainModelException : Exception
{
    protected DomainModelException(string message) : base(message) { }
    protected DomainModelException(string message, Exception innerException) 
        : base(message, innerException) { }
}

// Specific domain exceptions
public class MicroserviceAggregateRootAlreadyExistsException : DomainModelException
{
    public MicroserviceAggregateRootAlreadyExistsException(Guid objectId)
        : base($"MicroserviceAggregateRoot with id '{objectId}' already exists.")
    {
        ObjectId = objectId;
    }

    public Guid ObjectId { get; }
}

public class MicroserviceAggregateRootNotFoundException : DomainModelException
{
    public MicroserviceAggregateRootNotFoundException(Guid objectId)
        : base($"MicroserviceAggregateRoot with id '{objectId}' was not found.")
    {
        ObjectId = objectId;
    }

    public Guid ObjectId { get; }
}

public class ObjectIdRequiredException : DomainModelException
{
    public ObjectIdRequiredException()
        : base("ObjectId is required.")
    {
    }
}

Usage in Domain Code

public class MicroserviceAggregateRootsProcessor : IMicroserviceAggregateRootsProcessor
{
    public async Task<IMicroserviceAggregateRoot> CreateMicroserviceAggregateRoot(
        CreateMicroserviceAggregateRootInput input,
        CancellationToken token = default)
    {
        if (input.ObjectId == Guid.Empty)
        {
            throw new ObjectIdRequiredException();
        }

        if (await this.repository.ExistsAsync(input.ObjectId, token))
        {
            throw new MicroserviceAggregateRootAlreadyExistsException(input.ObjectId);
        }

        var aggregate = new MicroserviceAggregateRoot(input.ObjectId);
        await this.repository.InsertAsync(aggregate, token);
        return aggregate;
    }

    public async Task<IMicroserviceAggregateRoot> GetMicroserviceAggregateRootDetails(
        GetMicroserviceAggregateRootDetailsInput input,
        CancellationToken token = default)
    {
        var aggregate = await this.repository.GetByIdAsync(input.ObjectId, token);

        if (aggregate == null)
        {
            throw new MicroserviceAggregateRootNotFoundException(input.ObjectId);
        }

        return aggregate;
    }
}

Domain Exception Naming Conventions

Pattern Exception Type Use Case
*AlreadyExistsException Conflict scenarios Duplicate entity creation
*NotFoundException Entity lookup failures Entity not found
*RequiredException Input validation Missing required data
Invalid*Exception Domain rule violations Business rule violations
*DomainRuleViolatedException Complex business rules Advanced domain constraints

2. REST Exception Handling (ProblemDetails)

REST APIs use ASP.NET Core ProblemDetails middleware for standardized error responses:

// ProblemDetailsExtensions.cs
public static class ProblemDetailsExtensions
{
    public static void ConfigureProblemDetails(this IServiceCollection services)
    {
        services.AddProblemDetails(setup =>
        {
            // Map domain exceptions to HTTP status codes
            setup.Map<MicroserviceAggregateRootNotFoundException>((ctx, ex) =>
                new NotFoundProblemDetails 
                { 
                    Title = ex.Message,
                    Status = StatusCodes.Status404NotFound
                });

            setup.Map<MicroserviceAggregateRootAlreadyExistsException>((ctx, ex) =>
                new ConflictProblemDetails 
                { 
                    Title = ex.Message,
                    Status = StatusCodes.Status409Conflict
                });

            setup.Map<ObjectIdRequiredException>((ctx, ex) =>
                new BadRequestProblemDetails 
                { 
                    Title = ex.Message,
                    Status = StatusCodes.Status400BadRequest
                });

            // Fallback for unhandled exceptions
            setup.MapToStatusCode<Exception>(StatusCodes.Status500InternalServerError);
        });
    }
}

Registration

// Program.cs
builder.Services.ConfigureProblemDetails();

var app = builder.Build();

// Must come early in middleware pipeline
app.UseProblemDetails();

await app.RunAsync();

Response Format

When an exception is thrown, clients receive RFC 7807-compliant ProblemDetails:

{
  "type": "https://httpstatuses.com/409",
  "title": "MicroserviceAggregateRoot with id 'ABC123' already exists.",
  "status": 409,
  "traceId": "00-2e728d15b5e3f4a8c9d1e2f3a4b5c6d-00",
  "instance": "/api/aggregates"
}

3. gRPC Exception Handling

gRPC services use interceptors to map exceptions to gRPC Status codes:

// GrpcRichErrorInterceptor.cs
public class GrpcRichErrorInterceptor : Interceptor
{
    private readonly ILogger<GrpcRichErrorInterceptor> logger;

    public GrpcRichErrorInterceptor(ILogger<GrpcRichErrorInterceptor> logger)
    {
        this.logger = logger;
    }

    public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        try
        {
            return await continuation(request, context);
        }
        catch (MicroserviceAggregateRootNotFoundException ex)
        {
            this.logger.LogWarning(
                "Resource not found: {ExceptionMessage}",
                ex.Message);

            throw new RpcException(
                new Status(StatusCode.NotFound, ex.Message));
        }
        catch (MicroserviceAggregateRootAlreadyExistsException ex)
        {
            this.logger.LogWarning(
                "Resource conflict: {ExceptionMessage}",
                ex.Message);

            throw new RpcException(
                new Status(StatusCode.AlreadyExists, ex.Message));
        }
        catch (ObjectIdRequiredException ex)
        {
            this.logger.LogWarning(
                "Invalid argument: {ExceptionMessage}",
                ex.Message);

            throw new RpcException(
                new Status(StatusCode.InvalidArgument, ex.Message));
        }
        catch (ValidationException ex)
        {
            this.logger.LogWarning(
                "Validation failed: {ExceptionMessage}",
                ex.Message);

            throw new RpcException(
                new Status(StatusCode.InvalidArgument, ex.Message));
        }
        catch (Exception ex)
        {
            this.logger.LogError(
                ex,
                "Unhandled exception in gRPC service: {ExceptionMessage}",
                ex.Message);

            throw new RpcException(
                new Status(StatusCode.Internal, "An internal error occurred."));
        }
    }
}

Registration

// Program.cs
builder.Services.AddGrpc(options =>
{
    options.Interceptors.Add<GrpcRichErrorInterceptor>();
});

gRPC Status Code Mapping

Domain Exception gRPC Status Code Description
NotFoundException StatusCode.NotFound Resource not found
AlreadyExistsException StatusCode.AlreadyExists Resource conflict
ValidationException StatusCode.InvalidArgument Invalid input
ObjectIdRequiredException StatusCode.InvalidArgument Missing required parameter
Exception (fallback) StatusCode.Internal Unhandled error

4. Validation Exception Handling

FluentValidation is integrated for request validation:

// CreateMicroserviceAggregateRootInputValidator.cs
public class CreateMicroserviceAggregateRootInputValidator 
    : AbstractValidator<CreateMicroserviceAggregateRootInput>
{
    public CreateMicroserviceAggregateRootInputValidator()
    {
        RuleFor(x => x.ObjectId)
            .NotEmpty()
            .WithMessage("ObjectId is required.");
    }
}

Registration

// FluentValidationExtensions.cs
public static class FluentValidationExtensions
{
    public static IServiceCollection AddFluentValidation(
        this IServiceCollection services)
    {
        services.AddValidatorsFromAssemblyContaining<CreateMicroserviceAggregateRootInputValidator>();

        return services;
    }
}

Note: Automatic validation via AddFluentValidationAutoValidation() is no longer recommended by FluentValidation. The template uses manual validation instead, which provides better control and aligns with FluentValidation's current recommendations.

REST Validation Error Response

When validation fails, clients receive ValidationProblemDetails:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "ObjectId": ["ObjectId is required."]
  },
  "traceId": "00-7f7421a3b5c9d2e4f6a8b1c3d5e7f9a-00"
}

5. Exception Logging and Enrichment

All exceptions are logged with structured context using Serilog:

public class MicroserviceAggregateRootsProcessor : IMicroserviceAggregateRootsProcessor
{
    private readonly ILogger<MicroserviceAggregateRootsProcessor> logger;
    private readonly IMicroserviceAggregateRootsRepository repository;

    public async Task<IMicroserviceAggregateRoot> CreateMicroserviceAggregateRoot(
        CreateMicroserviceAggregateRootInput input,
        CancellationToken token = default)
    {
        try
        {
            var aggregate = new MicroserviceAggregateRoot(input.ObjectId);
            await this.repository.InsertAsync(aggregate, token);

            this.logger.LogInformation(
                "Created MicroserviceAggregateRoot with id: {ObjectId}",
                input.ObjectId);

            return aggregate;
        }
        catch (MicroserviceAggregateRootAlreadyExistsException ex)
        {
            // Domain exceptions logged as warnings (expected business errors)
            this.logger.LogWarning(
                ex,
                "Failed to create MicroserviceAggregateRoot: {Message}",
                ex.Message);
            throw;
        }
        catch (Exception ex)
        {
            // Infrastructure exceptions logged as errors (unexpected failures)
            this.logger.LogError(
                ex,
                "Failed to create MicroserviceAggregateRoot with id: {ObjectId}",
                input.ObjectId);
            throw;
        }
    }
}

Structured Log Format

Logs include correlation context automatically:

{
  "timestamp": "2025-01-15T12:00:01Z",
  "level": "Error",
  "message": "Failed to create MicroserviceAggregateRoot with id: ABC123",
  "exception": "MicroserviceAggregateRootAlreadyExistsException: MicroserviceAggregateRoot with id 'ABC123' already exists.",
  "traceId": "00-9f7281f60e0a3a4d5e6f7a8b9c0d1e2f-00",
  "spanId": "b9fc849e",
  "flow": "CreateMicroserviceAggregateRoot",
  "objectId": "ABC123",
  "service": "MicroserviceTemplate",
  "applicationFlowName": "MicroserviceAggregateRootsProcessor/CreateMicroserviceAggregateRoot"
}

6. Messaging Exception Handling

Messaging frameworks (MassTransit, NServiceBus) handle exceptions with retry policies and dead-letter queues:

MassTransit Retry Policy

// MassTransitExtensions.cs
services.AddMassTransit(x =>
{
    x.AddConsumer<MicroserviceAggregateRootCreatedConsumer>();

    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host("localhost");

        cfg.ReceiveEndpoint("aggregate-created-event", ep =>
        {
            // Retry policy for transient failures
            ep.UseMessageRetry(r => r.Interval(
                5, 
                TimeSpan.FromSeconds(10)));

            ep.ConfigureConsumer<MicroserviceAggregateRootCreatedConsumer>(context);
        });
    });
});

Consumer Exception Handling

public class MicroserviceAggregateRootCreatedConsumer 
    : IConsumer<MicroserviceAggregateRootCreatedEvent>
{
    private readonly ILogger<MicroserviceAggregateRootCreatedConsumer> logger;

    public async Task Consume(ConsumeContext<MicroserviceAggregateRootCreatedEvent> context)
    {
        try
        {
            // Process message
            await ProcessEvent(context.Message);
        }
        catch (DomainModelException ex)
        {
            // Domain exceptions: log and fail (don't retry)
            this.logger.LogWarning(
                ex,
                "Domain validation failed for message: {MessageId}",
                context.MessageId);

            // Message will be sent to dead-letter queue
            throw;
        }
        catch (Exception ex)
        {
            // Infrastructure exceptions: log and let retry policy handle
            this.logger.LogError(
                ex,
                "Error processing message: {MessageId}",
                context.MessageId);

            // Will be retried according to policy
            throw;
        }
    }
}

Dead-Letter Queue (DLQ)

After retry attempts are exhausted: - Messages are moved to DLQ - Alerts can be configured for DLQ growth - DLQ messages can be inspected and reprocessed manually

7. Background Job Exception Handling

Background jobs (Hangfire) handle exceptions with retry policies:

public class ScheduledJobService
{
    private readonly ILogger<ScheduledJobService> logger;
    private readonly IMicroserviceAggregateRootsProcessor processor;

    [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 10, 30, 60 })]
    public async Task ProcessScheduledTask()
    {
        try
        {
            // Job logic
            await processor.ProcessBatchAsync();

            this.logger.LogInformation("Scheduled job completed successfully");
        }
        catch (DomainModelException ex)
        {
            // Domain exceptions: log and fail (don't retry)
            this.logger.LogWarning(
                ex,
                "Domain validation failed in scheduled job");

            throw; // Hangfire will mark job as failed
        }
        catch (Exception ex)
        {
            // Infrastructure exceptions: log and retry
            this.logger.LogError(
                ex,
                "Error in scheduled job");

            throw; // Hangfire will retry according to [AutomaticRetry] attribute
        }
    }
}

8. External API Exception Handling

External API calls use Polly for resilience patterns:

// HttpClientExtensions.cs
public static class HttpClientExtensions
{
    public static IServiceCollection AddResilientHttpClient<TClient, TImplementation>(
        this IServiceCollection services)
        where TClient : class
        where TImplementation : class, TClient
    {
        services.AddHttpClient<TClient, TImplementation>()
            .AddPolicyHandler(GetRetryPolicy())
            .AddPolicyHandler(GetCircuitBreakerPolicy())
            .AddPolicyHandler(Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(10)));

        return services;
    }

    private static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
    {
        return Policy<HttpResponseMessage>
            .Handle<HttpRequestException>()
            .OrResult(r => (int)r.StatusCode >= 500)
            .WaitAndRetryAsync(
                3,
                retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
                onRetry: (outcome, timespan, retryCount, context) =>
                {
                    var logger = context.GetLogger();
                    logger?.LogWarning(
                        "Retry {RetryCount} after {Delay}s",
                        retryCount,
                        timespan.TotalSeconds);
                });
    }

    private static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
    {
        return Policy<HttpResponseMessage>
            .Handle<HttpRequestException>()
            .OrResult(r => (int)r.StatusCode >= 500)
            .CircuitBreakerAsync(
                handledEventsAllowedBeforeBreaking: 5,
                durationOfBreak: TimeSpan.FromSeconds(30),
                onBreak: (result, duration) =>
                {
                    // Log circuit breaker opened
                },
                onReset: () =>
                {
                    // Log circuit breaker reset
                });
    }
}

Usage

public class ExternalApiClient
{
    private readonly HttpClient httpClient;
    private readonly ILogger<ExternalApiClient> logger;

    public async Task<ResponseDto> CallExternalServiceAsync(RequestDto request)
    {
        try
        {
            var response = await httpClient.PostAsJsonAsync("/api/endpoint", request);
            response.EnsureSuccessStatusCode();

            return await response.Content.ReadFromJsonAsync<ResponseDto>();
        }
        catch (HttpRequestException ex)
        {
            this.logger.LogError(
                ex,
                "Failed to call external service: {RequestUri}",
                httpClient.BaseAddress);

            throw new ExternalServiceException("External service unavailable", ex);
        }
    }
}

Configuration

ProblemDetails Configuration

// Program.cs
builder.Services.ConfigureProblemDetails();

var app = builder.Build();
app.UseProblemDetails();

gRPC Interceptor Configuration

// Program.cs
builder.Services.AddGrpc(options =>
{
    options.Interceptors.Add<GrpcRichErrorInterceptor>();
});

FluentValidation Configuration

// Program.cs
builder.Services.AddFluentValidation();

Logging Configuration

// Program.cs
builder.Host.UseSerilog((context, configuration) =>
{
    configuration
        .ReadFrom.Configuration(context.Configuration)
        .Enrich.FromLogContext()
        .Enrich.WithCorrelationId()
        .Enrich.WithProperty("ServiceName", "MicroserviceTemplate")
        .WriteTo.Console(new RenderedCompactJsonFormatter());
});

Polly Policies Configuration

// Program.cs
builder.Services.AddResilientHttpClient<IExternalApiClient, ExternalApiClient>();

Exception Mapping Matrix

Domain Exception REST (HTTP) gRPC Messaging Log Level
AlreadyExistsException 409 Conflict AlreadyExists DLQ Warning
NotFoundException 404 Not Found NotFound DLQ Warning
ValidationException 400 Bad Request InvalidArgument DLQ Information
ObjectIdRequiredException 400 Bad Request InvalidArgument DLQ Warning
ExternalServiceException 502 Bad Gateway Unavailable Retry Error
Exception (unhandled) 500 Internal Server Error Internal Retry → DLQ Error

Best Practices

Do's

  1. Use typed domain exceptions
  2. Express business rule violations clearly
  3. Enable explicit exception mapping
  4. Improve code readability

  5. Log exceptions with context

  6. Always include correlation IDs
  7. Use structured logging
  8. Separate domain errors from infrastructure failures

  9. Map exceptions at boundaries

  10. Map domain exceptions to transport-specific responses
  11. Keep domain layer framework-agnostic
  12. Centralize mapping logic

  13. Handle external dependencies gracefully

  14. Use retry policies for transient failures
  15. Implement circuit breakers for resilience
  16. Set appropriate timeouts

  17. Validate early

  18. Validate DTOs at API boundaries
  19. Fail fast before business logic execution
  20. Provide clear validation error messages

  21. Use appropriate log levels

  22. Domain exceptions: Warning (expected business errors)
  23. Infrastructure exceptions: Error (unexpected failures)
  24. Validation failures: Information

  25. Never swallow exceptions

  26. Always log exceptions before rethrowing
  27. Don't catch exceptions unless handling them
  28. Use catch blocks to add context, then rethrow

Don'ts

  1. Don't expose internal exceptions to clients
  2. Don't leak stack traces in production
  3. Map all exceptions to client-friendly messages
  4. Hide implementation details

  5. Don't catch exceptions unnecessarily

  6. Let exceptions propagate to appropriate handlers
  7. Only catch when you can add value (logging, retry)
  8. Avoid catching generic Exception unless necessary

  9. Don't use exceptions for control flow

  10. Use return values or Result patterns for expected scenarios
  11. Exceptions should indicate exceptional conditions
  12. Don't throw exceptions for validation that can be checked upfront

  13. Don't ignore exception context

  14. Always preserve inner exceptions
  15. Include relevant identifiers in exception messages
  16. Don't lose correlation information

  17. Don't retry non-idempotent operations blindly

  18. Ensure operations are safe to retry
  19. Use idempotency keys where appropriate
  20. Consider compensating actions for failed operations

Common Scenarios

Scenario 1: Creating a Resource That Already Exists

// Domain layer
if (await repository.ExistsAsync(objectId))
{
    throw new MicroserviceAggregateRootAlreadyExistsException(objectId);
}

// REST client receives: 409 Conflict with ProblemDetails
// gRPC client receives: StatusCode.AlreadyExists
// Logged as: Warning level

Scenario 2: Retrieving a Non-Existent Resource

// Domain layer
var entity = await repository.GetByIdAsync(id);
if (entity == null)
{
    throw new MicroserviceAggregateRootNotFoundException(id);
}

// REST client receives: 404 Not Found with ProblemDetails
// gRPC client receives: StatusCode.NotFound
// Logged as: Warning level

Scenario 3: Invalid Input Validation

// FluentValidation automatically validates DTO
public class CreateInputValidator : AbstractValidator<CreateInput>
{
    public CreateInputValidator()
    {
        RuleFor(x => x.ObjectId).NotEmpty();
    }
}

// REST client receives: 400 Bad Request with ValidationProblemDetails
// gRPC client receives: StatusCode.InvalidArgument
// Logged as: Information level

Scenario 4: External API Failure

// Infrastructure layer with Polly policies
try
{
    var response = await httpClient.GetAsync("/external/api");
    response.EnsureSuccessStatusCode();
}
catch (HttpRequestException ex)
{
    // Retry policy attempts 3 retries
    // Circuit breaker opens after 5 failures
    // Final failure logged as Error
    throw new ExternalServiceException("External service unavailable", ex);
}

Scenario 5: Messaging Consumer Failure

// MassTransit consumer
public async Task Consume(ConsumeContext<Event> context)
{
    try
    {
        await ProcessEvent(context.Message);
    }
    catch (DomainModelException ex)
    {
        // Domain exception: log and send to DLQ (no retry)
        logger.LogWarning(ex, "Domain validation failed");
        throw;
    }
    catch (Exception ex)
    {
        // Infrastructure exception: retry 5 times, then DLQ
        logger.LogError(ex, "Processing failed");
        throw;
    }
}

Observability

Logging

All exceptions are logged with structured context:

  • Correlation IDs: Track requests across services
  • Trace IDs: Link to distributed traces
  • Flow Names: Identify where exceptions occurred
  • Exception Types: Categorize by domain vs infrastructure

Metrics

Exception-related metrics:

Metric Description
exceptions_total Total exception count by type
http_errors_total HTTP error count by status code
grpc_errors_total gRPC error count by status code
retry_attempts_total Retry count for failed operations
circuit_breaker_state Circuit breaker open/closed state

Distributed Tracing

Exceptions are captured in OpenTelemetry traces:

  • Exception events attached to spans
  • Stack traces preserved in trace data
  • Correlation with request flows
  • Performance impact analysis

Alerting

Configure alerts for:

  • High exception rates
  • Dead-letter queue growth
  • Circuit breaker openings
  • External service failures
  • Unhandled exception patterns

Testing Exception Handling

Unit Testing Domain Exceptions

[TestMethod]
public async Task Create_WhenAlreadyExists_ThrowsException()
{
    // Arrange
    var repository = new Mock<IRepository>();
    repository.Setup(r => r.ExistsAsync(It.IsAny<Guid>()))
        .ReturnsAsync(true);

    var processor = new MicroserviceAggregateRootsProcessor(repository.Object, logger);

    // Act & Assert
    var ex = await Assert.ThrowsExceptionAsync<MicroserviceAggregateRootAlreadyExistsException>(
        () => processor.CreateAsync(new CreateInput { ObjectId = Guid.NewGuid() }));

    Assert.AreEqual(expectedObjectId, ex.ObjectId);
}

Integration Testing REST Exception Mapping

[TestMethod]
public async Task Create_WhenAlreadyExists_Returns409Conflict()
{
    // Arrange
    var client = factory.CreateClient();

    // Act - First create succeeds
    var response1 = await client.PostAsJsonAsync("/api/aggregates", createRequest);
    Assert.AreEqual(HttpStatusCode.Created, response1.StatusCode);

    // Act - Second create fails
    var response2 = await client.PostAsJsonAsync("/api/aggregates", createRequest);

    // Assert
    Assert.AreEqual(HttpStatusCode.Conflict, response2.StatusCode);

    var problem = await response2.Content.ReadFromJsonAsync<ProblemDetails>();
    Assert.AreEqual(409, problem.Status);
    Assert.Contains("already exists", problem.Title);
}

Integration Testing gRPC Exception Mapping

[TestMethod]
public async Task Get_WhenNotFound_ThrowsRpcException()
{
    // Arrange
    var client = new MicroserviceAggregateRootsServiceClient(channel);

    // Act & Assert
    var ex = await Assert.ThrowsExceptionAsync<RpcException>(
        () => client.GetAsync(new GetRequest { ObjectId = Guid.NewGuid() }));

    Assert.AreEqual(StatusCode.NotFound, ex.StatusCode);
    Assert.Contains("not found", ex.Status.Detail);
}

Summary

Exception handling in the ConnectSoft Microservice Template provides:

  • Centralized Exception Management: Consistent handling across all layers and channels
  • Domain-Aware: Typed domain exceptions express business rules clearly
  • Framework-Aware: Automatic mapping to REST ProblemDetails and gRPC Status
  • Observable: Rich logging, tracing, and metrics for all exceptions
  • Resilient: Retry policies, circuit breakers, and DLQs for infrastructure failures
  • Client-Friendly: Structured, standards-compliant error responses
  • Testable: Clear patterns for unit and integration testing

By following these patterns and best practices, exception handling becomes a powerful foundation for building reliable, observable, and maintainable microservices that fail gracefully and inform clearly.