Um MQTT in ASP.NET Core zu konsumieren, registrierst du einen einzigen MQTTnet-IMqttClient in der Dependency Injection, verbindest und reconnectest ihn aus einem BackgroundService, schiebst eingehende Nachrichten in einen Channel<T> und verarbeitest sie in einem zweiten Hosted Service, der pro Nachricht einen DI-Scope erzeugt. Ein Health Check meldet den Verbindungsstatus, und deine API-Endpoints veröffentlichen über denselben Client.
Dieses Tutorial baut diesen Dienst Schritt für Schritt mit MQTTnet 5 auf .NET 8 oder neuer. Kennst du MQTTnet noch nicht, lies zuerst MQTT in C# mit MQTTnet; hier geht es um das Hosting.
Die Architektur
| Komponente | Lebensdauer | Aufgabe |
|---|---|---|
IMqttClient | Singleton | Eine Verbindung pro App-Instanz, von allem gemeinsam genutzt |
MqttConnectionService | Hosted Service | Verbindet, reconnectet, abonniert, trennt beim Shutdown |
Channel<InboundMessage> | Singleton | Begrenzter Puffer zwischen Netzwerk und Geschäftslogik |
TelemetryConsumer | Hosted Service | Liest den Channel, erzeugt einen Scope, ruft deinen Handler auf |
ITelemetryHandler | Scoped | Dein Code – darf DbContext und andere Scoped Services nutzen |
MqttHealthCheck | Transient | Meldet, ob der Client verbunden ist |
Empfang und Verarbeitung zu trennen ist wichtig. Der Event-Handler von MQTTnet läuft im Dispatcher des Clients; wartet er auf eine Datenbank, warten alle nachfolgenden Nachrichten mit, und auch das Keep-Alive-Handling kann leiden. Der Channel entkoppelt beides und liefert natürlichen Backpressure.
Projekt und Konfiguration
dotnet new web -n MqttWorker && cd MqttWorker
dotnet add package MQTTnet
dotnet user-secrets init
dotnet user-secrets set "Mqtt:Username" "username"
dotnet user-secrets set "Mqtt:Password" "password"{
"Mqtt": {
"Host": "broker.connectmq.com",
"Port": 8883,
"UseTls": true,
"Topics": [ "testmqtt/aspnet/telemetry/#" ]
}
}Zugangsdaten liegen lokal in den User Secrets und in der Produktion in Umgebungsvariablen (Mqtt__Username, Mqtt__Password) oder einem Secret Store – niemals in appsettings.json. Binde die Sektion an eine Settings-Klasse:
public sealed class MqttSettings
{
public string Host { get; set; } = "broker.connectmq.com";
public int Port { get; set; } = 8883;
public bool UseTls { get; set; } = true;
public string? Username { get; set; }
public string? Password { get; set; }
public string ClientId { get; set; } = $"aspnet-{Environment.MachineName}";
public string[] Topics { get; set; } = [];
}
public sealed record InboundMessage(string Topic, string Payload, DateTimeOffset ReceivedAt);Die Standard-Client-ID nutzt den Maschinennamen, in Kubernetes also den Pod-Namen – und der ist pro Replica eindeutig. Zwei Instanzen mit derselben Client-ID würden sich gegenseitig ständig vom Broker werfen.
Services registrieren
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MQTTnet;
using MQTTnet.Protocol;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<MqttSettings>(builder.Configuration.GetSection("Mqtt"));
builder.Services.AddSingleton<IMqttClient>(_ => new MqttClientFactory().CreateMqttClient());
builder.Services.AddSingleton(_ => Channel.CreateBounded<InboundMessage>(
new BoundedChannelOptions(1_000) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true }));
builder.Services.AddHostedService<MqttConnectionService>();
builder.Services.AddHostedService<TelemetryConsumer>();
builder.Services.AddScoped<ITelemetryHandler, TelemetryHandler>();
builder.Services.AddHealthChecks()
.AddCheck<MqttHealthCheck>("mqtt", failureStatus: HealthStatus.Degraded, tags: ["ready"]);
var app = builder.Build();
app.MapHealthChecks("/healthz/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") });
MapCommandEndpoint(app); // lokale Funktion, siehe unten
app.Run();Unter MQTTnet 4 ersetzt du MqttClientFactory durch MqttFactory und ergänzt using MQTTnet.Client; – der Rest bleibt gleich.
Der Verbindungsdienst
Dieser Hosted Service besitzt die Verbindung. Er übernimmt die Reconnect-Schleife aus dem MQTTnet-Tutorial und abonniert in ConnectedAsync neu, sodass die Subscriptions jeden Reconnect überstehen:
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using MQTTnet;
using MQTTnet.Formatter;
using MQTTnet.Protocol;
public sealed class MqttConnectionService(
IMqttClient client,
IOptions<MqttSettings> settings,
Channel<InboundMessage> inbox,
ILogger<MqttConnectionService> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var s = settings.Value;
var builder = new MqttClientOptionsBuilder()
.WithTcpServer(s.Host, s.Port)
.WithClientId(s.ClientId)
.WithCredentials(s.Username, s.Password)
.WithProtocolVersion(MqttProtocolVersion.V500)
.WithKeepAlivePeriod(TimeSpan.FromSeconds(30));
if (s.UseTls) builder.WithTlsOptions(o => o.UseTls());
var options = builder.Build();
client.ApplicationMessageReceivedAsync += async e =>
{
var m = e.ApplicationMessage;
// Nur das Nötige kopieren; keine MQTTnet-Objekte über den Handler hinaus halten.
await inbox.Writer.WriteAsync(
new InboundMessage(m.Topic, m.ConvertPayloadToString(), DateTimeOffset.UtcNow), stoppingToken);
};
client.ConnectedAsync += async _ =>
{
var sub = new MqttClientSubscribeOptionsBuilder();
foreach (var topic in s.Topics) sub.WithTopicFilter(topic, MqttQualityOfServiceLevel.AtLeastOnce);
var result = await client.SubscribeAsync(sub.Build(), stoppingToken);
foreach (var item in result.Items)
log.LogInformation("SUBACK {Topic}: {Code}", item.TopicFilter.Topic, item.ResultCode);
};
client.DisconnectedAsync += e =>
{
if (e.ClientWasConnected) log.LogWarning(e.Exception, "MQTT disconnected: {Reason}", e.Reason);
return Task.CompletedTask;
};
var delay = TimeSpan.FromSeconds(1);
while (!stoppingToken.IsCancellationRequested)
{
try
{
if (!await client.TryPingAsync(stoppingToken))
{
await client.ConnectAsync(options, stoppingToken);
log.LogInformation("MQTT connected to {Host}:{Port}", s.Host, s.Port);
delay = TimeSpan.FromSeconds(1);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex)
{
log.LogWarning("MQTT connect failed: {Message}", ex.Message);
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 60));
}
try { await Task.Delay(delay + TimeSpan.FromMilliseconds(Random.Shared.Next(500)), stoppingToken); }
catch (OperationCanceledException) { break; }
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
if (client.IsConnected)
await client.DisconnectAsync(new MqttClientDisconnectOptionsBuilder()
.WithReason(MqttClientDisconnectOptionsReason.NormalDisconnection).Build(), cancellationToken);
inbox.Writer.TryComplete();
}
}Weil der Channel mit FullMode = Wait begrenzt ist, lässt ein langsamer Consumer WriteAsync warten, was das PUBACK für QoS-1-Nachrichten verzögert. Der Broker hört dann auf zu senden, sobald das Receive Maximum des Clients erreicht ist, statt dass dein Prozess Nachrichten puffert, bis der Speicher ausgeht. Stimme die Kapazität darauf ab, wie viele Sekunden Traffic du zurückhalten willst.
StopAsync schickt ein ordentliches DISCONNECT, wenn der Host herunterfährt (Ctrl+C, Container-Stopp, Deployment). Ohne das bemerkt der Broker den fehlenden Client erst nach Ablauf des Keep-Alive und veröffentlicht dessen Last Will, falls einer gesetzt ist.
Der Consumer und Scoped Handler
Injizierst du einen Scoped Service wie einen DbContext direkt in einen Hosted Service, bricht die App beim Start ab mit "Cannot consume scoped service 'AppDbContext' from singleton 'Microsoft.Extensions.Hosting.IHostedService'." Die Lösung ist ein Scope pro Nachricht:
using System.Threading.Channels;
public interface ITelemetryHandler
{
Task HandleAsync(InboundMessage message, CancellationToken ct);
}
public sealed class TelemetryConsumer(
Channel<InboundMessage> inbox,
IServiceScopeFactory scopes,
ILogger<TelemetryConsumer> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var msg in inbox.Reader.ReadAllAsync(stoppingToken))
{
try
{
await using var scope = scopes.CreateAsyncScope();
var handler = scope.ServiceProvider.GetRequiredService<ITelemetryHandler>();
await handler.HandleAsync(msg, stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
log.LogError(ex, "Failed to handle message on {Topic}", msg.Topic);
}
}
}
}
public sealed class TelemetryHandler(ILogger<TelemetryHandler> log) : ITelemetryHandler
{
public Task HandleAsync(InboundMessage message, CancellationToken ct)
{
log.LogInformation("{Topic}: {Payload}", message.Topic, message.Payload);
return Task.CompletedTask; // in einer Datenbank speichern, einen anderen Dienst aufrufen, ...
}
}Das try/catch um jede Nachricht ist essenziell: Eine unbehandelte Exception in ExecuteAsync stoppt den Hosted Service – und seit .NET 6 standardmäßig den ganzen Host. Ein fehlerhafter Payload soll einen Logeintrag erzeugen, keinen Ausfall. Brauchst du Parallelität, starte mehrere Leseschleifen auf demselben Channel und setze SingleReader = false; die Reihenfolge pro Topic ist dann allerdings nicht mehr garantiert.
Health Checks
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MQTTnet;
public sealed class MqttHealthCheck(IMqttClient client) : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
=> Task.FromResult(client.IsConnected
? HealthCheckResult.Healthy("Connected to MQTT broker")
: new HealthCheckResult(context.Registration.FailureStatus, "Not connected to MQTT broker"));
}Mit context.Registration.FailureStatus entscheidet die Registrierung, wie schwer eine verlorene Verbindung wiegt; hier wird Degraded gemeldet. Der Check gehört nur zum Readiness-Endpoint, während /healthz/live gar keine Checks ausführt. Diese Trennung ist Absicht: Fällt der Broker aus, macht ein Orchestrator, der bei fehlgeschlagener Liveness-Probe jeden Pod neu startet, aus einem Problem einen Reconnect-Sturm. Readiness nimmt die Instanz aus dem Load Balancing, während die Reconnect-Schleife ihre Arbeit macht.
Aus einem API-Endpoint veröffentlichen
Der Singleton-Client lässt sich überall injizieren. MQTTnet serialisiert das Schreiben von Paketen intern, daher können parallele Requests über dieselbe Verbindung veröffentlichen:
static void MapCommandEndpoint(WebApplication app) =>
app.MapPost("/devices/{id}/commands", async (string id, JsonElement body, IMqttClient mqtt, CancellationToken ct) =>
{
if (!Regex.IsMatch(id, "^[A-Za-z0-9_-]{1,64}$")) return Results.BadRequest("invalid device id");
if (!mqtt.IsConnected) return Results.Problem("MQTT broker unavailable", statusCode: 503);
var message = new MqttApplicationMessageBuilder()
.WithTopic($"testmqtt/aspnet/devices/{id}/commands")
.WithPayload(body.GetRawText())
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
.WithMessageExpiryInterval(60)
.Build();
var result = await mqtt.PublishAsync(message, ct);
return result.ReasonCode is MqttClientPublishReasonCode.Success or MqttClientPublishReasonCode.NoMatchingSubscribers
? Results.Accepted()
: Results.Problem($"Broker rejected publish: {result.ReasonCode}", statusCode: 502);
});Die ID zu validieren ist keine Kosmetik: Ein Wert mit +, # oder / würde die Topic-Struktur verändern. Bei MQTT 5 liefert ein QoS-1-Publish auf ein Topic ohne Abonnenten NoMatchingSubscribers – das ist kein Fehler. Die Message Expiry verhindert, dass ein Gerät, das sich viel später wieder verbindet, den Befehl noch ausführt. Mit dem Topic Matcher prüfst du, ob deine Command-Topics zu den Abonnements der Geräte passen.
Mehrere Instanzen betreiben
Mit zwei Replicas und einer normalen Subscription liefert der Broker jede Nachricht an beide, und jede wird doppelt verarbeitet. Nutze in der Einstellung Topics eine Shared Subscription – $share/telemetry-workers/testmqtt/aspnet/telemetry/# –, dann verteilt der Broker die Nachrichten auf die Gruppe. Jede Replica braucht weiterhin ihre eigene Client-ID. Details und Broker-spezifische Eigenheiten findest du unter MQTT Shared Subscriptions.
Typische Fallstricke
- Beim Start auf die Verbindung warten. Verbindest du im Konstruktor oder blockierst
StartAsync, bis der Broker antwortet, startet die App nicht, solange der Broker unerreichbar ist. LassExecuteAsyncim Hintergrund verbinden; der Readiness-Check deckt die Lücke ab. - Nur nach dem ersten Connect abonnieren. Mit Clean Start verschwinden die Subscriptions bei jedem Reconnect. Abonniere in
ConnectedAsync. - Event-Handler mehrfach anhängen. Werden Events in Code angehängt, der bei jedem Reconnect läuft, wird jede Nachricht einmal pro Anhängen verarbeitet. Häng sie genau einmal an.
IMqttClientals Scoped oder Transient registrieren. Jeder Request würde eine eigene Broker-Verbindung öffnen – langsam und schnell an den Verbindungslimits. Bleib beim Singleton.
Den Dienst testen
dotnet runund im Log nachMQTT connectedund einer ZeileSUBACK ... GrantedQoS1suchen.curl -i http://localhost:5000/healthz/readysollte200 Healthyliefern (nimm den Port, den deine App ausgibt).- Im Online-MQTT-Client
{"celsius":21.5}antestmqtt/aspnet/telemetry/dev1senden; der Handler loggt die Nachricht. - Im Browser
testmqtt/aspnet/devices/#abonnieren, danncurl -X POST http://localhost:5000/devices/dev1/commands -H 'Content-Type: application/json' -d '{"led":"on"}'ausführen und zusehen, wie der Befehl ankommt. - Den Broker-Port kurz blockieren (oder das WLAN trennen): Readiness wechselt auf
Degradedund nach dem Reconnect zurück aufHealthy.
Test-Telemetrie an deinen ASP.NET-Core-Dienst senden
Scheitert die Verbindung mit einer Exception, die du nicht kennst, ordnet MQTTnet-Fehler die häufigen Meldungen ihren Ursachen zu.
Mit einem Singleton-Client, einem Verbindungsdienst, einem begrenzten Channel und Scoped Handlern wird MQTT zu einem ganz normalen Eingang deiner ASP.NET-Core-App: über Options konfiguriert, per Health Checks beobachtbar, sauber mit dem Host heruntergefahren und bereit zur Skalierung mit Shared Subscriptions.
Häufig gestellte Fragen
Warum erhalte ich 'Cannot consume scoped service from singleton' in meinem MQTT-Hosted-Service?
Hosted Services sind Singletons und können daher keine Scoped Services wie einen DbContext im Konstruktor entgegennehmen. Injiziere stattdessen IServiceScopeFactory und erzeuge für jede verarbeitete Nachricht (oder jeden Batch) einen Scope.
Soll der MQTT-Health-Check die Liveness-Probe fehlschlagen lassen?
Meist nicht. Ist der Broker down, behebt ein Neustart deiner App das Problem nicht und kann einen Reconnect-Sturm auslösen. Melde die MQTT-Verbindung über einen Readiness- oder Status-Endpoint und nutze Liveness nur für Probleme, die ein Neustart tatsächlich löst.
Wie betreibe ich mehrere Instanzen, ohne jede Nachricht doppelt zu verarbeiten?
Gib jeder Instanz eine eindeutige Client-ID und abonniere über eine Shared Subscription wie $share/telemetry-workers/testmqtt/aspnet/telemetry/#. Der Broker verteilt die Nachrichten dann auf die Gruppe, statt jeder Instanz eine Kopie zu schicken.


