Skip to content

Controllers and Routing

thnak edited this page Jun 6, 2026 · 2 revisions

Controllers and Routing

Overview

Controllers are plain C# classes decorated with [MqttController]. Each public method decorated with [MqttTopic] becomes a topic handler. The source generator scans these at build time and emits a DFA-based routing service — there is no reflection or dictionary lookup at runtime.


[MqttController]

[MqttController]                      // no prefix
public class SensorsController { }

[MqttController("devices")]           // all routes prefixed with "devices/"
public class DeviceController { }

prefix is prepended to every [MqttTopic] template in the class, separated by /.


[MqttTopic]

[MqttTopic("sensors/+/temperature")]  // single-level wildcard
[MqttTopic("logs/#")]                 // multi-level wildcard (must be last segment)
[MqttTopic("system/status")]          // exact match

MQTT wildcard rules apply:

  • + matches exactly one topic level.
  • # matches the rest of the topic; must be the last segment.

The final template is {prefix}/{template} with empty parts stripped. A controller with prefix = "devices" and template "+/telemetry" produces "devices/+/telemetry".


Parameter Binding

The generator resolves method parameters in this order:

Parameter type Resolution
Decorated with [FromMqttTopic(n)] Extracted from the n-th + wildcard segment of the topic
CancellationToken Forwarded from MQTTnet
byte[] Raw payload bytes
InterceptingPublishEventArgs Full MQTTnet event args
Implements IMqttPayloadParser<T> Custom binary parser (see below)
Any other type JSON-deserialized from the payload

Topic segment binding — [FromMqttTopic]

[MqttTopic("devices/+/sensors/+/data")]
public Task OnData(
    [FromMqttTopic(1)] string deviceId,   // first '+'
    [FromMqttTopic(2)] string sensorId,   // second '+'
    SensorReading payload)
{ }

For enum parameters the segment is parsed with Enum.TryParse (case-insensitive). For all other types Convert.ChangeType is used.

Tip: Named parameters work the same way — the name in the method signature does not need to match the topic segment; only the [FromMqttTopic(index)] matters.

Named-segment shorthand

If your topic template uses {name} style segments instead of bare +, declare the parameter with the matching name and no attribute:

[MqttController("sensors")]
public class SensorsController
{
    [MqttTopic("{deviceId}/temperature")]
    public async Task OnTemperature(string deviceId, TemperaturePayload payload) { }
}

The generator maps the parameter by name to the corresponding + position in the compiled template (sensors/+/temperature).


Custom Payload Parsers

For non-JSON payloads implement IMqttPayloadParser<T> on the payload type and tag it with [MqttPayloadContentType("...")]:

[MqttPayloadContentType("application/x-messagepack")]
public class MessagePackSensorReading : IMqttPayloadParser<MessagePackSensorReading>
{
    public double Value { get; set; }
    public string Unit { get; set; } = "";

    public MessagePackSensorReading Parse(ReadOnlySpan<byte> payload)
        => MessagePackSerializer.Deserialize<MessagePackSensorReading>(payload.ToArray());
}

Use it as a parameter type:

[MqttTopic("sensors/+/packed")]
public Task OnPacked([FromMqttTopic(1)] string id, MessagePackSensorReading data) { }

The generator registers the parser automatically for the declared content-type. If the incoming message has a matching ContentType property the custom parser is used; otherwise the framework falls back to JSON.


Request-Response Pattern

A handler can return Task<TResponse>. If the incoming message has a ResponseTopic, the framework serializes the return value as JSON and publishes it back:

[MqttTopic("queries/+/status")]
public async Task<DeviceStatus> GetStatus([FromMqttTopic(1)] string deviceId)
{
    return await _repository.GetStatusAsync(deviceId);
}

The client sets ResponseTopic on its publish; the broker sends the JSON response to that topic automatically.


Controller DI

Controllers are registered as scoped services. Inject any scoped or singleton dependency via the constructor:

[MqttController("telemetry")]
public class TelemetryController(IDataRepository repo, ILogger<TelemetryController> log)
{
    [MqttTopic("+/readings")]
    public async Task OnReading([FromMqttTopic(1)] string deviceId, SensorData data)
    {
        await repo.SaveAsync(deviceId, data);
        log.LogInformation("Saved reading from {DeviceId}", deviceId);
    }
}

Compile-Time Route Diagnostics

The source generator validates all topic templates at build time and reports conflicts as Roslyn diagnostics:

ID Severity Condition
MQTT001 Error Two handler methods share the exact same full topic template
MQTT002 Warning Two topic patterns overlap — a concrete topic can match both

MQTT001 — duplicate route

error MQTT001: Topic 'devices/status' is already handled by 'DeviceController.OnStatus'.
               Duplicate handler: 'AdminController.OnStatus'.

Occurs when two methods (in the same or different controllers) produce the same final template after prefix expansion.

MQTT002 — ambiguous overlap

warning MQTT002: Topic pattern 'devices/sensor/data' in 'DeviceController.OnSensorData'
                 overlaps with 'devices/+/data' in 'TelemetryController.OnData':
                 both match the same messages.

Occurs when one pattern (e.g. devices/sensor/data) is a subset of another wildcard pattern (e.g. devices/+/data), or when a # wildcard subsumes a more specific pattern. Both handlers would match the same incoming message — the first registered route wins at runtime.

Resolve by making patterns non-overlapping, or by moving the shared logic into a single handler.


Generated Code

At build time the generator emits three files into <YourAssembly>.Mqtt.Generated:

File Contents
MqttControllerRegistration.g.cs GeneratedMqttControllerRegistration : IMqttControllerRegistration
{Controller}Dispatcher.g.cs Per-controller dispatcher and I{Controller}Dispatcher interface
GeneratedMqttRoutingService.g.cs DFA topic matcher + RouteMatchCache (128-entry LRU)

Reference GeneratedMqttControllerRegistration in WithControllers<>(). Add a using for the generated namespace if needed:

using MyApp.Mqtt.Generated;

builder.Services
    .AddMqttServer(configuration)
    .WithControllers<GeneratedMqttControllerRegistration>();

Clone this wiki locally