Net-Base Magazine

08.05.2026

Delphi RTTI voor mapping zonder magie: attribuutgebaseerd, debugbaar en legacy-geschikt

Een pragmatisch mappingpatroon met Delphi RTTI: attributen in plaats van conventies, gecontroleerde conversies, duidelijke foutmeldingen en een debugmodus die in productie werkelijk helpt. Met broncodefragmenten voor dataset- of record-naar-object-mapping zonder verborgen magie.

08.05.2026

Wie gevestigde bedrijfsoftware in Delphi beheert, kent dit spanningsveld: enerzijds wil je gestructureerde domeinobjecten en duidelijke lagen, anderzijds bestaan er Datasets, Variants, CSV-imports, interface-payloads of een REST-API die ‚op de een of andere manier‘ op objecten gemapt moeten worden. Precies hier kom je snel uit bij Delphi RTTI voor mapping zonder magie: mapping via reflection (RTTI = Run-Time Type Information, type-informatie tijdens uitvoering), maar zodanig dat het traceerbaar blijft, goed te debuggen is en niet stiekem van conventies of naamsgrapjes afhankelijk is.

De kern: ‚magie‘ ontstaat meestal niet door RTTI op zich, maar door impliciete regels. Als mappingregels daarentegen expliciet in attributen staan, conversies gecentraliseerd zijn en fouten een duidelijke oorzaak benoemen, wordt RTTI een hulpmiddel in plaats van een verrassing.

Waarom RTTI-mapping in Delphi vaak faalt

RTTI-gebaseerd mapping faalt in reële systemen zelden aan het idee zelf, maar aan randvoorwaarden:

  • Legacy-gegevensvormen: Null/Empty/0 zijn niet duidelijk onderscheiden, veldtypen wisselen, strings bevatten ‚N/A‘.
  • Sluipende conventies: ‚veld heet als property‘ werkt tot de eerste alias, join of hernoemde propertynaam.
  • Moeilijk te debuggen: Als een mapper ‚gewoon niets instelt‘, ontbreekt later de oorzaak. In productie is dat vergif.
  • Performance-mythen: RTTI wordt generaliserend als ‚traag‘ bestempeld, terwijl het probleem meestal ontbrekend cachen is.

Een houdbare aanpak zou daarom (1) expliciete mapping-metagegevens hebben, (2) conversie en null-semantie duidelijk behandelen, (3) fouten en debug-uitvoer leveren en (4) RTTI-informatie cachen.

Delphi RTTI voor mapping zonder magie: ontwerpprincipes

Het volgende patroon is bewust ’saai‘ in de beste zin: regels zijn zichtbaar, bijwerkingen beperkt, en het kan stapsgewijs in bestaande modules worden geïntroduceerd.

  • Attributen in plaats van naamconventie: een property krijgt een attribuut dat de bronkolom benoemt.
  • Opt-in: alleen gemarkeerde properties worden ingesteld. Geen verrassingen door ‚alle gepubliceerde properties‘.
  • Conversie op één plek: Variant/String/Integer/Boolean/Enum/Nullable worden centraal gemapt.
  • Debugmodus: optioneel wordt gelogd welke velden zijn ingesteld/overgeslagen — inclusief reden.
  • RTTI-caching: de duurste onderdelen (propertylijst, attribuutevaluatie) worden per type voorbereid.

Broncodevoorbeeld: Attribuut-mapping met RTTI, caching en debug

Het fragment zet een rij (bijv. uit BDE-vervanging met native koppeling via TDataSet) om naar een object. In plaats van de mapper vast te koppelen aan TField, gebruiken we een kleine reader-interface. Dat is in de praktijk waardevol, omdat u later dezelfde logica ook voor JSON, INI, CSV of API-responses kunt gebruiken.

Delphi
unit RttiMapping;

interface

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

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

  // Kleine abstractie: waarde leveren + onderscheid tussen bestaan en NULL
  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: alleen Properties met attribuut
        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-conversie mislukt: "%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-ordinaal buiten bereik: %d', [Ord]);
    Exit(Ord);
  end;

  Name := VarToStr(V);
  Ord := GetEnumValue(AEnumType.Handle, Name);
  if Ord < 0 then
    raise ERttiMappingError.CreateFmt('Enum-naam onbekend: "%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;

  // Conversie bewust selectief: liever duidelijk falen dan stil 'op de een of andere manier'.
  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 niet geïmplementeerd voor %s', [AProp.Name]);

    tkClass:
      raise ERttiMappingError.CreateFmt('Class-Property-mapping niet geïmplementeerd voor %s', [AProp.Name]);
  else
    raise ERttiMappingError.CreateFmt('TypeKind niet ondersteund (%s) voor %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 of Target is 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('Bron ontbreekt: "%s" voor Property %s',
          [M.SourceName, M.Prop.Name]);
      Continue;
    end;

    if AReader.IsNull(M.SourceName) then
    begin
      if moIgnoreNull in AOptions then
        Continue;
      // Zonder Nullable/Optional-mechaniek kan NULL niet zinvol worden gezet.
      Continue;
    end;

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

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

end.

Waarvoor is dit nuttig

U krijgt een mapping die in code-reviews eenduidig beoordeeld kan worden:

  • Elke gemapte Property is visueel gemarkeerd (attribuut).
  • De conversie is centraal, daardoor consistent en testbaar.
  • Foutmeldingen geven aan, welke Property en welke bron betroffen is.
  • Een debug-modus geeft u in geval van twijfel de bewijsketen, zonder dat u breakpoints in het productieproces hoeft te plaatsen.

Randvoorwaarden en typische valkuilen

  • NULL-semantiek: Zonder een eigen nullable-concept (bijv. Nullable<T> of Option-Types) is „NULL zetten“ niet eenduidig. In het snippet wordt NULL standaard overgeslagen. Dat is conservatief en voorkomt stille overschrijvingen.
  • TRttiContext-levensduur: We bouwen de cache één keer per type en gooien de Context daarna weg. Dat is gebruikelijk. Belangrijk: bouw niet voor elke veldtoewijzing een nieuwe RTTI-Context op.
  • Threading: De cache is via Monitor beschermd. In hoogparallelle mappings (bijv. REST-Server) moet u daarnaast nagaan of u de cache al bij opstart „warm“ bouwt (Preload), om Lock-Contention te verminderen.
  • PropertyType Kind: tkClass en tkSet zijn opzettelijk niet geïmplementeerd. Voor geneste objecten dient u ofwel recursief te mappen (met een duidelijke policy) of bewust handmatig toe te wijzen.
  • Locale-valkuilen: varDouble via VarAsType is relatief robuust, maar strings zoals „1,23″ vs. „1.23″ blijven een onderwerp. Als uw bronnen strings leveren, is een eigen parser (met gedefinieerde Culture) vaak beter.

Variant voor FireDAC en TDataSet: Reader-Adapter in plaats van Mapper-koppeling

In BDE-Ablosung mit nativer Anbindung- of klassieke VCL/Win32-toepassingen is de bron vaak een TDataSet. In plaats van de mapper aan TField te binden, schrijft u een adapter die de interface IValueReader implementeert. Het voordeel: de mapper blijft onafhankelijk van de data-toegang (belangrijk als u de data-toegang later in services of naar een REST-Server uitbesteedt).

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;

Daardoor ziet een concreet Mapping er als volgt uit:

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;

Waar deze aanpak zinvol is – en waar niet

Dit patroon is doorgaans de moeite waard in drie situaties:

  1. Geleidelijke modernisering: U wilt domeinobjecten invoeren zonder de data‑toegang meteen volledig te herstructureren (typisch bij Delphi modernisering in bestaande applicaties).
  2. Interfacegrenzen: CSV-/Excel-imports, REST-Payloads of „gemengde“ gegevensbronnen hebben robuuste conversie en duidelijke foutmeldingen nodig.
  3. Onderhoudbaarheid in het team: Attributen maken mappingregels zichtbaar en reviewbaar, wat in grotere codebases goud waard is.

Er zijn ook duidelijke beperkingen:

  • Complexe objectgrafen (child-collections, cyclische referenties) moet u niet „automagisch“ mappen. Hier is expliciete code of een afzonderlijk assembler-/factory‑patroon meestal stabieler.
  • High-Throughput-Hotpaths (bijv. massale data‑ETL) profiteren eerder van codegegenereerde mappers of handgeoptimaliseerde mapping, zelfs als RTTI gecached is.
  • Nullable/Optional is een apart onderwerp. Als u echt moet onderscheiden tussen „niet aanwezig“, „NULL“ en „Default“, moet u dat in het domeinmodel uitdrukken, niet verbergen in de mapper.

Plaats in architectuur en operatie

Vanuit architectuurperspectief is deze mapper een infrastructuurcomponent op de grens tussen datarepresentatie en domein. Hij vervangt geen zuivere scheiding in lagen, maar kan die wel mogelijk maken: de data‑toegang (FireDAC, SQL, Views) mag praktisch blijven terwijl het domein consistent blijft. In meerlaagse systemen (vaak aangeduid als Layer-3 architectuur: UI, Domain/Services, Infrastructuur) hoort de mapper thuis in de infrastructuur en wordt hij door services gebruikt, niet door UI‑formulieren.

Operationeel belangrijk: schakel moDebug niet permanent in productie‑services, maar selectief. Bij moeilijk reproduceerbare dataproblemen is het zinvol een schakelbaar diagnosepad te hebben (configuratie, feature‑flag). Anders dreigen grote logvolumes en bijwerkingen.

Conclusie: RTTI ja, maar alleen met duidelijke kaders

Delphi RTTI voor mapping zonder magie werkt goed wanneer u RTTI gebruikt als gereedschap voor declaratieve metadata – niet als uitnodiging voor stille heuristieken. Attributen als opt-in, gecentraliseerde conversie, cache per type en duidelijke foutmeldingen brengen het onderwerp van „onduidelijk“ naar „bedrijfsklaar“. De benadering is bewust niet universeel: voor geneste grafen, strikte nulsemantiek of maximale prestaties heeft u aanvullende bouwstenen nodig. Als robuuste brug tussen dataset/legacy-structuren en modernere domeinobjecten is het in veel Delphi-codebasen precies de pragmatische stap die modernisering in de eerste plaats mogelijk maakt.

Als u in een gegroeide Delphi-toepassing vastloopt bij mappinggrenzen, datakwaliteit of stapsgewijze modernisering, kunnen we dat samen zorgvuldig opzetten en in uw architectuur inpassen: Neem contact op.

In het vakgebied spelen ook Delphi Rtti Mapping en Attribute Mapping Delphi een belangrijke rol, wanneer integraties, gegevensstromen en verdere ontwikkeling naadloos moeten samenwerken.

Project of moderniseringsproject met Net-Base bespreken.

Bericht delen

Dit bericht direct delen

LinkedIn, X, XING, Facebook, WhatsApp en e-mail zijn direct beschikbaar. Voor Instagram bereiden we de link en een korte tekst direct voor.

E-mail

Instagram opent in een nieuw tabblad. Link en korte tekst worden van tevoren naar het klembord gekopieerd.