Net-Base Magazine

08.05.2026

Delphi RTTI for mapping without magic: attribute-based, debuggable and legacy-compatible

A pragmatic mapping pattern with Delphi RTTI: attributes instead of conventions, controlled conversions, clear error messages and a debug mode that genuinely helps during operation. Includes source code snippets for dataset- or record-to-object mapping without hidden magic.

08.05.2026

Anyone running mature business software in Delphi knows the tension: on the one hand you want structured domain objects and clear layers, on the other there are datasets, Variants, CSV imports, interface payloads or a REST API that „somehow“ must be mapped to objects. This is exactly where you quickly end up with Delphi RTTI for mapping without magic: mapping by reflection (RTTI = Run-Time Type Information), but done so it remains traceable, well debuggable and does not secretly depend on conventions or name tricks.

The core point: „magic“ usually does not arise from RTTI itself, but from implicit rules. If mapping rules, by contrast, are explicit in attributes, conversions are centralized and errors state a clear cause, RTTI becomes a tool instead of a surprise.

Why RTTI mapping in Delphi often fails

RTTI-based mapping in real systems rarely fails because of the idea, but because of boundary conditions:

  • Legacy data forms: Null/Empty/0 are not cleanly separated, field types change, strings contain ‚N/A‘.
  • Creeping conventions: ‚Field is named like the property‘ works until the first alias, join or refactored property name.
  • Hard to debug: When a mapper „simply sets nothing“, the cause is missing later. In production this is toxic.
  • Performance myths: RTTI is broadly labeled as „slow“, although the problem is usually missing caching.

A viable approach should therefore (1) have explicit mapping metadata, (2) handle conversion and null semantics clearly, (3) provide errors and debug output and (4) cache RTTI info.

Delphi RTTI for mapping without magic: Design principles

The following pattern is deliberately „boring“ in the best sense: rules are visible, side effects are limited, and it can be gradually introduced into existing modules.

  • Attributes instead of name convention: A property receives an attribute that names the source column.
  • Opt-in: Only marked properties are set. No surprises from „all published properties“.
  • Conversion in one place: Variant/String/Integer/Boolean/Enum/Nullable are mapped centrally.
  • Debug mode: Optionally log which fields were set/skipped — with the reason.
  • RTTI caching: The most expensive parts (property list, attribute evaluation) are prepared per type.

Source snippet: Attribute mapping with RTTI, caching and debug

The snippet maps a row (e.g. from BDE-replacement with native binding via TDataSet) to an object. Instead of coupling the mapper tightly to TField, we use a small reader interface. This is valuable in practice because you can later use the same logic for JSON, INI, CSV or API responses.

unit RttiMapping;

interface

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

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

// Small abstraction: provide value + distinguish existence/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: only properties with attribute
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 conversion failed: „%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 out of range: %d‘, [Ord]);
Exit(Ord);
end;

Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt(‚Enum name unknown: „%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;

// Conversion deliberately selective: fail clearly rather than silently „somehow“.
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 not implemented for %s‘, [AProp.Name]);

tkClass:
raise ERttiMappingError.CreateFmt(‚Class property mapping not implemented for %s‘, [AProp.Name]);
else
raise ERttiMappingError.CreateFmt(‚TypeKind not supported (%s) for %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 or 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(‚Source missing: „%s“ for property %s‘,
[M.SourceName, M.Prop.Name]);
Continue;
end;

if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Without nullable/optional mechanism, NULL cannot be meaningfully set.
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 error for %s <- %s: %s‘,
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;

end.

What this is for

You get a mapping that can be cleanly evaluated in code reviews:

  • Each mapped Property is visually marked (attribute).
  • The conversion is centralized, therefore consistent and testable.
  • Error messages indicate which property and which source are affected.
  • A debug mode provides the audit trail when needed, without requiring breakpoints in the production process.

Constraints and common pitfalls

  • NULL semantics: Without a dedicated nullable concept (e.g. Nullable<T> or option types) „setting NULL“ is ambiguous. In the snippet NULL is skipped by default. This is conservative and prevents silent overwrites.
  • TRttiContext lifetime: We build the cache once per type and discard the context afterwards. That is common. Important: Do not create a new RTTI context per field assignment.
  • Threading: The cache is protected via Monitor. In highly parallel mappings (e.g. REST-Server) you should also consider pre-warming the cache at startup (Preload) to reduce lock contention.
  • PropertyType Kind: tkClass and tkSet are intentionally not implemented. For nested objects you should either map recursively (with a clear policy) or assign explicitly by hand.
  • Locale pitfalls: varDouble via VarAsType is relatively robust, but strings like „1,23“ vs. „1.23“ remain an issue. If your sources deliver strings, a dedicated parser (with a defined Culture) is often better.

Variant for FireDAC and TDataSet: reader adapter instead of mapper coupling

In BDE-Ablosung mit nativer Anbindung or classic VCL/Win32 applications the source is often a TDataSet. Instead of binding the mapper to TField, write an adapter that implements the IValueReader interface. The advantage: the mapper remains independent of data access (important if you later move data access into services or outsource it to a REST-Server).

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;

A concrete mapping then looks like this:

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;

Where this approach is worthwhile — and where it isn’t

This pattern is typically worthwhile in three situations:

  1. Incremental modernization: You want to introduce domain objects without immediately reworking the data access layer (typical in Delphi modernization of legacy applications).
  2. Interface boundaries: CSV/Excel imports, REST payloads or „mixed“ data sources require robust conversion and clear error reporting.
  3. Maintainability in the team: Attributes make mapping rules visible and reviewable, which is highly valuable in larger codebases.

There are also clear limits to its use:

  • Complex object graphs (child collections, cyclic references) should not be „automagically“ mapped. Explicit code or a separate assembler/factory pattern is usually more stable here.
  • High-throughput hotpaths (e.g. bulk-data ETL) are better served by code-generated mappers or hand-optimized mapping, even if RTTI is cached.
  • Nullable/Optional is a separate concern. If you truly need to distinguish between „absent“, „NULL“ and „default“, express that in the domain model rather than hiding it in the mapper.

Placement in architecture and operations

From an architectural perspective this mapper is an infrastructure component at the boundary between data representation and the domain. It does not replace clean layering, but it can enable it: data access (FireDAC, SQL, views) may remain pragmatic while the domain remains consistent. In multi-layered systems (often referred to as Layer-3 architecture: UI, Domain/Services, Infrastructure) the mapper belongs in infrastructure and is consumed by services, not UI forms.

Operationally important: Do not enable moDebug permanently in production services; enable it selectively. For hard-to-reproduce data issues it makes sense to provide a switchable diagnostic path (configuration, feature flag). Otherwise you risk log volume and side effects.

Conclusion: RTTI yes, but only with clear guardrails

Delphi RTTI for mapping without magic works well when you treat RTTI as a tool for declarative metadata — not as an invitation to implicit heuristics. Attributes as opt-in, centralized conversion, per-type caching and clear error messages move the topic from “opaque” to “operational”. The approach is deliberately not universal: for nested graphs, strict null semantics or maximum performance you will need additional components. As a robust bridge between dataset/legacy structures and more modern domain objects, however, it is in many Delphi codebases precisely the pragmatic step that makes modernization possible in the first place.

If you are stuck in a mature Delphi application on mapping edges, data quality or incremental modernization, we can set this up cleanly together and integrate it into your architecture: Contact us.

In the domain context, Delphi Rtti Mapping and Attribute Mapping Delphi also play an important role when integrations, data flows and ongoing development must interact cleanly.

Discuss a project or modernization initiative with Net-Base.

Share post

Share this post directly

LinkedIn, X, XING, Facebook, WhatsApp and email are available immediately. For Instagram, we will prepare the link and a short caption immediately.

Email

Instagram opens in a new tab. The link and short text are copied to the clipboard beforehand.