Net-Base Magazin

08.05.2026

Delphi RTTI für Mapping ohne Magie: Attribute-basiert, debugbar und legacy-tauglich

Ein pragmatisches Mapping-Muster mit Delphi RTTI: Attribute statt Konventionen, kontrollierte Konvertierungen, klare Fehlertexte und ein Debug-Modus, der im Betrieb wirklich hilft. Mit Source-Schnipsel für Dataset- oder Record-zu-Objekt-Mapping ohne versteckte Magie.

08.05.2026

Wer gewachsene Business-Software in Delphi betreibt, kennt das Spannungsfeld: Einerseits will man strukturierte Domänenobjekte und klare Schichten, andererseits gibt es Datasets, Variants, CSV-Importe, Schnittstellenpayloads oder eine REST-API, die „irgendwie“ auf Objekte gemappt werden müssen. Genau hier landet man schnell bei Delphi RTTI für Mapping ohne Magie: also Mapping per Reflection (RTTI = Run-Time Type Information, Typinformationen zur Laufzeit), aber so, dass es nachvollziehbar bleibt, gut debugbar ist und nicht heimlich an Konventionen oder Namensspielchen hängt.

Der Kernpunkt: „Magie“ entsteht meist nicht durch RTTI an sich, sondern durch implizite Regeln. Wenn Mapping-Regeln dagegen explizit in Attributen stehen, Konvertierungen zentralisiert sind und Fehler eine klare Ursache benennen, wird RTTI zu einem Werkzeug statt zu einer Überraschung.

Warum RTTI-Mapping in Delphi oft kippt

RTTI-basiertes Mapping scheitert in realen Systemen selten an der Idee, sondern an Randbedingungen:

  • Legacy-Datenformen: Null/Empty/0 sind nicht sauber getrennt, Feldtypen wechseln, Strings enthalten „N/A“.
  • Schleichende Konventionen: „Feld heißt wie Property“ funktioniert bis zum ersten Alias, Join oder refaktorierten Property-Namen.
  • Schwer zu debuggen: Wenn ein Mapper „einfach nichts setzt“, fehlt später die Ursache. Im Betrieb ist das Gift.
  • Performance-Mythen: RTTI wird pauschal als „langsam“ abgestempelt, obwohl meist fehlendes Caching das Problem ist.

Ein tragfähiger Ansatz sollte deshalb (1) explizite Mapping-Metadaten haben, (2) Konvertierung und Null-Semantik klar behandeln, (3) Fehler und Debug-Ausgaben liefern und (4) RTTI-Infos cachen.

Delphi RTTI für Mapping ohne Magie: Designprinzipien

Das folgende Muster ist bewusst „langweilig“ im besten Sinn: Regeln sind sichtbar, Nebenwirkungen begrenzt, und man kann es schrittweise in bestehende Module ziehen.

  • Attribute statt Namenskonvention: Property bekommt ein Attribut, das die Quellspalte benennt.
  • Opt-in: Nur markierte Properties werden gesetzt. Keine Überraschungen durch „alle publizierten Properties“.
  • Konvertierung an einer Stelle: Variant/String/Integer/Boolean/Enum/Nullable werden zentral gemappt.
  • Debug-Mode: Optional wird protokolliert, welche Felder gesetzt/übersprungen wurden – mit Grund.
  • RTTI-Caching: Die teuersten Teile (Propertyliste, Attributeauswertung) werden pro Typ vorbereitet.

Source-Schnipsel: Attribut-Mapping mit RTTI, Caching und Debug

Das Snippet bildet eine Zeile (z. B. aus BDE-Ablosung mit nativer Anbindung via TDataSet) auf ein Objekt ab. Statt den Mapper fest an TField zu koppeln, verwenden wir eine kleine Reader-Schnittstelle. Das ist in der Praxis wertvoll, weil Sie später dieselbe Logik auch für JSON, INI, CSV oder API-Responses verwenden können.

Delphi
unit RttiMapping;

interface

uses
  System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
  System.Variants;

type
  // Explizites Mapping: Property <- Quellname
  MapFromAttribute = class(TCustomAttribute)
  private
    FName: string;
  public
    constructor Create(const AName: string);
    property Name: string read FName;
  end;

  // Kleine Abstraktion: Wert liefern + Existenz/NULL unterscheiden
  IValueReader = interface
    ['{7D1E5864-7D3A-4D30-BD1C-0A94F7E6C0EF}']
    function HasValue(const AName: string): Boolean;
    function IsNull(const AName: string): Boolean;
    function GetValue(const AName: string): Variant;
  end;

  TRttiMapOptions = set of (moDebug, moIgnoreMissing, moIgnoreNull);

  ERttiMappingError = class(Exception);

  TRttiMapper = class
  private
    type
      TPropMap = record
        Prop: TRttiProperty;
        SourceName: string;
      end;
      TTypeCache = class
        Props: TArray<TPropMap>;
      end;
  private
    class var FCache: TObjectDictionary<PTypeInfo, TTypeCache>;
    class var FCacheLock: TObject;

    class function GetOrBuildCache(ATypeInfo: PTypeInfo): TTypeCache; static;
    class function FindMapFromAttr(const AProp: TRttiProperty): string; static;
    class procedure SetPropertyValue(const AInstance: TObject; const AProp: TRttiProperty;
      const AValue: Variant); static;
    class function VariantToBoolean(const V: Variant): Boolean; static;
    class function VariantToEnumOrdinal(AEnumType: TRttiType; const V: Variant): Integer; static;
  public
    class constructor Create;
    class destructor Destroy;

    class procedure MapToObject(const AReader: IValueReader; const ATarget: TObject;
      const AOptions: TRttiMapOptions = [moIgnoreMissing]); static;
  end;

implementation

{ MapFromAttribute }

constructor MapFromAttribute.Create(const AName: string);
begin
  inherited Create;
  FName := AName;
end;

{ TRttiMapper }

class constructor TRttiMapper.Create;
begin
  FCache := TObjectDictionary<PTypeInfo, TTypeCache>.Create([doOwnsValues]);
  FCacheLock := TObject.Create;
end;

class destructor TRttiMapper.Destroy;
begin
  FCache.Free;
  FCacheLock.Free;
end;

class function TRttiMapper.FindMapFromAttr(const AProp: TRttiProperty): string;
var
  Attr: TCustomAttribute;
begin
  Result := '';
  for Attr in AProp.GetAttributes do
    if Attr is MapFromAttribute then
      Exit(MapFromAttribute(Attr).Name);
end;

class function TRttiMapper.GetOrBuildCache(ATypeInfo: PTypeInfo): TTypeCache;
var
  Ctx: TRttiContext;
  RType: TRttiType;
  P: TRttiProperty;
  L: TList<TPropMap>;
  Src: string;
  M: TPropMap;
begin
  TMonitor.Enter(FCacheLock);
  try
    if FCache.TryGetValue(ATypeInfo, Result) then
      Exit;

    Result := TTypeCache.Create;

    Ctx := TRttiContext.Create;
    RType := Ctx.GetType(ATypeInfo);

    L := TList<TPropMap>.Create;
    try
      for P in RType.GetProperties do
      begin
        if not P.IsWritable then
          Continue;

        // Opt-in: Nur Properties mit Attribut
        Src := FindMapFromAttr(P);
        if Src = '' then
          Continue;

        M.Prop := P;
        M.SourceName := Src;
        L.Add(M);
      end;

      Result.Props := L.ToArray;
    finally
      L.Free;
    end;

    FCache.Add(ATypeInfo, Result);
  finally
    TMonitor.Exit(FCacheLock);
  end;
end;

class function TRttiMapper.VariantToBoolean(const V: Variant): Boolean;
var
  S: string;
begin
  if VarIsBool(V) then
    Exit(V);

  if VarIsNumeric(V) then
    Exit(V <> 0);

  S := Trim(VarToStr(V)).ToLower;
  if (S = '1') or (S = 'true') or (S = 't') or (S = 'y') or (S = 'yes') then
    Exit(True);
  if (S = '0') or (S = 'false') or (S = 'f') or (S = 'n') or (S = 'no') then
    Exit(False);

  raise ERttiMappingError.CreateFmt('Boolean-Konvertierung fehlgeschlagen: "%s"', [VarToStr(V)]);
end;

class function TRttiMapper.VariantToEnumOrdinal(AEnumType: TRttiType; const V: Variant): Integer;
var
  Ord: Integer;
  Name: string;
begin
  if VarIsNumeric(V) then
  begin
    Ord := Integer(V);
    if (Ord < GetTypeData(AEnumType.Handle)^.MinValue) or
       (Ord > GetTypeData(AEnumType.Handle)^.MaxValue) then
      raise ERttiMappingError.CreateFmt('Enum-Ordinal außerhalb des Bereichs: %d', [Ord]);
    Exit(Ord);
  end;

  Name := VarToStr(V);
  Ord := GetEnumValue(AEnumType.Handle, Name);
  if Ord < 0 then
    raise ERttiMappingError.CreateFmt('Enum-Name unbekannt: "%s"', [Name]);
  Result := Ord;
end;

class procedure TRttiMapper.SetPropertyValue(const AInstance: TObject;
  const AProp: TRttiProperty; const AValue: Variant);
var
  V: TValue;
  T: TRttiType;
  Ord: Integer;
begin
  T := AProp.PropertyType;

  // Konvertierung bewusst selektiv: lieber klar scheitern als still "irgendwie".
  case T.TypeKind of
    tkUString, tkString, tkLString, tkWString:
      V := TValue.From<string>(VarToStr(AValue));

    tkInteger, tkInt64:
      V := TValue.From<Int64>(VarAsType(AValue, varInt64));

    tkFloat:
      V := TValue.From<Double>(VarAsType(AValue, varDouble));

    tkEnumeration:
      begin
        if T.Handle = TypeInfo(Boolean) then
          V := TValue.From<Boolean>(VariantToBoolean(AValue))
        else
        begin
          Ord := VariantToEnumOrdinal(T, AValue);
          V := TValue.FromOrdinal(T.Handle, Ord);
        end;
      end;

    tkSet:
      raise ERttiMappingError.CreateFmt('Set-Mapping nicht implementiert für %s', [AProp.Name]);

    tkClass:
      raise ERttiMappingError.CreateFmt('Class-Property Mapping nicht implementiert für %s', [AProp.Name]);
  else
    raise ERttiMappingError.CreateFmt('TypeKind nicht unterstützt (%s) für %s',
      [GetEnumName(TypeInfo(TTypeKind), Ord(T.TypeKind)), AProp.Name]);
  end;

  AProp.SetValue(AInstance, V);
end;

class procedure TRttiMapper.MapToObject(const AReader: IValueReader;
  const ATarget: TObject; const AOptions: TRttiMapOptions);
var
  Cache: TTypeCache;
  M: TPropMap;
  V: Variant;
  Msg: string;
begin
  if (ATarget = nil) or (AReader = nil) then
    raise ERttiMappingError.Create('MapToObject: Reader oder Target ist nil');

  Cache := GetOrBuildCache(ATarget.ClassInfo);

  for M in Cache.Props do
  begin
    if not AReader.HasValue(M.SourceName) then
    begin
      if not (moIgnoreMissing in AOptions) then
        raise ERttiMappingError.CreateFmt('Quelle fehlt: "%s" für Property %s',
          [M.SourceName, M.Prop.Name]);
      Continue;
    end;

    if AReader.IsNull(M.SourceName) then
    begin
      if moIgnoreNull in AOptions then
        Continue;
      // Ohne Nullable/Optional-Mechanik kann man NULL nicht sinnvoll setzen.
      Continue;
    end;

    V := AReader.GetValue(M.SourceName);

    try
      SetPropertyValue(ATarget, M.Prop, V);
      if moDebug in AOptions then
      begin
        Msg := Format('Mapped %s <- %s (%s)', [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
        OutputDebugString(PChar(Msg));
      end;
    except
      on E: Exception do
        raise ERttiMappingError.CreateFmt('Mapping-Fehler bei %s <- %s: %s',
          [M.Prop.Name, M.SourceName, E.Message]);
    end;
  end;
end;

end.

Wozu das gut ist

Sie bekommen ein Mapping, das sich in Code-Reviews sauber beurteilen lässt:

  • Jede gemappte Property ist optisch markiert (Attribut).
  • Die Konvertierung ist zentral, dadurch konsistent und testbar.
  • Fehlertexte sagen, welche Property und welche Quelle betroffen ist.
  • Ein Debug-Modus gibt Ihnen im Zweifel die Beweiskette, ohne dass Sie Breakpoints im Produktivprozess brauchen.

Randbedingungen und typische Stolperfallen

  • NULL-Semantik: Ohne eigenes Nullable-Konzept (z. B. Nullable<T> oder Option-Types) ist „NULL setzen“ nicht eindeutig. Im Snippet wird NULL standardmäßig übersprungen. Das ist konservativ und verhindert stille Überschreibungen.
  • TRttiContext-Lebensdauer: Wir bauen den Cache einmal pro Typ und werfen den Context danach weg. Das ist üblich. Wichtig ist: Nicht pro Feldzuweisung neuen RTTI-Context bauen.
  • Threading: Der Cache ist via Monitor geschützt. In hochparallelen Mappings (z. B. REST-Server) sollten Sie zusätzlich prüfen, ob Sie den Cache schon beim Start „warm“ bauen (Preload), um Lock-Contention zu reduzieren.
  • PropertyType Kind: tkClass und tkSet sind absichtlich nicht implementiert. Für verschachtelte Objekte sollten Sie entweder rekursiv mappen (mit klarer Policy) oder bewusst per Hand zuweisen.
  • Locale-Fallen: varDouble über VarAsType ist relativ robust, aber Strings wie „1,23“ vs. „1.23“ sind trotzdem ein Thema. Wenn Ihre Quellen Strings liefern, ist ein eigener Parser (mit definierter Culture) oft besser.

Variante für FireDAC und TDataSet: Reader-Adapter statt Mapper-Kopplung

In BDE-Ablosung mit nativer Anbindung- oder klassischen VCL/Win32-Anwendungen ist die Quelle häufig ein TDataSet. Statt den Mapper an TField zu binden, schreiben Sie einen Adapter, der das Interface IValueReader erfüllt. Der Vorteil: Der Mapper bleibt unabhängig vom Datenzugriff (wichtig, wenn Sie Datenzugriff später in Services oder einen REST-Server auslagern).

Delphi
uses Data.DB, System.Variants, RttiMapping;

type
  TDataSetValueReader = class(TInterfacedObject, IValueReader)
  private
    FDS: TDataSet;
  public
    constructor Create(ADS: TDataSet);
    function HasValue(const AName: string): Boolean;
    function IsNull(const AName: string): Boolean;
    function GetValue(const AName: string): Variant;
  end;

constructor TDataSetValueReader.Create(ADS: TDataSet);
begin
  inherited Create;
  FDS := ADS;
end;

function TDataSetValueReader.HasValue(const AName: string): Boolean;
begin
  Result := (FDS <> nil) and (FDS.FindField(AName) <> nil);
end;

function TDataSetValueReader.IsNull(const AName: string): Boolean;
var
  F: TField;
begin
  F := FDS.FindField(AName);
  Result := (F = nil) or F.IsNull;
end;

function TDataSetValueReader.GetValue(const AName: string): Variant;
begin
  Result := FDS.FieldByName(AName).Value;
end;

Damit sieht ein konkretes Mapping so aus:

Delphi
type
  TOrderRow = class
  private
    FId: Int64;
    FCustomerNo: string;
    FIsClosed: Boolean;
  public
    [MapFrom('order_id')]
    property Id: Int64 read FId write FId;

    [MapFrom('customer_no')]
    property CustomerNo: string read FCustomerNo write FCustomerNo;

    [MapFrom('is_closed')]
    property IsClosed: Boolean read FIsClosed write FIsClosed;
  end;

// ...
var
  Row: TOrderRow;
  Reader: IValueReader;
begin
  Row := TOrderRow.Create;
  try
    Reader := TDataSetValueReader.Create(MyQuery);
    TRttiMapper.MapToObject(Reader, Row, [moIgnoreMissing, moDebug, moIgnoreNull]);
  finally
    Row.Free;
  end;
end;

Wo sich der Ansatz lohnt – und wo nicht

Dieses Muster lohnt sich typischerweise in drei Situationen:

  1. Schrittweise Modernisierung: Sie wollen Domänenobjekte einführen, ohne den Datenzugriff sofort komplett umzubauen (klassisch bei Delphi Modernisierung in Bestandsanwendungen).
  2. Schnittstellenkanten: CSV-/Excel-Importe, REST-Payloads oder „gemischte“ Datenquellen brauchen robuste Konvertierung und gute Fehlermeldungen.
  3. Wartbarkeit im Team: Attribute machen Mapping-Regeln sichtbar und reviewbar, was in größeren Codebasen Gold wert ist.

Einsatzgrenzen gibt es ebenfalls klar:

  • Komplexe Objektgraphen (Child-Collections, zyklische Referenzen) sollten Sie nicht „automagisch“ mappen. Hier ist expliziter Code oder ein getrenntes Assembler/Factory-Muster meist stabiler.
  • High-Throughput-Hotpaths (z. B. Massendaten-ETL) profitieren eher von codegenerierten Mappern oder handoptimiertem Mapping, selbst wenn RTTI gecacht ist.
  • Nullable/Optional ist ein eigenes Thema. Wenn Sie wirklich zwischen „nicht vorhanden“, „NULL“ und „Default“ unterscheiden müssen, sollten Sie das im Domänenmodell ausdrücken, nicht im Mapper verstecken.

Einordnung in Architektur und Betrieb

Aus Architekturperspektive ist dieser Mapper eine Infrastruktur-Komponente an der Grenze zwischen Datenrepräsentation und Domäne. Er ersetzt keine saubere Schichtung, kann sie aber ermöglichen: Der Datenzugriff (FireDAC, SQL, Views) darf weiterhin pragmatisch sein, während die Domäne konsistent bleibt. In mehrschichtigen Systemen (oft als Layer-3 Architektur bezeichnet: UI, Domain/Services, Infrastruktur) gehört der Mapper in die Infrastruktur und wird von Services genutzt, nicht von UI-Formularen.

Betrieblich wichtig: Aktivieren Sie moDebug nicht dauerhaft in produktiven Services, sondern gezielt. Für schwer reproduzierbare Datenprobleme ist es sinnvoll, einen schaltbaren Diagnosepfad zu haben (Konfiguration, Feature-Flag). Sonst drohen Log-Volumen und Nebenwirkungen.

Fazit: RTTI ja, aber nur mit klaren Leitplanken

Delphi RTTI für Mapping ohne Magie funktioniert dann gut, wenn Sie RTTI als Werkzeug für deklarative Metadaten nutzen – nicht als Einladung zu stillen Heuristiken. Attribute als Opt-in, zentralisierte Konvertierung, Cache pro Typ und verständliche Fehlertexte bringen das Thema von „undurchsichtig“ zu „betriebsfähig“. Der Ansatz ist bewusst nicht universell: Für verschachtelte Graphen, strikte Null-Semantik oder maximale Performance brauchen Sie weitere Bausteine. Als robuste Brücke zwischen Dataset/Legacy-Strukturen und moderneren Domänenobjekten ist er aber in vielen Delphi-Codebasen genau der pragmatische Schritt, der Modernisierung überhaupt erst möglich macht.

Wenn Sie in einer gewachsenen Delphi-Anwendung gerade an Mapping-Kanten, Datenqualität oder schrittweiser Modernisierung hängen, können wir das gemeinsam sauber aufsetzen und in Ihre Architektur einpassen: Kontakt aufnehmen.

Im fachlichen Umfeld spielen auch Delphi Rtti Mapping und Attribute Mapping Delphi eine wichtige Rolle, wenn Integrationen, Datenflüsse und Weiterentwicklung sauber zusammenspielen müssen.

Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.

Beitrag teilen

Diesen Beitrag direkt weitergeben

LinkedIn, X, XING, Facebook, WhatsApp und E-Mail sind sofort verfügbar. Für Instagram bereiten wir Link und Kurztext direkt vor.

E-Mail

Instagram oeffnet in einem neuen Tab. Link und Kurztext werden vorher in die Zwischenablage kopiert.