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¶
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¶
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¶
- Use typed domain exceptions
- Express business rule violations clearly
- Enable explicit exception mapping
-
Improve code readability
-
Log exceptions with context
- Always include correlation IDs
- Use structured logging
-
Separate domain errors from infrastructure failures
-
Map exceptions at boundaries
- Map domain exceptions to transport-specific responses
- Keep domain layer framework-agnostic
-
Centralize mapping logic
-
Handle external dependencies gracefully
- Use retry policies for transient failures
- Implement circuit breakers for resilience
-
Set appropriate timeouts
-
Validate early
- Validate DTOs at API boundaries
- Fail fast before business logic execution
-
Provide clear validation error messages
-
Use appropriate log levels
- Domain exceptions: Warning (expected business errors)
- Infrastructure exceptions: Error (unexpected failures)
-
Validation failures: Information
-
Never swallow exceptions
- Always log exceptions before rethrowing
- Don't catch exceptions unless handling them
- Use catch blocks to add context, then rethrow
Don'ts¶
- Don't expose internal exceptions to clients
- Don't leak stack traces in production
- Map all exceptions to client-friendly messages
-
Hide implementation details
-
Don't catch exceptions unnecessarily
- Let exceptions propagate to appropriate handlers
- Only catch when you can add value (logging, retry)
-
Avoid catching generic
Exceptionunless necessary -
Don't use exceptions for control flow
- Use return values or Result patterns for expected scenarios
- Exceptions should indicate exceptional conditions
-
Don't throw exceptions for validation that can be checked upfront
-
Don't ignore exception context
- Always preserve inner exceptions
- Include relevant identifiers in exception messages
-
Don't lose correlation information
-
Don't retry non-idempotent operations blindly
- Ensure operations are safe to retry
- Use idempotency keys where appropriate
- 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.