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:

  1. A shared library containing the service interface (the contract)
  2. A server application that implements and hosts the service
  3. 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:

bash
# 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.Client

Now add project references so both the server and client can access the shared contracts:

bash
dotnet add WitRpcDemo.Server reference WitRpcDemo.Contracts
dotnet add WitRpcDemo.Client reference WitRpcDemo.Contracts

Step 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:

bash
cd WitRpcDemo.Server
dotnet add package OutWit.Communication.Server
dotnet add package OutWit.Communication.Server.WebSocket

For the client project:

bash
cd WitRpcDemo.Client
dotnet add package OutWit.Communication.Client
dotnet add package OutWit.Communication.Client.WebSocket

Step 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:

csharp
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 (returning bool, void, string) and an async method (returning Task<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:

csharp
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:

  1. Raising events is as simple as invoking them like methods: TaskStarted(taskName), ProgressChanged(progress), etc. WitRPC automatically sends these to all connected clients.

  2. Events are initialized with empty delegates (= delegate { }), a common C# pattern to avoid null-reference exceptions when no handlers are attached.

  3. Background processing runs in a separate task, periodically raising ProgressChanged events. 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:

csharp
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:

csharp
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:

  1. Client configuration mirrors the server: same serializer (JSON), matching transport (WebSocket to the same URL).

  2. ConnectAsync with timeout: We wait up to 5 seconds for the connection. Always check the return value!

  3. GetService<ITaskService>(): This returns a proxy object that implements ITaskService. It looks and feels like a local object, but every method call goes to the server.

  4. Event subscriptions: We attach handlers to the proxy's events. When the server raises ProgressChanged, our lambda runs on the client.

  5. 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:

bash
cd WitRpcDemo.Server
dotnet run

You should see:

=== WitRPC Demo Server ===

Server is running at ws://localhost:5000
Press any key to stop the server...

Terminal 2, the client:

bash
cd WitRpcDemo.Client
dotnet run

You 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:

  1. Press 1 and enter a task name like "My First Task"
  2. Watch as progress events stream from the server to the client in real-time
  3. Try pressing 2 during processing to cancel the task
  4. Press 4 to test async data processing
  5. Press Q to 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:

  1. No manual serialization: We passed strings, doubles, and booleans without writing any JSON parsing code.

  2. No HTTP routing: No controllers, no endpoints, no route attributes. Just interface methods.

  3. No event infrastructure: The server raises C# events, and clients receive them. No SignalR hubs, no message queues.

  4. Type safety throughout: If we rename a method or change a parameter type, the compiler catches it immediately in both projects.

  5. 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:

csharp
// Server
options.WithTcp(5000, maxNumberOfClients: 10);

// Client  
options.WithTcp("localhost", 5000);

Switch serializers: Use MessagePack for smaller, faster messages:

csharp
// Both server and client
options.WithMessagePack();

Add security: Enable encryption and access tokens:

csharp
// Both server and client
options.WithEncryption();
options.WithAccessToken("my-secret-token");

Host multiple services: Register several interfaces on one server:

csharp
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.