Anzeige
Anzeige
Anzeige
Anzeige
Lesedauer 11 Min.

SignalRC baut auf DRY

DRY ist eines dieser Prinzipien, die jeder für selbstverständlich hält, die aber trotzdem oft nicht konsequent umgesetzt werden. In SignalRC ist das Shared-Projekt von Beginn an dabei.
©  Sofija De Mitri, Patrizio De Mitri, Event Wave

Heute widmen wir uns einem Prinzip, von dem vermutlich jeder Entwickler schon mal gehört hat, dem aber in der Praxis trotzdem nicht immer die verdiente Aufmerksamkeit geschenkt wird: DRY. Don’t Repeat Yourself. Schreib es einmal, schreib es richtig und benutz es überall. 

Das klingt simpel und ist es im Grunde auch. Doch die konsequente Umsetzung über ein gesamtes Projekt wie das unsere hinweg, das wir als DDC-Truck im Artikel „SignalRC – in Echtzeit ans Steuer“ [1] ausführlich vorgestellt haben, erfordert Disziplin und ein paar gute Entscheidungen am Anfang. 

SignalRC hat dafür ein Schema entwickelt, das sich durch die gesamte Architektur zieht. Das sehen wir uns jetzt an.

Geschichte

Das Prinzip stammt aus dem Standardwerk „The Pragmatic Programmer“ und besagt im Kern: Jedes Stück Wissen sollte im System genau eine einzige, eindeutige Repräsentation haben. 

Warum das wichtig so ist, merkt man meistens erst, wenn man großzügig dagegen verstoßen hat. Doppelter Code bedeutet doppelte Pflege. Ändert sich eine Logik, muss man sich erinnern, wo sie überall liegt, und vor allem, wohin sie kopiert wurde. 

Wir sind Entwickler. Wir vergessen das. Garantiert. Oder wir vergessen, es unserem Nachfolger zu erzählen.

In SignalRC wurde es konsequent durchgezogen, weil wir andauernd zwischen dem Server, dem Onboard-Client und dem Web-Client im Browser springen. Die sprechen alle miteinander, sie benutzen alle die gleichen Datentypen, die gleichen Pfade, die gleichen Magic Strings und die gleichen Logiken. Wenn da auch nur eine Seite aus der Reihe tanzt, fällt die ganze Kommunikation auseinander.

Das Shared-Projekt – eine Quelle der Wahrheit

Der wichtigste Baustein für die Einhaltung von DRY in SignalRC ist das Shared-Projekt. Das ist eine simple .NET Class Library, die von sowohl Server als auch Onboard-Client referenziert wird.

Hier landen alle Typen, die beide Seiten brauchen. Datenmodelle wie CarConfiguration und JanusConfiguration, DTOs wie SshAuthenticationRequest, Enums, Hilfsklassen. Alles, was geteilt werden muss, gehört hierhin. Alles, was nicht geteilt werden muss, bleibt draußen. 

Die Regel ist bestechend einfach: Wenn ein Typ in mehr als einem Projekt vorkommt, wandert er ins Shared-Projekt. 

Hub-Interfaces – alles mit dem gleichen Vertrag

Das Herzstück der SignalR-Kommunikation in SignalRC sind die Hub-Interfaces. Diese leben alle im Shared-Projekt und definieren exakt, welche Methoden Server und Client haben.

Es gibt zwei Richtungen: Die Server-Interfaces in Shared/Hubs/ beschreiben, was der Client beim Server aufrufen kann. Die Client-Interfaces in Shared/HubClients/ beschreiben, was der Server beim Client aufrufen kann.

 

// Was der Server anbietet (Shared/Hubs/)
 public interface ICarControlServer
 {
     Task<string?> AquireCarControl(int carId, SshAuthenticationRequest authRequest);
     Task ReleaseCarControl(int carId, string sessionId);
     Task UpdateChannel(int carId, string sessionId, int channelId, decimal value);
     Task RegisterForControl(int carId);
     Task<string?> GetChallenge(int carId);
     Task ExecuteBashCommand(int carId, string sessionId, string command);
     Task SendBashOutput(int carId, string output, bool isError);
 }
 
 // Was der Client anbietet (Shared/HubClients/)
 public interface ICarControlClient
 {
     Task<string> AquireCarControl(SshAuthenticationRequest authRequest);
     Task ReleaseCarControl(string sessionId);
     Task UpdateChannel(string sessionId, string channelId, decimal value);
     Task<string?> GetChallenge();
     Task ExecuteBashCommand(string sessionId, string command);
 }

 

Der Server implementiert das Server-Interface und typisiert seinen Hub gegen das Client-Interface:

 

public class CarControlHub : Hub<ICarControlClient>, ICarControlServer

 

Der Hub<ICarControlClient> sorgt dafür, dass Clients.Client(id) ein stark typisiertes Objekt zurückgibt. Kein SendAsync("MethodeName", parameter) mit Magic Strings. 

Der Onboard-Client macht es genau andersherum. Er implementiert das Client-Interface und erzeugt sich einen typisierten Proxy für das Server-Interface:

 

public class ControlService : ICarControlClient, IHubConnectionObserver
 {
     // ...
     public async Task ConnectToServer()
     {
         _connection = ServerConnectionService.ConnectToHub(HubPaths.CarControlHub);
         _connection.Register<ICarControlClient>(this);  // "Ich bin der Client"
         _server = _connection.CreateHubProxy<ICarControlServer>();  // "So spreche ich mit dem Server"
         await _server.RegisterForControl(carId.Value);  // Typsicher!
     }
 }

 

Die Interfaces werden einmal im Shared-Projekt definiert und bestimmen automatisch, was auf Server- und Clientseite implementiert werden muss beziehungsweise aufgerufen werden kann. Ändert sich eine Signatur, knallt es sofort beim Kompilieren auf beiden Seiten. Nicht erst zur Laufzeit.

Dieses Muster zieht sich durch alle Hubs: CarControlHub, TelemetryHub, CarVideoHub, CarConnectionHub, UserChannelHub. Jeder Hub hat sein Server-Interface sowie sein Client-Interface, und beide Seiten implementieren ihren jeweiligen Teil.

TypedSignalR – der Compiler als Qualitätssicherung

An dieser Stelle wird auch der Grund für den Einsatz von TypedSignalR.Client sichtbar. Das NuGet-Paket ist ein „Source Generator“, der aus den Shared Interfaces zur Compile-Zeit typisierte Proxy-Klassen erzeugt. Ohne TypedSignalR müsste der Onboard-Client seine Hub-Aufrufe so vornehmen:

 

// So bitte nicht
 await _connection.InvokeAsync("RegisterForControl", carId);

 

Magic Strings. Kein IntelliSense. Keine Compile-Time-Checks. Die Quelle der Wahrheit ist dann dein Gedächtnis beim Implementieren der Methode. Ändert sich der Methodenname oder ein Parameter auf dem Server, merkt man das erst, wenn der Aufruf zur Laufzeit fehlschlägt. Dann steht dein Auto aber im Zweifelsfall gerade mitten im Nirgendwo. Viel Spaß beim Hinlaufen.

Mit TypedSignalR und den geteilten Interfaces sieht das Ganze so aus:

 

// So bitte schon
 await _server.RegisterForControl(carId);

 

Das ist nicht nur schöner zu lesen, es ist auch sicherer. Die Methode hat eine klare Signatur, der Compiler prüft sie, und bei Änderungen macht die IDE dich sofort darauf aufmerksam. Die Kombination aus Shared Interfaces und TypedSignalR eliminiert also eine ganze Kategorie von Fehlerquellen. Man definiert den Vertrag einmal und bekommt die Implementierung quasi geschenkt.

HubPaths – Konstanten statt Copy and Paste

Auch hier werden sämtliche Magic Strings ein einziges Mal an nur einer einzigen Stelle definiert und danach überall genutzt. So findet man keine Strings direkt im Code, sondern nur Konstanten, die im Zweifel genauso heißen wie ihr Inhalt.

 

public class HubPaths
 {
     public const string CarConnectionHub = "/hubs/connection";
     public const string CarControlHub = "/hubs/control";
     public const string TelemetryHub = "/hubs/telemetry";
     public const string CarUiHub = "/hubs/carui";
     public const string CarVideoHub = "/hubs/video";
     public const string UserChannelHub = "/hubs/userchannel";
 }

 

Der Server nutzt die Konstanten dann beim Registrieren:

 

app.MapHub<CarControlHub>(HubPaths.CarControlHub);
 app.MapHub<TelemetryHub>(HubPaths.TelemetryHub);

 

Der Onboard-Client verwendet dieselben Konstanten beim Verbinden:

 

_connection = ServerConnectionService.ConnectToHub(HubPaths.CarControlHub);

 

Wer kennt es? Kleiner Typo: /hubs/controll auf der einen Seite und /hubs/control auf der anderen. Der Aufwand, die Konstanten einzubauen, strebt gegen 0. Der Aufwand, einen solchen Fehler später beim Debuggen zu finden, dagegen nicht.

Vererbung – Control-Erweiterungen schreiben sich quasi selbst

DRY ist nicht nur bei der Kommunikation zu sehen. Auf dem Onboard-Client gibt es eine Hierarchie für die Steuerungstypen, die zeigen, wie mächtig DRY im objektorientierten Design ist. Oder ist es, wie mächtig Objektorientierung ist, um DRY zu erreichen? Das lasse ich mal offen für die Debatte.

Ganz oben steht die ControlTypeBase:

 

public abstract class ControlTypeBase : IControlType
 {
     public int? Address { get; set; }
     public string Name { get; set; } = string.Empty;
     public bool TestDisabled { get; set; } = false;
     public Dictionary<string, object> Options { get; set; } = new();
     public IModuleManager PinManager { get; set; }
 
     public virtual void Initialize() { }
     public async Task RunTestAsync()
     {
         if (!TestDisabled)
             await RunTestInternalAsync();
     }
     protected virtual Task RunTestInternalAsync() => Task.CompletedTask;
     public abstract void OnControlRecived(decimal newValue);
     public abstract void OnControlReleased();
 }

 

Sie definiert alles, was jeder Steuerungstyp braucht: Adresse, Name, Optionen, Pin-Manager. Der Test-Mechanismus ist auch schon drin, inklusive der Möglichkeit, ihn per Config zu deaktivieren.

Darauf baut die ServoControlBase auf:

 

public abstract class ServoControlBase : ControlTypeBase
 {
     IPwmModule _pinInstance;
 
     public override void Initialize()
     {
         _pinInstance = PinManager.GetModule<IPwmModule>(Address ?? 0);
         base.Initialize();
     }
 
     public override void OnControlRecived(decimal newValue)
     {
         _pinInstance.SetServoPosition((float)newValue);
     }
 
     protected override async Task RunTestInternalAsync()
     {
         const int DELAY = 1000;
         for (int i = 0; i < 5; i++)
         {
             OnControlRecived(-1); await Task.Delay(DELAY);
             OnControlRecived(1);  await Task.Delay(DELAY);
         }
         OnControlReleased();
     }
 
     public override void OnControlReleased() => OnControlRecived(0);
 }

 

Und jetzt der Clou, die konkreten Servo-Klassen:

 

[ControlType("Steering")]
 public class SteeringControl : ServoControlBase 
 { 
     public SteeringControl(ILogger<SteeringControl> logger) : base(logger) { }
     public override string ToString() => $"Steering@{Address}";
 }

 

Das wars. Ernsthaft. Die Lenkung ist kaum mehr als ein Klassenname mit einem Attribut. Die gesamte PWM-Initialisierung, die Positionsberechnung, der Test-Sweep, das Release-Verhalten: alles schon da. An einer Stelle. 

Dasselbe gilt für ThrottleControl, ServoControl und OnOffServoControl. Jede Klasse ist zwischen 5 und 15 Zeilen lang. Die eigentliche Logik steckt in den Basisklassen und wird nicht wiederholt.

Der gesamte Vererbungsbaum sieht so aus:

 

IControlType
└── ControlTypeBase (abstract)
    ├── ServoControlBase (abstract)
    │   ├── SteeringControl
    │   ├── ThrottleControl
    │   ├── ServoOnOffControl
    │   └── OnOffServoControl
    ├── PwmLight
    │   └── PwmBlinker
    ├── RotaryLight
    └── CustomBash

 

Was nicht passt, wird überschrieben. Was passt, wird geerbt.

AddAllTransient – Registrierung ohne Aufwand

Wenn man schon dabei ist, Wiederholungen zu vermeiden, warum nicht auch bei der Dependency Injection? Normalerweise registriert man jeden Typ einzeln:

 

services.AddTransient<SteeringControl>();
 services.AddTransient<ThrottleControl>();
 services.AddTransient<PwmLight>();
 // ... und so weiter für jeden neuen Typ

 

In SignalRC gibt es dafür eine einzige, simple Extension Method:

 

 

public static IServiceCollection AddAllTransient(this IServiceCollection serviceCollection, Type baseServiceType)
 {
     var assembly = baseServiceType.Assembly;
     var types = assembly.GetTypes()
         .Where(t => t.IsClass && !t.IsAbstract && baseServiceType.IsAssignableFrom(t));
 
     foreach (var type in types)
     {
         serviceCollection.AddTransient(baseServiceType, type);
         serviceCollection.AddTransient(type);
     }
     return serviceCollection;
 }

 

Der Aufruf ersetzt alle manuellen Registrierungen:

 

serviceCollection.AddAllTransient(typeof(ControlTypeBase));
 serviceCollection.AddAllTransient(typeof(IPwmModule));
 serviceCollection.AddAllTransient(typeof(IGpioModule));

 

Neue Klasse erstellen, von ControlTypeBase ableiten, Attribut drauf und fertig. Kein manuelles Eintragen in der DI-Konfiguration. Die Assembly wird gescannt und alles, was passt, wird registriert. 

Das eignet sich ganz hervorragend für Strategy-Patterns, die zur Genüge in SignalRC zu finden sind.

ChannelMap – Konfiguration statt Codeduplikation

Ein weiteres Beispiel auf einer ganz anderen Ebene ist die channelMap.json. Statt für jedes Fahrzeug eigenen Code zu schreiben, beschreibt eine JSON-Datei, was das Fahrzeug hat und was es kann.

 

{
   "controlChannels": {
     "steering":      { "controlType": "Steering", "pinManager": "Pca9685", "address": 15 },
     "throttle":      { "controlType": "Steering", "pinManager": "Pca9685", "address": 12 },
     "headLight":     { "controlType": "PwmLight", "pinManager": "Pca9685", "address": 7 },
     "leftTurnLight": { "controlType": "PwmBlinker", "pinManager": "Pca9685", "address": 9 }
   }
 }

 

Will man ein neues Fahrzeug aufsetzen, heißt es also nur: Config schreiben, deployen, fahren. 

Utilities – die kleinen Helfer

Es gibt auch einiges an Hilfsklassen, damit kein Code verdoppelt werden muss. Um einige zu nennen:

HashUtility liegt im Shared-Projekt und wird überall verwendet, wo ein SHA-256-Hash gebraucht wird: bei der ChannelMap, bei der SSH-Key-Verifikation, beim Dateitransfer.

Auf Serverseite gibt es die HubClientsExtension, die Gruppen-Operationen vereinfacht:

 

public static T Car<T>(this IHubClients<T> clients, long id) 
     => clients.Group(CarGroup(id));

 

Statt überall Clients.Group($"car:{carId}") zu schreiben, reicht ein Clients.Car(carId). Das Format des Gruppennamens wird an genau einer Stelle definiert. Ändert sich das Schema, ändert man eine Methode. Vor allem liest sich der Code, nur durch die zwei Zeilen Hilfsklasse, gleich besser.

Client Models

Das Shared-Projekt wird von Server und Onboard-Client referenziert, aber nicht vom Web-Client. Der Web-Client ist eine Next.js-Anwendung in TypeScript. Die spricht auch mit denselben Hubs, aber die TypeScript-Interfaces müssen separat generiert werden. 

Der Punkt hier ist: Die Modelle werden generiert. Die eine Wahrheit liegt immer noch in der C#-Model-Klasse.

Braucht man das mit AI noch, oder kann das weg?

Berechtigte Frage. Wir leben mittlerweile in einer Welt, in der eine AI auf Knopfdruck Code generiert. Warum sich also die Mühe machen, wenn die AI den duplizierten Code genauso schnell nochmal schreiben kann?

Gerade deshalb. AI macht es unfassbar einfach, Code zu erzeugen. Und genau das ist das Problem. Das Erstellen von Code ist keine Hürde mehr. Dadurch wird hemmungslos generiert. Drei Dateien mit fast identischer Logik? Kein Problem, oder? 

Wer pflegt den Code später? Richtig, du und deine Kollegen. 

Jetzt könnte man sagen: Wir lassen Änderungen auch von der AI machen. Dann kommt ein ganz anderes Problem zum Tragen: Je mehr duplizierter Code im Projekt liegt, desto mehr Kontext muss die AI beim nächsten Prompt verstehen. Große Projekte mit viel Wiederholung sind auch für AIs schwerer zu navigieren. Ein sauber strukturiertes Projekt mit klaren Abstraktionen ist nicht nur für Menschen lesbarer, sondern auch für die AI. 

Sowohl Entwickler als auch AI finden den relevanten Code schneller, sie verstehen die Hierarchie besser und sie generieren beziehungsweise schreiben konsistenteren Code, weil die bestehende Struktur den Weg vorgibt. Es ist also auch durchaus hilfreich, den AI-Assistenten darauf hinzuweisen, dass man keine doppelten Codes haben möchte.

Fazit

DRY ist eines dieser Prinzipien, die jeder für selbstverständlich hält, die aber trotzdem nicht konsequent umgesetzt werden. In SignalRC war es von Anfang an keine Option, es nicht zu tun. Das Shared-Projekt ist seit Beginn an dabei. Schnittstellen, Hilfsmethoden und Konstanten wurden auch beim PoC schon durchgezogen.

Das Schöne daran: Es macht nicht nur den Code sauberer, es macht auch die Weiterentwicklung schneller: 

  • Neuer Hub? Interface rein, beide Seiten implementieren, fertig. 
  • Neuer Steuerungstyp? Eine Klasse schreiben, Attribut drauf, deployen. 
  • Neues Fahrzeug? Config schreiben, fahren. 

 

Mit DRY muss man immer nur eine Stelle anfassen. Develop. Reuse. Yay!

Praxisbeispiel

Schaut auch gerne mal in das konkrete Projekt auf GitHub rein.

 

[1] Georg Poweleit, SignalRC – in Echtzeit ans Steuer, dotnetpro 10-11/2025, Seite 46 ff.

Neueste Beiträge

Middleware, Datenbank und Testing im Alltag - Nest.js für .NET-Entwickler, Teil 4
Nest.js in der Praxis: Die Bausteine, die eine Nest.js-Anwendung produktionsreif machen.
6 Minuten
11. Mär 2026
Topics als Kernbausteine eines KI-Agenten - Low Code/No Code und KI mit Copilot Studio, Teil 1
Topics (Themen) bilden in Copilot Studio die zentralen Bausteine für die Steuerung von Dialogabläufen. Sie ermöglichen eine modulare und wartbare Struktur des KI-Agenten, indem sie fachlich klar abgegrenzte Gesprächslogiken kapseln.
6 Minuten
9. Mär 2026
00:00
Accessible Web: Weil "geht so" keine Antwort ist - DWX hakt nach
Barrierefreiheit ist kein Nachgedanke – sie ist Qualitätsmerkmal. Interview mit Maria Korneeva.
10. Mär 2026

Das könnte Dich auch interessieren

Elektronische Schaltkreise im Browser simulieren - Simulation
Statt mit Steckfeld oder Lötkolben kann man auf dieser Website Schaltungen per Drag and Drop zusammenstellen und deren Verhalten testen.
2 Minuten
26. Jul 2018
Batterie-Management mit SignalRC - Der DDC-Truck, Teil 4
Das Batterie-Management-System (BMS) von RC-Modellen benötigt verlässliche Telemetrie.
6 Minuten
12. Feb 2026
Nest.js: Warum sich der Blick über den Tellerrand für .NET-Entwickler lohnt - Nest.js für .NET-Entwickler, Teil 1
In modernen Softwareprojekten steht das C#-Backend längst neben einem Frontend, das in TypeScript lebt. Zwei Sprachen, zwei Ökosysteme, zwei Denkweisen. Was, wenn das Backend dieselbe Sprache sprechen könnte – ohne auf Enterprise-Patterns zu verzichten? Nest.js zeigt, dass genau das geht.
6 Minuten
18. Feb 2026
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige