Modernizing Legacy .NET with Reflection
This article examines a reflection-based modernization approach for exposing hundreds of methods to modern channels through a controlled execution pipeline, without rewriting business rules that have evolved over many years in financial systems.
In enterprise software modernization, the hardest question is often not “how should the new system be built?” The real question is how years of mature business rules, operational practices, and financial control points can be carried into a new channel without disrupting proven behavior.
This is especially true in financial systems, where a big-bang rewrite may look clean in theory but carries significant practical risk. A single transaction chain — from a credit decision to an accounting entry, from an insurance policy operation to customer verification — can be deeply intertwined with regulation, audit requirements, approval mechanisms, reconciliation, backward compatibility, and established operational practices.
This is the core problem: while modern web channels are being developed, a large set of mature legacy business methods should not have to be wrapped repeatedly as separate services. Instead, they can be executed through a controlled and reusable execution layer.
The architectural idea was not to “use reflection.” The real goal was to create a controlled, observable, and reusable modernization boundary between the modern channel and proven legacy business behavior.
1. The Problem: Re-exposing Hundreds of Business Methods One by One
The conventional approach is straightforward: for every legacy operation required by the web layer, create a new endpoint, request/response model, mapper, and adapter. This is reasonable for a small number of operations. In a large legacy system with hundreds of methods, however, the same mechanical work begins to repeat at scale.
- Rewriting similar controller / service / mapping code for every operation.
- Updating many wrappers whenever legacy method signatures change.
- Continuously paying the cost of type conversion and error mapping between the new channel and the legacy model.
- Scattering the same security, logging, and exception-handling rules across multiple endpoints.
- Allowing modernization speed to be constrained by integration boilerplate rather than by the actual complexity of business capabilities.
The design question therefore becomes: instead of exposing every method as a dedicated service, can the common execution behavior be designed once and used to run different legacy operations through the same pipeline?
2. The Solution: A Generic Execution Contract
The execution engine begins with a shared envelope. The request identifies which operation should run, which request type should be used, which response type should be returned, and the payload itself. From the outside, there is a single execution contract; internally, there is a controlled chain of type resolution, binding, and invocation.
Code 1 — Simplified execution request model
public sealed class LegacyExecuteRequest
{
public string OperationName { get; set; }
public string RequestTypeName { get; set; }
public string ResponseTypeName { get; set; }
public string Payload { get; set; }
public int ResourceId { get; set; }
public int ActionId { get; set; }
}Code 2 — JSON representation of the same contract
{
"operationName": "GetCustomerSummary",
"requestTypeName": "CustomerRequest",
"responseTypeName": "CustomerSummary",
"payload": "{ \"CustomerId\": 12345 }",
"resourceId": 2107,
"actionId": 11
}The most important benefit of this contract is not that it makes the client completely independent of legacy assembly details. More importantly, it standardizes execution behavior in one place. Validation, authorization, logging, type resolution, invocation, and response normalization become stages of the same flow.
3. Why Reflection Made Sense Here
Reflection provides the ability to inspect type and member metadata at runtime, instantiate objects, and invoke methods. Introductory examples usually demonstrate this with GetProperties, Activator.CreateInstance, or MethodInfo.Invoke. In a production system, however, these primitives are not valuable in isolation; their value emerges when they are used together inside a controlled execution pipeline.
Code 3 — Reflection in its simplest form
var serviceType = typeof(CustomerService);
var method = serviceType.GetMethod("GetCustomerSummary");
var service = Activator.CreateInstance(serviceType);
var result = method.Invoke(service, new object[] { request });These few lines capture the essence of the mechanism, but they are not production-ready. A real system must treat method discovery, allowed operations, payload binding, generic response resolution, exception normalization, and auditability as separate responsibilities.
4. Type Resolver + Invoker: Keeping Runtime Flexibility Under Control
The critical design decision is to resolve known types through a controlled registry instead of allowing unrestricted resolution such as Type.GetType(request.TypeName). This makes type resolution deterministic while explicitly defining the execution engine’s boundaries.
Code 4 — Controlled type registry
public sealed class TypeRegistry
{
private readonly IDictionary<string, Type> _knownTypes =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
{ "CustomerRequest", typeof(CustomerRequest) },
{ "CustomerSummary", typeof(CustomerSummary) },
{ "LoanRequest", typeof(LoanRequest) },
{ "LoanResult", typeof(LoanResult) }
};
public Type Resolve(string alias)
{
Type type;
if (!_knownTypes.TryGetValue(alias, out type))
throw new InvalidOperationException("Unsupported contract type: " + alias);
return type;
}
}Payload binding can then be performed against the resolved request type. JSON is converted into the correct CLR type at runtime, method metadata is resolved, and invocation parameters are prepared.
Code 5 — Resolver, binding, invocation, and response-normalization flow
public object Execute(LegacyExecuteRequest request)
{
var requestType = _typeRegistry.Resolve(request.RequestTypeName);
var responseType = _typeRegistry.Resolve(request.ResponseTypeName);
var operation = _operationRegistry.Resolve(request.OperationName);
var payload = JsonConvert.DeserializeObject(request.Payload, requestType);
var target = Activator.CreateInstance(operation.ServiceType);
var result = operation.Method.Invoke(target, new[] { payload });
return _responseNormalizer.Normalize(result, responseType);
}The key distinction is this: Reflection is not a mechanism for discovering and executing arbitrary CLR types requested by an external client. It is a tool that connects pre-defined operations to a shared runtime execution model.
5. The Real Value of the Invoker Layer
Reducing this design to “one endpoint can call many methods” would miss its real value. The important part is that different legacy operations pass through the same engineering policies.
- Centralized request validation and payload limits.
- Resource/action-based authorization and operation-policy checks.
- A consistent correlation ID and structured logging approach.
- Controlled mapping of legacy exceptions into an error contract suitable for modern channels.
- Centralized request/response type-resolution behavior.
- Less repetitive adapter code and a faster modernization pace.
- A single observable boundary for the dependency between the new channel and the legacy system.
For that reason, this component is better understood as a small execution gateway that acts as an anti-corruption layer rather than as a simple reflection helper. The modern system does not have to carry the legacy system’s internal semantics directly; the boundary translates contracts between the two worlds.
6. Why This Matters Especially in Financial Systems
In financial-system modernization, technical correctness must be considered together with business continuity. Modernizing a screen may be relatively easy, but the limit, pricing, risk, approval, accounting, insurance, or customer-verification behavior behind that screen may have accumulated hundreds of edge cases over many years.
Where does this pattern create value in financial-system modernization?
The value of an execution boundary is not limited to simplifying technical integration. Its real value is allowing years of mature business behavior across different domains to be reused by modern channels without rewriting it, while preserving the same control mechanisms.
Lending / Disbursement
Limits, pricing, eligibility, credit approval, and disbursement controls can remain within the existing domain behavior. The execution boundary connects the new web flow to these behaviors in a controlled way, decoupling channel modernization from a full domain rewrite.
KYC / Customer
Critical operations such as identity verification, customer status checks, and data validation can continue to run in the legacy system. The new channel does not need to understand every detail of legacy customer contracts; that complexity is contained behind a controlled boundary.
Get Samet Özer’s stories in your inbox
Join Medium for free to get updates from this writer.
Insurance
Operations such as policy inquiry, approval, cancellation, and information retrieval become accessible through the same execution model. Different insurance operations can therefore be governed by shared authorization, logging, and exception-handling policies.
Accounting / Reconciliation
Ledger controls, posting, and reconciliation directly affect financial outcomes. The execution boundary exposes these operations to modern channels in a way that is centralized, traceable, and auditable.
Workflow / Maker-Checker
Steps such as initiate, approve, reject, and promote often require operational and governance controls in addition to technical execution. The execution boundary allows resource and action context to be evaluated centrally before invocation.
Payments / Channel Operations
Transaction controls, channel rules, and transaction-state management are critical behaviors in payment flows. This layer allows the modern channel to evolve without making its delivery schedule dependent on a full domain rewrite.
Consider a lending product moving to a web channel. The user experience can be redesigned with React or another modern technology, while product eligibility, limit checks, campaign rules, insurance integrations, and pre-disbursement controls do not all need to be rewritten at the same time. The execution boundary allows the channel to modernize while proven domain behavior continues to be consumed in a controlled way.
The same approach is even more valuable in accounting and reconciliation. Calling an operation that affects a financial record from a new channel is not merely an HTTP integration problem; it must be possible to trace the user context, the action, the correlation ID, the duration, and the result of the operation.
At this point, the execution engine effectively buys time: the modern channel can begin delivering value immediately, while domain behavior can be migrated to dedicated services or separated later according to business priority and risk.
7. Controlled Coexistence Instead of a Big-Bang Rewrite
In large legacy systems, modernization is often a period rather than a single project. New and old systems may coexist for months or even years. Once this reality is accepted, the objective is not to eliminate the old system on day one, but to let the new system gain ground in a controlled manner.
The Strangler Fig pattern, also described in Microsoft’s Azure Architecture Center, advocates replacing legacy functionality incrementally with new applications and services rather than attempting a single large-scale rewrite. A reflection-based execution engine can serve as one internal integration mechanism within this transition: the new channel can continue to operate through a stable interface while the underlying capability is gradually migrated to a new service.
8. The Difference Between “Calling Any Method” and Controlled Invocation
What determines the production quality of this architecture is not reflection itself, but how clearly its boundaries are defined. Rather than resolving arbitrary assembly, type, or method names supplied by the client, the engine should expose only operations defined through an explicit registry or an attribute-based contract.
Code 6 — Defining a callable operation explicitly
[AttributeUsage(AttributeTargets.Method)]
public sealed class LegacyOperationAttribute : Attribute
{
public LegacyOperationAttribute(string name, int resourceId, int actionId)
{
Name = name;
ResourceId = resourceId;
ActionId = actionId;
}
public string Name { get; private set; }
public int ResourceId { get; private set; }
public int ActionId { get; private set; }
}
public sealed class CustomerService
{
[LegacyOperation("GetCustomerSummary", 2107, 11)]
public CustomerSummary GetCustomerSummary(CustomerRequest request)
{
// Proven legacy domain behavior
return LoadCustomerSummary(request.CustomerId);
}
}In this model, the engine’s discovery phase registers only operations that are explicitly marked and matched with a policy. Runtime flexibility is preserved while the execution surface remains explicit and manageable.
ResourceId and ActionId may still be carried in the request envelope for compatibility or validation purposes, but they should not be treated as authoritative authorization data. The effective operation metadata should come from the server-side registry. If client-provided values are retained, they should either be validated against the registered operation metadata or ignored when authorization decisions are made.
- Resolve only permitted request and response types, and validate them against the contract registered for the selected operation; do not allow arbitrary assembly or type resolution.
- Restrict callable services and methods through an allow-list / registry.
- Evaluate authorization not only at the “is the user authenticated?” level, but in resource/action/operation context.
- Generate trusted context such as user identity, channel, and correlation ID server-side whenever possible.
- Apply payload-size, timeout, serialization, and error-message policies centrally.
- For financial and auditable operations, record the operation, duration, result, and correlation metadata without logging sensitive payloads.
9. The Hard Part Is Often the Type System, Not Invoke
Invoking a method with reflection takes only a few lines. The real production challenge is reliably managing a heterogeneous type system that has evolved over many years. In legacy systems, the response type is not necessarily a simple DTO; nested generics and dictionary/list combinations are common.
Code 7 — Generic response shapes encountered in real systems
GenericResponse<List<bool>>
GenericResponse<Dictionary<string, int>>
GenericResponse<Dictionary<string, List<AccountingPlanItem>>>For that reason, the response-resolver layer is more than a Type.GetType(…) call. Generic types must be closed correctly, nested collections must be resolved, and the serializer must produce the expected contract.
Code 8 — Building a closed generic response type at runtime
public Type BuildGenericResponseType(Type payloadType)
{
var responseDefinition = typeof(GenericResponse<>);
return responseDefinition.MakeGenericType(payloadType);
}
public Type BuildDictionaryResponse(Type itemType)
{
var listType = typeof(List<>).MakeGenericType(itemType);
var dictionaryType = typeof(Dictionary<,>)
.MakeGenericType(typeof(string), listType);
return BuildGenericResponseType(dictionaryType);
}At this point, reflection is no longer merely a tool for “finding a method by name”; it becomes part of the infrastructure that interprets the legacy contract model at runtime.
10. Observability Is Not an Optional Feature in Financial Modernization
Once a dynamic execution layer exists between the new channel and the legacy system, it naturally becomes an observation point. Which operations are called most often? Where is latency highest? Which resource/action combinations produce errors? Which legacy capabilities should be prioritized next in the modernization plan?
Code 9 — Using the invocation boundary as a telemetry point
public object ExecuteWithTelemetry(LegacyExecuteRequest request)
{
var started = Stopwatch.StartNew();
var correlationId = _context.CorrelationId;
// Resolve the operation from the trusted server-side registry.
var operation = _operationRegistry.Resolve(request.OperationName);
try
{
var result = Execute(request);
_logger.Info(new
{
correlationId,
request.OperationName,
resourceId = operation.ResourceId,
actionId = operation.ActionId,
durationMs = started.ElapsedMilliseconds,
status = "Success"
});
return result;
}
catch (Exception ex)
{
_logger.Error(new
{
correlationId,
request.OperationName,
resourceId = operation.ResourceId,
actionId = operation.ActionId,
durationMs = started.ElapsedMilliseconds,
status = "Failed",
errorType = ex.GetType().Name
});
throw;
}
}These metrics are valuable not only for production support, but also for modernization planning. Usage metrics can show which legacy operations should be migrated to dedicated services first; error and latency metrics can reveal which areas deserve priority from a technical-debt perspective.
11. Performance: Reflection Cost Must Be Evaluated in Context
Reflection is more expensive than a direct method call. Runtime metadata resolution, argument preparation, and dynamic invocation are the main sources of that overhead. In financial systems, however, the total latency of an operation is rarely determined by method invocation alone. Database access, stored procedures, external service calls, serialization, workflow steps, and business rules may account for a much larger share of the total duration.
For that reason, evaluating reflection performance means measuring the entire execution pipeline rather than focusing only on the cost of Invoke(). Caching MethodInfo objects so the same type and method metadata are not resolved again on every request can remove unnecessary runtime overhead, especially for frequently invoked operations.
Code 10 — Caching method metadata
public sealed class MethodCache
{
private readonly ConcurrentDictionary<string, MethodInfo> _cache =
new ConcurrentDictionary<string, MethodInfo>();
public MethodInfo GetOrAdd(
Type serviceType,
string methodName,
Type[] parameterTypes)
{
var signature = string.Join(
",",
parameterTypes.Select(x => x.FullName));
var key = $"{serviceType.FullName}:{methodName}:{signature}";
return _cache.GetOrAdd(key, _ =>
{
var method = serviceType.GetMethod(
methodName,
BindingFlags.Instance | BindingFlags.Public,
binder: null,
types: parameterTypes,
modifiers: null);
if (method == null)
throw new MissingMethodException(
serviceType.FullName,
methodName);
return method;
});
}
}The objective is not to eliminate the cost of reflection entirely, but to avoid repeating the same discovery work on every call. Real optimization decisions should be driven by telemetry and production workloads, because reflection is not necessarily the actual bottleneck.
12. The Broader Modernization Value of This Architecture
An execution engine like this separates three timelines: the modernization of the user experience, the modernization of the integration layer, and the modernization of domain logic. When these three do not have to ship in the same release, the modernization process becomes significantly easier to manage.
Delivering a new web experience without waiting to rewrite years of proven lending, insurance, accounting, or workflow behavior is a significant strategic advantage. Domain teams can evolve critical business rules according to their own release plans, while channel teams continue improving the user experience.
In financial systems, another consequence is that risk can be divided into smaller change sets. When one capability moves to a new service, only that area’s behavior changes; other capabilities can continue using mature legacy behavior through the execution boundary.
13. Conclusion: Reflection Is the Technique; the Modernization Boundary Is the Real Product
MethodInfo.Invoke may be the most visually striking line in this architecture, but it is not the line that creates the system’s value. The value comes from designing a boundary that exposes mature behavior in a legacy .NET system to modern channels in a controlled way.
Reflection is a means, not an end. The invoker does more than execute methods; it brings validation, authorization, type resolution, normalization, observability, and audit behavior into a single execution model. This is particularly powerful in financial-system modernization, where technology can evolve incrementally while the system continues delivering business value.
The success of legacy modernization is not measured by how quickly the old system can be deleted, but by how safely and deliberately change can be managed without interrupting business value.






