In the previous post, I introduced WitRPC and what version 2.3 brings. Today, we're going hands-on. By the end of this tutorial, you'll have a working client-server application where the client calls methods on the server and receives real-time event notifications, all through strongly-typed C# interfaces.
Let's build something together.
What We're Building
We'll create a simple task processing service that demonstrates WitRPC's core capabilities:
- Client-to-server calls: The client will start and stop a processing task
- Server-to-client events: The server will push progress updates back to the client in real-time
- Async operations: We'll use async/await for non-blocking communication
The complete example will consist of three projects:
- A shared library containing the service interface (the contract)
- A server application that implements and hosts the service
- A client application that connects and uses the service
Prerequisites
Before we begin, make sure you have:
- .NET 8 SDK (or later) installed
- Your favorite IDE (Visual Studio, Rider, or VS Code)
- Basic familiarity with C# interfaces and async/await
Step 1: Create the Solution Structure
Let's start by creating our solution and projects:
# Create a new solution
dotnet new sln -n WitRpcDemo
# Create the shared contracts library
dotnet new classlib -n WitRpcDemo.Contracts
dotnet sln add WitRpcDemo.Contracts
# Create the server application
dotnet new console -n WitRpcDemo.Server
dotnet sln add WitRpcDemo.Server
# Create the client application
dotnet new console -n WitRpcDemo.Client
dotnet sln add WitRpcDemo.ClientNow add project references so both the server and client can access the shared contracts:
dotnet add WitRpcDemo.Server reference WitRpcDemo.Contracts
dotnet add WitRpcDemo.Client reference WitRpcDemo.ContractsStep 2: Install WitRPC Packages
WitRPC is modular: you install only what you need. For this tutorial, we'll use WebSocket transport and JSON serialization.
For the server project:
cd WitRpcDemo.Server
dotnet add package OutWit.Communication.Server
dotnet add package OutWit.Communication.Server.WebSocketFor the client project:
cd WitRpcDemo.Client
dotnet add package OutWit.Communication.Client
dotnet add package OutWit.Communication.Client.WebSocketStep 3: Define the Service Contract
This is the heart of WitRPC. Your service contract is just a plain C# interface: no special attributes, no proto files, no XML configuration.
In the WitRpcDemo.Contracts project, create a file called ITaskService.cs:
namespace WitRpcDemo.Contracts;
/// <summary>
/// Service contract for task processing operations.
/// This interface is shared between client and server.
/// </summary>
public interface ITaskService
{
// ============================================
// EVENTS: Server-to-client notifications
// ============================================
/// <summary>
/// Raised when task processing begins.
/// </summary>
event Action<string> TaskStarted;
/// <summary>
/// Raised periodically to report progress (0.0 to 1.0).
/// </summary>
event Action<double> ProgressChanged;
/// <summary>
/// Raised when task processing completes successfully.
/// </summary>
event Action<string> TaskCompleted;
/// <summary>
/// Raised if an error occurs during processing.
/// </summary>
event Action<string> TaskFailed;
// ============================================
// METHODS: Client-to-server operations
// ============================================
/// <summary>
/// Starts processing a task with the given name.
/// </summary>
/// <param name="taskName">Name of the task to process</param>
/// <returns>True if the task was started successfully</returns>
bool StartTask(string taskName);
/// <summary>
/// Stops the currently running task.
/// </summary>
void StopTask();
/// <summary>
/// Gets the current status of task processing.
/// </summary>
/// <returns>Current status message</returns>
string GetStatus();
/// <summary>
/// Processes data asynchronously and returns the result.
/// </summary>
/// <param name="input">Input data to process</param>
/// <returns>Processed result</returns>
Task<string> ProcessDataAsync(string input);
}What's happening here?
- Events (
TaskStarted,ProgressChanged,TaskCompleted,TaskFailed) define callbacks from server to client. When the server raises these events, all connected clients receive the notification. - Methods (
StartTask,StopTask,GetStatus,ProcessDataAsync) define operations the client can call on the server. Notice we have both synchronous methods (returningbool,void,string) and an async method (returningTask<string>).
This interface is the single source of truth for your client-server communication. Both sides use it, giving you compile-time type safety and full IDE support.
Step 4: Implement the Service on the Server
Now let's implement the interface. In the WitRpcDemo.Server project, create TaskService.cs:
using WitRpcDemo.Contracts;
namespace WitRpcDemo.Server;
public class TaskService : ITaskService
{
// Events are initialized with empty delegates to avoid null checks
public event Action<string> TaskStarted = delegate { };
public event Action<double> ProgressChanged = delegate { };
public event Action<string> TaskCompleted = delegate { };
public event Action<string> TaskFailed = delegate { };
private CancellationTokenSource? _cancellationTokenSource;
private bool _isRunning;
private string _currentTask = string.Empty;
public bool StartTask(string taskName)
{
if (_isRunning)
{
Console.WriteLine($"[Server] Cannot start '{taskName}' - another task is already running.");
return false;
}
_currentTask = taskName;
_isRunning = true;
_cancellationTokenSource = new CancellationTokenSource();
Console.WriteLine($"[Server] Starting task: {taskName}");
// Notify all connected clients that the task has started
TaskStarted(taskName);
// Run the processing in the background
_ = RunProcessingAsync(_cancellationTokenSource.Token);
return true;
}
public void StopTask()
{
if (!_isRunning)
{
Console.WriteLine("[Server] No task is running.");
return;
}
Console.WriteLine($"[Server] Stopping task: {_currentTask}");
_cancellationTokenSource?.Cancel();
}
public string GetStatus()
{
if (_isRunning)
{
return $"Currently processing: {_currentTask}";
}
return "Idle - no task running";
}
public async Task<string> ProcessDataAsync(string input)
{
Console.WriteLine($"[Server] Processing data: {input}");
// Simulate some async work
await Task.Delay(500);
var result = $"Processed: {input.ToUpperInvariant()} (length: {input.Length})";
Console.WriteLine($"[Server] Result: {result}");
return result;
}
private async Task RunProcessingAsync(CancellationToken cancellationToken)
{
try
{
// Simulate a task with 10 steps
for (int i = 1; i <= 10; i++)
{
if (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("[Server] Task was cancelled.");
TaskFailed("Task was cancelled by user.");
return;
}
// Simulate work
await Task.Delay(500, cancellationToken);
// Report progress to all connected clients
double progress = i / 10.0;
Console.WriteLine($"[Server] Progress: {progress:P0}");
ProgressChanged(progress);
}
// Task completed successfully
var result = $"Task '{_currentTask}' completed successfully!";
Console.WriteLine($"[Server] {result}");
TaskCompleted(result);
}
catch (OperationCanceledException)
{
Console.WriteLine("[Server] Task was cancelled.");
TaskFailed("Task was cancelled.");
}
catch (Exception ex)
{
Console.WriteLine($"[Server] Task failed: {ex.Message}");
TaskFailed($"Error: {ex.Message}");
}
finally
{
_isRunning = false;
_currentTask = string.Empty;
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
}
}Key points:
Raising events is as simple as invoking them like methods:
TaskStarted(taskName),ProgressChanged(progress), etc. WitRPC automatically sends these to all connected clients.Events are initialized with empty delegates (
= delegate { }), a common C# pattern to avoid null-reference exceptions when no handlers are attached.Background processing runs in a separate task, periodically raising
ProgressChangedevents. This demonstrates WitRPC's real-time push capabilities.
Step 5: Set Up the WitRPC Server
Now let's wire everything together. Update Program.cs in the WitRpcDemo.Server project:
using OutWit.Communication.Server;
using OutWit.Communication.Server.WebSocket;
using WitRpcDemo.Contracts;
using WitRpcDemo.Server;
Console.WriteLine("=== WitRPC Demo Server ===");
Console.WriteLine();
// Create the service instance
var taskService = new TaskService();
// Build the server with configuration
var server = WitServerBuilder.Build(options =>
{
// Register our service implementation
options.WithService<ITaskService>(taskService);
// Use JSON serialization (human-readable, good for debugging)
options.WithJson();
});
// Configure WebSocket transport
var transportOptions = WebSocketServerTransportOptions.Default
.WithUrl("http://localhost:5000")
.WithMaxNumberOfClients(10);
// Initialize the server with transport
server.WithTransport(transportOptions);
// Start listening for connections
server.StartWaitingForConnection();
Console.WriteLine("Server is running at ws://localhost:5000");
Console.WriteLine("Press any key to stop the server...");
Console.WriteLine();
// Keep the server running until a key is pressed
Console.ReadKey();
// Graceful shutdown
Console.WriteLine();
Console.WriteLine("Shutting down...");
server.StopWaitingForConnection();
Console.WriteLine("Server stopped.");Configuration breakdown:
| Method | Purpose |
|---|---|
WithService<ITaskService>(taskService) |
Registers our service implementation |
WithJson() |
Uses JSON for serialization (easy to debug) |
WithUrl("http://localhost:5000") |
Sets the WebSocket endpoint |
WithMaxNumberOfClients(10) |
Limits concurrent connections |
StartWaitingForConnection() |
Starts accepting clients (non-blocking) |
Step 6: Build the Client
Finally, let's create the client. Update Program.cs in the WitRpcDemo.Client project:
using OutWit.Communication.Client;
using OutWit.Communication.Client.WebSocket;
using WitRpcDemo.Contracts;
Console.WriteLine("=== WitRPC Demo Client ===");
Console.WriteLine();
// Build the client with matching configuration
var client = WitClientBuilder.Build(options =>
{
// Use JSON serialization (must match server)
options.WithJson();
});
// Configure WebSocket transport to connect to the server
var transportOptions = WebSocketClientTransportOptions.Default
.WithUrl("ws://localhost:5000");
// Initialize the client with transport
client.WithTransport(transportOptions);
// Connect to the server with a 5-second timeout
Console.WriteLine("Connecting to server...");
bool connected = await client.ConnectAsync(TimeSpan.FromSeconds(5), CancellationToken.None);
if (!connected)
{
Console.WriteLine("Failed to connect to the server. Is it running?");
return;
}
Console.WriteLine("Connected successfully!");
Console.WriteLine();
// Get the service proxy - this implements ITaskService
// but forwards all calls to the server
ITaskService taskService = client.GetService<ITaskService>();
// Subscribe to server events
taskService.TaskStarted += taskName =>
{
Console.WriteLine($"[Event] Task started: {taskName}");
};
taskService.ProgressChanged += progress =>
{
// Draw a simple progress bar
int filled = (int)(progress * 20);
string bar = new string('█', filled) + new string('░', 20 - filled);
Console.WriteLine($"[Event] Progress: [{bar}] {progress:P0}");
};
taskService.TaskCompleted += result =>
{
Console.WriteLine($"[Event] Task completed: {result}");
Console.WriteLine();
};
taskService.TaskFailed += error =>
{
Console.WriteLine($"[Event] Task failed: {error}");
Console.WriteLine();
};
// Interactive demo loop
while (true)
{
Console.WriteLine("Commands: [1] Start task [2] Stop task [3] Get status [4] Process data [Q] Quit");
Console.Write("> ");
var input = Console.ReadLine()?.Trim().ToUpperInvariant();
switch (input)
{
case "1":
Console.Write("Enter task name: ");
var taskName = Console.ReadLine() ?? "Demo Task";
bool started = taskService.StartTask(taskName);
Console.WriteLine(started ? "Task start requested." : "Failed to start task.");
break;
case "2":
taskService.StopTask();
Console.WriteLine("Stop requested.");
break;
case "3":
string status = taskService.GetStatus();
Console.WriteLine($"Status: {status}");
break;
case "4":
Console.Write("Enter data to process: ");
var data = Console.ReadLine() ?? "Hello";
string result = await taskService.ProcessDataAsync(data);
Console.WriteLine($"Result: {result}");
break;
case "Q":
Console.WriteLine("Disconnecting...");
await client.DisconnectAsync();
Console.WriteLine("Goodbye!");
return;
default:
Console.WriteLine("Unknown command.");
break;
}
Console.WriteLine();
}What's happening here:
Client configuration mirrors the server: same serializer (JSON), matching transport (WebSocket to the same URL).
ConnectAsyncwith timeout: We wait up to 5 seconds for the connection. Always check the return value!GetService<ITaskService>(): This returns a proxy object that implementsITaskService. It looks and feels like a local object, but every method call goes to the server.Event subscriptions: We attach handlers to the proxy's events. When the server raises
ProgressChanged, our lambda runs on the client.Method calls:
taskService.StartTask(taskName)sends the call to the server and returns the result. It's that simple.
Step 7: Run the Demo
Open two terminal windows.
Terminal 1, the server:
cd WitRpcDemo.Server
dotnet runYou should see:
=== WitRPC Demo Server ===
Server is running at ws://localhost:5000
Press any key to stop the server...Terminal 2, the client:
cd WitRpcDemo.Client
dotnet runYou should see:
=== WitRPC Demo Client ===
Connecting to server...
Connected successfully!
Commands: [1] Start task [2] Stop task [3] Get status [4] Process data [Q] Quit
>Now try the commands:
- Press
1and enter a task name like "My First Task" - Watch as progress events stream from the server to the client in real-time
- Try pressing
2during processing to cancel the task - Press
4to test async data processing - Press
Qto quit
You'll see the server and client communicating in both directions: the client calls methods, and the server pushes events back.
What Just Happened?
Let's recap what WitRPC did for us:
No manual serialization: We passed strings, doubles, and booleans without writing any JSON parsing code.
No HTTP routing: No controllers, no endpoints, no route attributes. Just interface methods.
No event infrastructure: The server raises C# events, and clients receive them. No SignalR hubs, no message queues.
Type safety throughout: If we rename a method or change a parameter type, the compiler catches it immediately in both projects.
Clean separation: The shared interface is the contract. The server implements it; the client consumes it. That's the entire architecture.
Next Steps
You now have a working WitRPC application! Here are some things to try:
Switch transports: Replace WebSocket with TCP for better performance:
// Server
options.WithTcp(5000, maxNumberOfClients: 10);
// Client
options.WithTcp("localhost", 5000);Switch serializers: Use MessagePack for smaller, faster messages:
// Both server and client
options.WithMessagePack();Add security: Enable encryption and access tokens:
// Both server and client
options.WithEncryption();
options.WithAccessToken("my-secret-token");Host multiple services: Register several interfaces on one server:
options.WithServices()
.AddService<ITaskService>(new TaskService())
.AddService<IUserService>(new UserService())
.Build();Complete Source Code
The complete source code for this tutorial is available on GitHub.
Coming Up Next
In the next post, we'll look at how these design choices translate into productivity and performance compared to traditional approaches, and at the scenarios where WitRPC is the right tool for the job.
Until then, happy coding!
This is part 2 of a series on WitRPC. See Part 1: Introducing WitRPC for an overview of the framework.