If you're maintaining a .NET application that uses WCF or SignalR for client-server communication, you've likely felt the friction. WCF is stuck in the .NET Framework past, requiring workarounds for modern .NET. SignalR works but lacks the type safety you want for complex APIs. Both have served well, but there's a cleaner path forward.
This post provides practical guidance for migrating to WitRPC, with side-by-side code comparisons and a step-by-step approach that minimizes risk.
Why Consider Migration?
Before diving into the how, let's address the why.
The WCF Challenge
WCF was a powerful framework, but it's showing its age:
- Not supported on .NET Core/5+: Microsoft didn't port WCF server-side to modern .NET. CoreWCF exists as a community project, but it's a compatibility layer, not a path forward.
- Verbose configuration: XML config files, binding configurations, endpoint behaviors. The ceremony is extensive.
- Complex duplex callbacks: WCF's callback contracts work but require separate interfaces and careful session management.
- Windows-centric: Many WCF features assume Windows (MSMQ, Windows authentication, etc.).
The SignalR Limitation
SignalR is modern and works well for its intended purpose, but:
- Loosely typed: Method invocations use string names. Rename a method? Runtime errors await.
- Hub-centric model: Everything routes through hubs. For structured service APIs, this feels awkward.
- Web-focused: SignalR excels at browser-to-server communication but adds overhead for pure .NET-to-.NET scenarios.
What WitRPC Offers
WitRPC addresses these pain points:
| Aspect | WCF | SignalR | WitRPC |
|---|---|---|---|
| .NET Core/5+ support | ❌ (CoreWCF only) | ✅ | ✅ |
| Type safety | ✅ | ❌ | ✅ |
| Configuration | XML-heavy | Code-based | Code-based |
| Events/Callbacks | Complex | String-based | Native C# events |
| Transport flexibility | Good | HTTP-only | Excellent |
If you're already using WCF or SignalR and want to modernize, WitRPC provides a natural upgrade path.
Part 1: Migrating from WCF to WitRPC
WCF developers will find WitRPC familiar, since both use interface-based contracts. The migration is often straightforward because the mental model is similar.
What Stays the Same
- Interface-based contracts: Both WCF and WitRPC define services as interfaces
- Proxy-based client calls: Both generate client proxies that implement the service interface
- Request/response pattern: Method calls work the same way conceptually
What Changes
| WCF | WitRPC |
|---|---|
[ServiceContract], [OperationContract] attributes |
No attributes needed |
| XML configuration files | Fluent code configuration |
[CallbackContract] for duplex |
C# events in the interface |
ChannelFactory<T> |
client.GetService<T>() |
| Complex binding configurations | Simple transport selection |
Migration Example: Service Contract
WCF (Before):
// Service contract with WCF attributes
[ServiceContract(CallbackContract = typeof(IProcessingCallback))]
public interface IProcessingService
{
[OperationContract]
bool StartProcessing(string taskName);
[OperationContract]
void StopProcessing();
[OperationContract]
ProcessingStatus GetStatus();
}
// Separate callback contract for duplex communication
public interface IProcessingCallback
{
[OperationContract(IsOneWay = true)]
void OnProgressChanged(double progress);
[OperationContract(IsOneWay = true)]
void OnProcessingCompleted(string result);
}WitRPC (After):
// Single interface with events, no attributes needed
public interface IProcessingService
{
// Events replace the callback contract
event Action<double> ProgressChanged;
event Action<string> ProcessingCompleted;
// Methods stay essentially the same
bool StartProcessing(string taskName);
void StopProcessing();
ProcessingStatus GetStatus();
}What changed:
- Removed all
[ServiceContract]and[OperationContract]attributes - Merged the callback interface into events on the main interface
- Simplified callback signatures (no
IsOneWayneeded: events are naturally one-way)
Migration Example: Service Implementation
WCF (Before):
[ServiceBehavior(InstanceContextMode = InstanceContextMode.PerSession)]
public class ProcessingService : IProcessingService
{
private IProcessingCallback _callback;
public ProcessingService()
{
// Get callback channel from operation context
_callback = OperationContext.Current
.GetCallbackChannel<IProcessingCallback>();
}
public bool StartProcessing(string taskName)
{
Task.Run(() => DoProcessing(taskName));
return true;
}
private void DoProcessing(string taskName)
{
for (int i = 0; i <= 100; i += 10)
{
Thread.Sleep(500);
_callback.OnProgressChanged(i / 100.0);
}
_callback.OnProcessingCompleted($"Completed: {taskName}");
}
// ... other methods
}WitRPC (After):
public class ProcessingService : IProcessingService
{
// Events initialized with empty delegates
public event Action<double> ProgressChanged = delegate { };
public event Action<string> ProcessingCompleted = delegate { };
public bool StartProcessing(string taskName)
{
Task.Run(() => DoProcessing(taskName));
return true;
}
private void DoProcessing(string taskName)
{
for (int i = 0; i <= 100; i += 10)
{
Thread.Sleep(500);
ProgressChanged(i / 100.0); // Just raise the event
}
ProcessingCompleted($"Completed: {taskName}");
}
// ... other methods
}What changed:
- Removed
[ServiceBehavior]attribute - Removed
OperationContextand callback channel retrieval - Events are just standard C# events: raise them directly
- No session management needed for callbacks
Migration Example: Server Hosting
WCF (Before):
// App.config or Web.config
<system.serviceModel>
<services>
<service name="MyApp.ProcessingService">
<endpoint address="net.tcp://localhost:8000/ProcessingService"
binding="netTcpBinding"
contract="MyApp.IProcessingService" />
</service>
</services>
<bindings>
<netTcpBinding>
<binding name="tcpBinding"
maxReceivedMessageSize="2147483647"
receiveTimeout="00:30:00">
<security mode="None" />
</binding>
</netTcpBinding>
</bindings>
</system.serviceModel>
// Code
using (var host = new ServiceHost(typeof(ProcessingService)))
{
host.Open();
Console.WriteLine("Service running...");
Console.ReadLine();
}WitRPC (After):
// All configuration in code, no XML
var server = WitServerBuilder.Build(options =>
{
options.WithService<IProcessingService>(new ProcessingService());
options.WithJson();
});
var transport = TcpServerTransportOptions.Default
.WithPort(8000)
.WithMaxNumberOfClients(100);
server.WithTransport(transport);
server.StartWaitingForConnection();
Console.WriteLine("Service running...");
Console.ReadLine();
server.StopWaitingForConnection();What changed:
- All configuration moved to fluent C# code
- No XML files to maintain
- Simpler, more readable setup
- Easy to see all settings in one place
Migration Example: Client Connection
WCF (Before):
// Client must implement callback interface
public class CallbackHandler : IProcessingCallback
{
public void OnProgressChanged(double progress)
{
Console.WriteLine($"Progress: {progress:P0}");
}
public void OnProcessingCompleted(string result)
{
Console.WriteLine($"Done: {result}");
}
}
// Connection setup
var callback = new CallbackHandler();
var context = new InstanceContext(callback);
var factory = new DuplexChannelFactory<IProcessingService>(
context,
new NetTcpBinding(),
new EndpointAddress("net.tcp://localhost:8000/ProcessingService"));
IProcessingService service = factory.CreateChannel();
((ICommunicationObject)service).Open();
bool started = service.StartProcessing("MyTask");WitRPC (After):
var client = WitClientBuilder.Build(options =>
{
options.WithJson();
});
var transport = TcpClientTransportOptions.Default
.WithAddress("localhost", 8000);
client.WithTransport(transport);
await client.ConnectAsync(TimeSpan.FromSeconds(5));
var service = client.GetService<IProcessingService>();
// Subscribe to events directly, no callback class needed
service.ProgressChanged += progress =>
Console.WriteLine($"Progress: {progress:P0}");
service.ProcessingCompleted += result =>
Console.WriteLine($"Done: {result}");
bool started = service.StartProcessing("MyTask");What changed:
- No callback handler class needed
- No
DuplexChannelFactoryorInstanceContext - Events subscribed like any C# event
- Async connection with timeout
- Cleaner, more intuitive code
Part 2: Migrating from SignalR to WitRPC
SignalR migration requires a different approach: you're moving from a loosely-typed hub model to a strongly-typed interface model.
What Stays the Same
- Real-time communication: Both support server-to-client push
- WebSocket transport: Both can use WebSockets
- Async patterns: Both work well with async/await
What Changes
| SignalR | WitRPC |
|---|---|
| Hub classes | Service interfaces |
| String method names | Typed method calls |
Clients.Caller.SendAsync("Method", args) |
Event invocation |
connection.On("Method", handler) |
Event subscription |
| Groups and broadcast | Direct event multicast |
Migration Example: Hub to Interface
SignalR (Before):
// Server: Hub class
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
{
// String-based method invocation
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
public async Task JoinRoom(string roomName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
await Clients.Group(roomName).SendAsync("UserJoined", Context.ConnectionId);
}
}
// Client
connection.On<string, string>("ReceiveMessage", (user, message) =>
{
Console.WriteLine($"{user}: {message}");
});
await connection.InvokeAsync("SendMessage", "Alice", "Hello!");WitRPC (After):
// Shared interface
public interface IChatService
{
event Action<string, string> MessageReceived;
event Action<string> UserJoined;
Task SendMessageAsync(string user, string message);
Task JoinRoomAsync(string roomName);
}
// Server implementation
public class ChatService : IChatService
{
public event Action<string, string> MessageReceived = delegate { };
public event Action<string> UserJoined = delegate { };
public Task SendMessageAsync(string user, string message)
{
MessageReceived(user, message); // Broadcasts to all subscribers
return Task.CompletedTask;
}
public Task JoinRoomAsync(string roomName)
{
// Room logic here
UserJoined(roomName);
return Task.CompletedTask;
}
}
// Client
var chat = client.GetService<IChatService>();
chat.MessageReceived += (user, message) =>
{
Console.WriteLine($"{user}: {message}");
};
await chat.SendMessageAsync("Alice", "Hello!");What changed:
- String method names → typed interface methods
SendAsync("ReceiveMessage", ...)→ event invocationconnection.On("ReceiveMessage", ...)→ standard event subscription- Compile-time checking everywhere
Migration Example: Complex Hub Methods
SignalR (Before):
public class DashboardHub : Hub
{
public async Task<DashboardData> GetDashboard()
{
return await _dashboardService.GetDataAsync();
}
public async Task SubscribeToMetrics(string[] metricIds)
{
foreach (var id in metricIds)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"metric_{id}");
}
}
// Called by background service to push updates
public static async Task PushMetricUpdate(
IHubContext<DashboardHub> hubContext,
string metricId,
double value)
{
await hubContext.Clients.Group($"metric_{metricId}")
.SendAsync("MetricUpdated", metricId, value);
}
}
// Client
connection.On<string, double>("MetricUpdated", (id, value) =>
{
UpdateChart(id, value);
});
var data = await connection.InvokeAsync<DashboardData>("GetDashboard");
await connection.InvokeAsync("SubscribeToMetrics", new[] { "cpu", "memory" });WitRPC (After):
// Interface
public interface IDashboardService
{
event Action<string, double> MetricUpdated;
Task<DashboardData> GetDashboardAsync();
Task SubscribeToMetricsAsync(string[] metricIds);
}
// Implementation
public class DashboardService : IDashboardService
{
public event Action<string, double> MetricUpdated = delegate { };
private readonly HashSet<string> _subscribedMetrics = new();
public async Task<DashboardData> GetDashboardAsync()
{
return await _repository.GetDataAsync();
}
public Task SubscribeToMetricsAsync(string[] metricIds)
{
foreach (var id in metricIds)
{
_subscribedMetrics.Add(id);
}
return Task.CompletedTask;
}
// Called by background service
public void PushMetricUpdate(string metricId, double value)
{
if (_subscribedMetrics.Contains(metricId))
{
MetricUpdated(metricId, value);
}
}
}
// Client
var dashboard = client.GetService<IDashboardService>();
dashboard.MetricUpdated += (id, value) =>
{
UpdateChart(id, value);
};
var data = await dashboard.GetDashboardAsync();
await dashboard.SubscribeToMetricsAsync(new[] { "cpu", "memory" });Benefits of the WitRPC approach:
GetDashboardreturn type is explicit, withoutInvokeAsync<T>and manual type specification- Subscription logic is in your control, not tied to SignalR's group system
- All method signatures visible in the interface: a self-documenting API
Step-by-Step Migration Strategy
Whether migrating from WCF or SignalR, follow this incremental approach to minimize risk.
Step 1: Create Shared Contracts
Start by defining your WitRPC interfaces in a new shared library:
MyApp.Contracts/
├── IProcessingService.cs
├── IChatService.cs
├── Models/
│ ├── ProcessingStatus.cs
│ └── DashboardData.cs
└── MyApp.Contracts.csprojThese interfaces should mirror your existing service capabilities but use WitRPC conventions (events instead of callbacks).
Step 2: Implement WitRPC Services
Create new service implementations that implement your WitRPC interfaces. Initially, these can delegate to your existing business logic:
public class ProcessingServiceAdapter : IProcessingService
{
private readonly LegacyProcessingService _legacy;
public event Action<double> ProgressChanged = delegate { };
public event Action<string> ProcessingCompleted = delegate { };
public ProcessingServiceAdapter(LegacyProcessingService legacy)
{
_legacy = legacy;
// Bridge legacy events to WitRPC events
_legacy.ProgressUpdated += (s, e) => ProgressChanged(e.Progress);
_legacy.Completed += (s, e) => ProcessingCompleted(e.Result);
}
public bool StartProcessing(string taskName)
{
return _legacy.Start(taskName);
}
// ... other methods delegate to _legacy
}Step 3: Run Both Systems in Parallel
Host WitRPC alongside your existing WCF/SignalR endpoints:
// Existing WCF host continues running
var wcfHost = new ServiceHost(typeof(ProcessingService));
wcfHost.Open();
// New WitRPC host runs in parallel
var witRpcServer = WitServerBuilder.Build(options =>
{
options.WithService<IProcessingService>(
new ProcessingServiceAdapter(existingService));
});
witRpcServer.WithTransport(tcpOptions);
witRpcServer.StartWaitingForConnection();
Console.WriteLine("Both WCF and WitRPC endpoints available");Step 4: Migrate Clients Gradually
Update clients one at a time to use WitRPC:
// Feature flag or configuration
if (UseWitRpc)
{
var client = WitClientBuilder.Build(options => { });
client.WithTransport(tcpOptions);
await client.ConnectAsync();
_service = client.GetService<IProcessingService>();
}
else
{
// Legacy WCF client
var factory = new ChannelFactory<ILegacyProcessingService>(...);
_service = new LegacyServiceWrapper(factory.CreateChannel());
}Step 5: Retire Legacy Endpoints
Once all clients have migrated:
- Remove legacy WCF/SignalR hosting code
- Remove adapter layers if business logic was refactored
- Delete legacy client code
- Remove legacy NuGet packages
Common Pitfalls and Solutions
Pitfall 1: Forgetting to Match Serializers
Problem: Client uses JSON, server uses MessagePack → connection fails silently.
Solution: Explicitly configure both sides:
// Server
options.WithMessagePack();
// Client: MUST match
options.WithMessagePack();Pitfall 2: Event Subscription Timing
Problem: Events raised before client subscribes are missed.
Solution: Subscribe to events before triggering operations:
var service = client.GetService<IProcessingService>();
// Subscribe FIRST
service.ProgressChanged += HandleProgress;
service.ProcessingCompleted += HandleCompletion;
// THEN start the operation
service.StartProcessing("task");Pitfall 3: Thread Affinity in UI Apps
Problem: WitRPC events arrive on background threads; UI updates fail.
Solution: Marshal to UI thread:
// WPF
service.ProgressChanged += progress =>
{
Dispatcher.Invoke(() => ProgressBar.Value = progress);
};
// WinForms
service.ProgressChanged += progress =>
{
BeginInvoke(() => progressBar.Value = (int)(progress * 100));
};Pitfall 4: Connection Lifecycle
Problem: Calling methods after disconnect throws unclear exceptions.
Solution: Handle connection state:
client.Disconnected += () =>
{
_isConnected = false;
ShowReconnectUI();
};
// Before calls
if (!_isConnected)
{
await client.ConnectAsync(TimeSpan.FromSeconds(5));
}Performance Expectations
After migration, you can expect:
| Scenario | Measured result |
|---|---|
| Local IPC (Named Pipes/MMF) | 3-4x faster than CoreWCF in benchmarks |
| Large payloads over the internet | ~30-40% faster than SignalR, ~40% faster than CoreWCF |
| Local IPC vs SignalR | Over 2x faster with memory-mapped files |
The biggest gains come from:
- Eliminating XML parsing (WCF config)
- Using binary serializers (MessagePack/MemoryPack)
- Choosing optimal transport for your scenario
Conclusion
Migrating from WCF or SignalR to WitRPC is a practical path to modernizing your .NET communication layer:
From WCF:
- Keep the interface-based mental model you know
- Eliminate verbose configuration and attributes
- Get simpler callbacks through native C# events
- Run on modern .NET without compatibility shims
From SignalR:
- Gain compile-time type safety
- Replace string-based invocations with typed methods
- Simplify event handling with standard C# patterns
- Keep real-time capabilities with better structure
The migration can be incremental: run both systems in parallel, migrate clients gradually, and retire legacy code when ready. The result is cleaner code, better performance, and a foundation ready for future .NET evolution.
Next up: Under the Hood of WitRPC: Dynamic Proxies and Event Handling, a deep dive into how WitRPC works.
This is part 5 of a series on WitRPC. See previous posts: Introducing WitRPC, Getting Started, Real-World Benefits, and WitRPC vs. gRPC.