In previous posts, we've used WitRPC to call remote methods and handle server events. It felt like magic: you call a method on an interface, and somehow it executes on a different machine. Today, we pull back the curtain and explore how WitRPC actually works.

Understanding the internals isn't just academic curiosity. It helps you debug issues, make informed architecture decisions, and appreciate why WitRPC behaves the way it does.

The Big Picture

When you write this code:

csharp
var service = client.GetService<ITaskService>();
bool result = service.StartTask("MyTask");

What actually happens? Let's trace the journey:

The response travels the same path in reverse. Let's examine each component.

Dynamic Proxy Generation

The heart of WitRPC's magic is the dynamic proxy. When you call client.GetService<ITaskService>(), WitRPC creates a class at runtime that implements ITaskService.

How Castle DynamicProxy Works

WitRPC uses Castle DynamicProxy, a mature .NET library for runtime proxy generation. Here's the conceptual flow:

csharp
// What you write
ITaskService service = client.GetService<ITaskService>();

// What happens internally (simplified)
public T GetService<T>() where T : class
{
    var proxyGenerator = new ProxyGenerator();
    var interceptor = new WitRpcInterceptor(this, typeof(T));
    
    // Castle creates a class at runtime that implements T
    // Every method call is routed through the interceptor
    return proxyGenerator.CreateInterfaceProxyWithoutTarget<T>(interceptor);
}

Castle DynamicProxy uses System.Reflection.Emit to generate IL code at runtime. The generated class looks conceptually like this:

csharp
// Pseudo-code of what Castle generates
public class TaskServiceProxy : ITaskService
{
    private readonly IInterceptor _interceptor;
    
    public bool StartTask(string taskName)
    {
        var invocation = new Invocation(
            method: typeof(ITaskService).GetMethod("StartTask"),
            arguments: new object[] { taskName }
        );
        
        _interceptor.Intercept(invocation);
        
        return (bool)invocation.ReturnValue;
    }
    
    // ... other interface methods similarly wrapped
}

Every method call becomes an Invocation object passed to the interceptor. The interceptor decides what to do; in WitRPC's case, it sends the call over the network.

The Interceptor: Where RPC Happens

The interceptor is where local calls become remote calls:

csharp
// Simplified WitRPC interceptor logic
public class WitRpcInterceptor : IInterceptor
{
    private readonly WitClient _client;
    private readonly Type _serviceType;
    
    public void Intercept(IInvocation invocation)
    {
        // 1. Build request message
        var request = new RpcRequest
        {
            ServiceType = _serviceType.FullName,
            MethodName = invocation.Method.Name,
            Arguments = invocation.Arguments,
            ArgumentTypes = invocation.Method.GetParameters()
                .Select(p => p.ParameterType).ToArray()
        };
        
        // 2. Serialize and send
        var response = _client.SendRequest(request);
        
        // 3. Handle response
        if (response.Exception != null)
        {
            throw new WitExceptionFault(response.Exception);
        }
        
        invocation.ReturnValue = response.Result;
    }
}

For async methods, the interceptor returns a Task that completes when the server responds:

csharp
if (invocation.Method.ReturnType == typeof(Task) ||
    invocation.Method.ReturnType.IsGenericType && 
    invocation.Method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))
{
    // Return a Task that completes when server responds
    invocation.ReturnValue = SendRequestAsync(request);
}

Convention-Based Method Matching

WitRPC uses a convention-based approach to match client calls to server methods. When a request arrives at the server:

  1. Find the service implementation registered for the requested interface type
  2. Find the method with the matching name and parameter types
  3. Deserialize arguments to the expected types
  4. Invoke the method via reflection
  5. Serialize and return the result
csharp
// Server-side request handling (simplified)
public object HandleRequest(RpcRequest request)
{
    // Find registered service
    var service = _services[request.ServiceType];
    
    // Find method by name and signature
    var method = service.GetType().GetMethod(
        request.MethodName,
        request.ArgumentTypes
    );
    
    // Invoke
    var result = method.Invoke(service, request.Arguments);
    
    // Handle async methods
    if (result is Task task)
    {
        await task;
        if (task.GetType().IsGenericType)
        {
            result = ((dynamic)task).Result;
        }
    }
    
    return result;
}

This convention-based approach is why you don't need [OperationContract] attributes: the method name and signature are the contract.

Event Handling: Server-to-Client Push

Events are where WitRPC really shines. Let's trace how a server event reaches client handlers.

Server Side: Event Interception

When you register a service with WitRPC, the framework wraps your implementation to intercept event invocations:

csharp
// Your service implementation
public class TaskService : ITaskService
{
    public event Action<double> ProgressChanged = delegate { };
    
    private void DoWork()
    {
        ProgressChanged(0.5); // This needs to reach clients
    }
}

WitRPC replaces your event's backing delegate with one that broadcasts to all connected clients:

csharp
// Conceptually what WitRPC does
public void WrapServiceEvents(object service, Type interfaceType)
{
    foreach (var eventInfo in interfaceType.GetEvents())
    {
        // Create a handler that broadcasts to clients
        var broadcastHandler = CreateBroadcastHandler(eventInfo);
        
        // Subscribe to the event
        eventInfo.AddEventHandler(service, broadcastHandler);
    }
}

private Delegate CreateBroadcastHandler(EventInfo eventInfo)
{
    return (args) =>
    {
        var eventMessage = new EventNotification
        {
            EventName = eventInfo.Name,
            Arguments = args
        };
        
        // Send to all subscribed clients
        foreach (var client in _subscribedClients[eventInfo.Name])
        {
            client.SendEvent(eventMessage);
        }
    };
}

Client Side: Event Subscription

On the client, the proxy also intercepts event subscriptions:

csharp
// When you write this:
service.ProgressChanged += progress => Console.WriteLine(progress);

// The proxy intercepts the += operator

Castle DynamicProxy handles event subscription specially. The proxy maintains local handlers and notifies the server about subscriptions:

csharp
// Proxy event handling (conceptual)
public class TaskServiceProxy : ITaskService
{
    private Action<double> _progressChangedHandlers;
    
    public event Action<double> ProgressChanged
    {
        add
        {
            _progressChangedHandlers += value;
            
            // First subscriber? Tell server we're interested
            if (_progressChangedHandlers.GetInvocationList().Length == 1)
            {
                _client.Subscribe("ProgressChanged");
            }
        }
        remove
        {
            _progressChangedHandlers -= value;
            
            // No more subscribers? Unsubscribe from server
            if (_progressChangedHandlers == null)
            {
                _client.Unsubscribe("ProgressChanged");
            }
        }
    }
    
    // Called when server sends an event
    internal void RaiseProgressChanged(double value)
    {
        _progressChangedHandlers?.Invoke(value);
    }
}

The Event Flow

Putting it together:

Thread Considerations

Events arrive on a background thread managed by WitRPC's transport layer. This has implications:

csharp
// Events fire on background threads!
service.ProgressChanged += progress =>
{
    // This runs on a WitRPC thread, not the UI thread
    
    // For WPF:
    Dispatcher.Invoke(() => progressBar.Value = progress);
    
    // For WinForms:
    BeginInvoke(() => progressBar.Value = (int)(progress * 100));
    
    // For Blazor:
    InvokeAsync(() => { this.progress = progress; StateHasChanged(); });
};

WitRPC doesn't automatically marshal to a synchronization context because the "right" context depends on your application type.

Serialization Pipeline

Every message (requests, responses, events) passes through the serialization layer.

Message Structure

WitRPC uses a two-layer serialization approach:

The envelope uses a fixed binary format for efficiency. The payload uses your chosen serializer (JSON, MessagePack, etc.).

Serializer Choice Impact

Different serializers have different characteristics:

Serializer Size Speed Human-Readable Complex Types
JSON Large Slow ✅ Yes Good
MessagePack Small Fast ❌ No Good
MemoryPack Smallest Fastest ❌ No Requires attributes
ProtoBuf Small Fast ❌ No Requires attributes

For debugging, JSON is invaluable: you can inspect wire traffic with tools like Wireshark or Fiddler. For production with high throughput, binary serializers provide significant gains.

Type Handling

WitRPC needs to serialize types that both client and server understand. By default, it uses strong type matching:

csharp
// Both client and server must have the same type
public class OrderInfo
{
    public int Id { get; set; }
    public string Customer { get; set; }
}

// If OrderInfo is in a shared assembly, it works out of the box
// If it's in different assemblies with same namespace/name, WitRPC can match by name

For complex scenarios, you can configure type resolution:

csharp
options.WithTypeResolver(new CustomTypeResolver());

Transport Abstraction

WitRPC's transport layer is pluggable. Each transport implements the same interface:

csharp
public interface ITransport : IDisposable
{
    event TransportDataEventHandler Callback;     // (Guid sender, byte[] data)
    event TransportEventHandler Disconnected;     // (Guid sender)

    Task SendBytesAsync(byte[] data);

    Guid Id { get; }
}

This is the actual interface from the source. Note how small it is: a transport only knows how to move bytes and report who they came from. Connection setup lives in transport-specific factories, and everything above this line (serialization, encryption, request correlation) is transport-agnostic by construction.

Transport Comparison

Transport Use Case Relative speed Setup
Memory-Mapped File Same machine, max speed Fastest measured Simple
Named Pipes Same machine, easy Close second locally Simple
TCP Network, performance Network-bound Port config
WebSocket Web clients, firewalls Network-bound URL config
REST Interop, stateless Network-bound, per-request URL config

The transport choice doesn't affect your service code, only the configuration:

csharp
// Same service, different transports: one line changes
options.WithService(taskService);

options.WithMemoryMappedFile("MyApp");                       // Option A: local IPC
options.WithTcp(5000, maxNumberOfClients: 100);              // Option B: network
options.WithWebSocket("http://0.0.0.0:8080", maxClients: 100); // Option C: web

Exception Propagation

When server methods throw, clients need to know. WitRPC serializes exception information:

csharp
// Server
public bool StartTask(string name)
{
    if (string.IsNullOrEmpty(name))
        throw new ArgumentException("Name required", nameof(name));
    // ...
}

// Client
try
{
    service.StartTask("");
}
catch (WitExceptionFault ex)
{
    // ex.Message carries the server-side error information
    Console.WriteLine($"Server error: {ex.Message}");
}

The exception hierarchy:

WitException (base)
├── WitExceptionFault           // the server implementation threw
├── WitExceptionTransport       // transport-level failure (connection lost, unreachable)
├── WitExceptionSerialization   // payload could not be (de)serialized
└── WitExceptionEncryption      // encryption handshake or crypto operation failed

Catching WitExceptionFault separates business failures from communication problems; catching the base WitException covers the rest.

Static Proxies for AOT

Dynamic proxy generation requires JIT compilation. In AOT environments (iOS, Blazor WASM with AOT, Native AOT), this isn't available.

WitRPC supports static proxies, pre-compiled proxy classes:

csharp
// Hand-written or generated static proxy
public class TaskServiceStaticProxy : RequestInterceptor, ITaskService
{
    public TaskServiceStaticProxy(WitClient client) : base(client) { }
    
    public bool StartTask(string taskName)
    {
        return Invoke<bool>("StartTask", taskName);
    }
    
    public event Action<double> ProgressChanged
    {
        add => Subscribe("ProgressChanged", value);
        remove => Unsubscribe("ProgressChanged", value);
    }
    
    // ... other members
}

// Usage in AOT environment
var service = client.GetService<ITaskService>(
    () => new TaskServiceStaticProxy(client)
);

WitRPC can generate these proxies at build time using a source generator, making AOT support transparent.

Connection Lifecycle

Understanding the connection lifecycle helps with debugging:

You can observe connection state:

csharp
client.Disconnected += sender => Console.WriteLine("Disconnected");

// Reconnection is observed through the auto-reconnect callbacks:
options.WithAutoReconnect(r =>
{
    r.OnReconnecting = (attempt, delay) => Console.WriteLine($"Reconnecting, attempt {attempt}...");
    r.OnReconnected = () => Console.WriteLine("Reconnected");
});

// And queried at any time:
var state = client.ConnectionState;   // Connected / Reconnecting / Disconnected / Failed

Debugging Tips

Understanding the internals helps with debugging:

1. Enable Logging

csharp
options.WithLogger(new ConsoleLogger(LogLevel.Debug));

This shows message flow, timing, and errors.

2. Use JSON for Development

csharp
// Development: readable wire format
options.WithJson();

// Production: performance
options.WithMemoryPack();

3. Inspect with Network Tools

For TCP/WebSocket transports, use Wireshark or similar tools to inspect traffic. With JSON serialization, messages are human-readable.

4. Check Event Subscriptions

If events aren't firing, verify:

  • Client subscribed before server raised event
  • No exceptions in event handlers (they can silently fail)
  • Connection is still active

5. Handle Disconnections

csharp
client.Disconnected += () =>
{
    // Service proxy is no longer usable
    // Reconnect or notify user
};

Conclusion

WitRPC's internals are built on proven patterns:

  • Dynamic Proxies (Castle DynamicProxy) make remote calls feel local
  • Convention-based routing eliminates configuration ceremony
  • Event interception enables natural server-to-client push
  • Pluggable serialization balances debuggability and performance
  • Pluggable transports adapt to different deployment scenarios
  • Static proxy support ensures AOT compatibility

Understanding these mechanisms helps you:

  • Debug issues more effectively
  • Choose appropriate transports and serializers
  • Design services that work with (not against) the framework
  • Appreciate why certain patterns are recommended

The goal of all this machinery is simple: let you write distributed code that looks and feels like local code. The complexity is hidden so you can focus on your application logic.


Next up: Advanced WitRPC Usage: Composite Services for Multi-Interface Servers, hosting multiple services on a single connection.

This is part 6 of a series on WitRPC. See previous posts: Introducing WitRPC, Getting Started, Real-World Benefits, WitRPC vs. gRPC, and Migrating to WitRPC.