Modulare Code-Struktur in C#: Module, Handler, Trigger

Monolithische Klassen sind ein Antipattern. Mit klaren, spezialisierten Modulen schreibst du Code, der wartbar ist und sich anfühlt wie LEGO — verschiedene Komponenten, die zusammenpassen.

Das Monolith-Problem

Ein häufiges Muster: Eine Roboterzelle-Klasse mit 5000+ Zeilen Code.

class RobotCell
{
  // IO-Logik
  public void HandleInput() { }
  public void SetOutput() { }
  
  // Sequenzen
  public void PickSequence() { }
  public void PlaceSequence() { }
  
  // Fehler
  public void HandleError() { }
  ...
  // >> 5000 Zeilen auf bei 50+ Methoden
}
        

Das ist unmöglich zu testen. Unmöglich zu debuggen. Ein Fehler im Pick-Sequenz kann sich durch E/A-Logik verlaufen. Eine neue Sequenz zusammenzukopieren? Du riskierst, etwas zu überschreiben.

Das Modul-Ansatz

Teile die Zelle in spezialisierte Module ein — jedes mit einer Aufgabe (Single Responsibility Principle).

Module-Typen

1. Data Module

Speichert Status und Daten. Beispiel:

public class JobData
{
  public string JobID { get; set; }
  public int PartCount { get; set; }
  public DateTime StartTime { get; set; }
}
        

Einfach. Dumb. Das ist gut. Keine Logik, nur Daten.

2. IO Module

Abstrahiert alle Ein- und Ausgaben (Sensoren, Ventile, etc.).

public interface IIOModule
{
  bool IsDoorClosed { get; }
  bool IsGreiferBusy { get; }
  void SetVacuum(bool active);
  void SetRGBLight(Color color);
}
        

Mit einer Schnittstelle: Einfach austauschbar. Real Hardware? Eine Implementation. Simulator? Andere. Tests? Mock. All das ohne Zellcode zu ändern.

3. Handler Module

Führen Operationen durch. Beispiel:

public class PickHandler
{
  private readonly IIOModule _io;
  private readonly ILogger _logger;

  public async Task Execute(PickData data)
  {
    _logger.Info("Pick starting...");
    
    _io.SetVacuum(true);
    await Task.Delay(500);
    
    if (!_io.IsGreiferBusy)
    {
      throw new PickException("Greifer konnte nicht greifen");
    }
    
    _logger.Info("Pick completed");
  }
}
        

Jeder Handler ist isoliert, testbar, wiederverwendbar.

4. Trigger Module

Reagiert auf Events (Sensoren, externe Commands).

public class SafetyTrigger
{
  public event EventHandler DoorOpenedEvent;

  public void Monitor(IIOModule io)
  {
    if (!io.IsDoorClosed)
    {
      DoorOpenedEvent?.Invoke(this, EventArgs.Empty);
    }
  }
}
        

Trigger sind Events, die Handler oder andere Module anstupsen können.

5. Orchestrator/Coordinator

The "big picture" Klasse, die Handler und Trigger zusammenbringt:

public class CellOrchestrator
{
  private readonly PickHandler _pickHandler;
  private readonly PlaceHandler _placeHandler;
  private readonly SafetyTrigger _safetyTrigger;

  public CellOrchestrator(PickHandler ph, PlaceHandler pl, SafetyTrigger st)
  {
    _pickHandler = ph;
    _placeHandler = pl;
    _safetyTrigger = st;
    
    _safetyTrigger.DoorOpenedEvent += (s, e) => Stop();
  }

  public async Task RunSequence(JobData job)
  {
    await _pickHandler.Execute(job.PickData);
    await _placeHandler.Execute(job.PlaceData);
  }
}
        

Vorteile dieser Struktur

Testbarkeit

Mock eine IO-Schnittstelle, test einen Handler isoliert:

[Test]
public async Task PickHandler_WithoutGreifer_ThrowsException()
{
  var mockIO = new Mock<IIOModule>();
  mockIO.Setup(x => x.IsGreiferBusy).Returns(false);
  
  var handler = new PickHandler(mockIO.Object);
  
  Assert.ThrowsAsync<PickException>(() => handler.Execute(data));
}
        

Keine Real Hardware nötig. Kein 5000-Zeilen-Monolith zu debuggen.

Wiederverwendbarkeit

Ein PickHandler funktioniert in Zelle A und Zelle B. Musst nur die IO-Implementierung tauschen.

Skalierbarkeit

Neue Sequenz? Neuer Handler. Neue Safety-Regel? Neuer Trigger. Bestehendes Code bleibt unberührt.

Lesbarkeit

Der Orchestrator-Code sagt klar: "Pick → Place → done". Das ist wartbar und nachvollziehbar.

Dependency Injection

Mit DI (z.B. Microsoft.Extensions.DependencyInjection) wird die Zusammensetzung elegant:

var services = new ServiceCollection();
services.AddSingleton<IIOModule, RealIOModule>();
services.AddSingleton<PickHandler>();
services.AddSingleton<PlaceHandler>();
services.AddSingleton<SafetyTrigger>();
services.AddSingleton<CellOrchestrator>();

var container = services.BuildServiceProvider();
var cell = container.GetService<CellOrchestrator>();
await cell.RunSequence(jobData);
        

Best Practice: Interface Segregation

Nutze viele kleine Interfaces, nicht ein großes:

// FALSCH
public interface IEverything
{
  bool IsDoor { get; } // Safety
  void SetVacuum(); // Greifer
  Color GetSensorColor(); // Vision
}

// RICHTIG
public interface ISafetyIO { bool IsDoor { get; } }
public interface IGriperIO { void SetVacuum(); }
public interface IVisionIO { Color GetSensorColor(); }
        

So kann ein Handler nur das abhängen, was er wirklich braucht.

Error Handling in Modulen

Jeder Handler sollte unterschiedliche Fehler transparent machen:

public class PickException : Exception { }
public class GreiferTimeoutException : PickException { }
public class GreiferBlockedException : PickException { }
        

Der Orchestrator kann dann spezifisch reagieren:

catch (GreiferBlockedException ex)
{
  _logger.Warn("Greifer blocked, retrying...");
  await Task.Delay(1000);
  await _pickHandler.Execute(data);
}
        

Fazit

Modularer Code ist nicht kompliziert — er ist einfach richtig strukturiert.

Mit Handler, Trigger, IO-Layer und Orchestrator entstehen Systeme, die:

  • Einfach zu verstehen sind
  • Einfach zu testen sind
  • Einfach zu warten sind
  • Einfach zu erweitern sind

Das ist nicht extra Aufwand — es ist bessere Struktur von Anfang an.