MessageValidation: A Protocol-Agnostic Message Validation Pipeline for .NET
Every message consumer faces the same challenge: raw bytes arrive, you deserialize them, and then you have to validate before processing. This logic gets copy-pasted across every protocol and every project.
MessageValidation extracts that repeated work into a single, reusable pipeline โ and stays completely agnostic about which transport or validation framework you use.
๐ฆ GitHub: Romain-OD/MessageValidation ยท MIT ยท .NET 10
The Problem
Imagine you have three consumers โ one on MQTT, one on RabbitMQ, one on Kafka. Each one ends up doing:
// โ Repeated in every consumer, every project var message = JsonSerializer.Deserialize<OrderPlaced>(payload); if (message.OrderId == Guid.Empty) { /* drop or log */ return; } if (message.Amount <= 0) { /* dead-letter */ return; } await _handler.HandleAsync(message);
The transport changes. The shape changes. The boilerplate does not.
The Solution: One Pipeline, Any Transport
Raw bytes โ Deserialize โ Validate โ Handle (or dead-letter / log / skip)
Install the core package:
dotnet add package MessageValidation
The core has zero opinion on which messaging library or validation framework you use. Adapters bring the implementations.
Quick Start
1 โ Define your message
public class TemperatureReading { public string SensorId { get; set; } = ""; public double Value { get; set; } public DateTime Timestamp { get; set; } }
2 โ Implement a validator
public class TemperatureReadingValidator : IMessageValidator<TemperatureReading> { public Task<MessageValidationResult> ValidateAsync( TemperatureReading message, CancellationToken ct = default) { var errors = new List<MessageValidationError>(); if (string.IsNullOrWhiteSpace(message.SensorId)) errors.Add(new("SensorId", "SensorId is required.")); if (message.Value is < -50 or > 150) errors.Add(new("Value", "Value must be between -50 and 150.")); return Task.FromResult(errors.Count == 0 ? MessageValidationResult.Success() : MessageValidationResult.Failure(errors)); } }
3 โ Implement a handler
public class TemperatureHandler : IMessageHandler<TemperatureReading> { public Task HandleAsync( TemperatureReading message, MessageContext context, CancellationToken ct = default) { // Only reached when validation passes Console.WriteLine($"[{context.Source}] Sensor {message.SensorId}: {message.Value}°C"); return Task.CompletedTask; } }
4 โ Register & run
builder.Services.AddMessageValidation(options =>
{
options.MapSource<TemperatureReading>("sensors/+/temperature");
options.DefaultFailureBehavior = FailureBehavior.DeadLetter;
});
builder.Services.AddMessageDeserializer<JsonMessageDeserializer>();
builder.Services.AddScoped<IMessageValidator<TemperatureReading>, TemperatureReadingValidator>();
builder.Services.AddMessageHandler<TemperatureReading, TemperatureHandler>();
// Feed any raw bytes into the pipeline
var pipeline = serviceProvider.GetRequiredService<IMessageValidationPipeline>();
await pipeline.ProcessAsync(new MessageContext
{
Source = "sensors/living-room/temperature",
RawPayload = payloadBytes
});
Source Mapping & Wildcards
Map source patterns (topics, queues, routing keys) to message types. Supports MQTT-style wildcards out of the box:
| Pattern | Matches |
|---|---|
sensors/living-room/temperature |
Exact match only |
sensors/+/temperature |
sensors/kitchen/temperature, sensors/bedroom/temperature, โฆ |
devices/# |
devices/abc, devices/abc/status, devices/abc/status/battery, โฆ |
options.MapSource<TemperatureReading>("sensors/+/temperature"); options.MapSource<DeviceHeartbeat>("devices/#");
Five Failure Behaviors
When validation fails you choose what happens โ no surprises, no hidden defaults:
| Behavior | What it does |
|---|---|
Log |
Logs the errors and drops the message (default) |
DeadLetter |
Routes to a dead-letter destination via IDeadLetterHandler |
Skip |
Silently drops the message |
ThrowException |
Throws MessageValidationException |
Custom |
Delegates to your IValidationFailureHandler |
options.DefaultFailureBehavior = FailureBehavior.DeadLetter; options.DeadLetterPrefix = "$dead-letter/"; // default, override as needed builder.Services.AddDeadLetterHandler<MyDeadLetterHandler>();
The DeadLetterContext carries the computed destination, the original MessageContext, the validation errors, and a UTC timestamp โ everything you need to replay or audit the message.
Transport Adapters
The same AddMessageValidation(...) registration works across all transports. Only the transport line changes; handlers, validators, and dead-letter logic stay untouched.
MQTTnet
dotnet add package MessageValidation.MqttNet
builder.Services.AddMqttNetMessageValidation();
// Or hook directly onto an IMqttClient
mqttClient.UseMessageValidation(pipeline);
RabbitMQ
dotnet add package MessageValidation.RabbitMQ // wraps RabbitMQ.Client 7.2.1
builder.Services.AddRabbitMqMessageValidation();
Kafka
dotnet add package MessageValidation.Kafka // wraps Confluent.Kafka 2.13.2
builder.Services.AddKafkaMessageValidation();
Validation Adapters
Don't want to implement IMessageValidator<T> by hand? Drop in a validation adapter.
DataAnnotations
dotnet add package MessageValidation.DataAnnotations
public class OrderPlaced { [Required] public Guid OrderId { get; set; } [Range(0.01, double.MaxValue)] public decimal Amount { get; set; } } // Bridges [Required], [Range], [MaxLength], โฆ โ IMessageValidator<T> automatically builder.Services.AddMessageDataAnnotationsValidation();
FluentValidation
dotnet add package MessageValidation.FluentValidation
public class OrderPlacedValidator : AbstractValidator<OrderPlaced> { public OrderPlacedValidator() { RuleFor(x => x.OrderId).NotEmpty(); RuleFor(x => x.Amount).GreaterThan(0); } } // Bridges IValidator<T> โ IMessageValidator<T> automatically builder.Services.AddMessageFluentValidation(); builder.Services.AddScoped<IValidator<OrderPlaced>, OrderPlacedValidator>();
Architecture at a Glance
MessageValidation/ โ Core pipeline (zero opinion)
โ Abstractions/
โ โ IMessageValidationPipeline โ mockable contract
โ โ IMessageValidator<T>
โ โ IMessageHandler<T>
โ โ IMessageDeserializer
โ โ IDeadLetterHandler
โ Configuration/
โ โ FailureBehavior โ Log | DeadLetter | Skip | Throw | Custom
โ โ MessageValidationOptions โ source-to-type mapping + wildcards
โ Diagnostics/
โ MessageValidationMetrics โ System.Diagnostics.Metrics counters
โ
MessageValidation.DataAnnotations/ โ validation adapter
MessageValidation.FluentValidation/ โ validation adapter
MessageValidation.MqttNet/ โ transport adapter
MessageValidation.RabbitMQ/ โ transport adapter
MessageValidation.Kafka/ โ transport adapter
The core defines contracts and the pipeline. Adapters bring the implementations. Nothing is coupled.
Observability Built In
The library ships System.Diagnostics.Metrics counters out of the box โ plug them into your existing OpenTelemetry or Prometheus setup with no extra code.
Package Summary
| Package | Role |
|---|---|
MessageValidation |
Core pipeline & abstractions |
MessageValidation.FluentValidation |
FluentValidation adapter |
MessageValidation.DataAnnotations |
DataAnnotations adapter |
MessageValidation.MqttNet |
MQTTnet transport hook |
MessageValidation.RabbitMQ |
RabbitMQ transport hook |
MessageValidation.Kafka |
Kafka transport hook |
All packages target .NET 10, are MIT licensed, and available on NuGet.org.
Roadmap
| Version | Highlights |
|---|---|
| v0.1 | Core pipeline, FluentValidation adapter, MQTTnet adapter, wildcard matching |
| v0.2 | DataAnnotations adapter, System.Diagnostics.Metrics observability |
| v0.3 | Dead-letter queue support (IDeadLetterHandler, DeadLetterContext, metrics) |
| v1.0 | RabbitMQ & Kafka adapters |
| v2.0 | Middleware-style pipeline (Use, Map), Azure Service Bus adapter |
Getting Started
dotnet add package MessageValidation
๐ GitHub โ Romain-OD/MessageValidation
๐ NuGet โ MessageValidation
๐ NuGet โ All packages
Related in this series
- NATS Message Broker in .NET - subjects, queue groups and the NatsNet package in context
- Idempotency in .NET: the inbox pattern - validation rejects bad messages, idempotency handles repeated ones
- The Outbox Pattern in .NET - reliable publishing on the producer side
- Change Data Capture in PostgreSQL with .NET and Aspire - events sourced from the database log
