A WitRPC service contract is a plain C# interface shared by client and server. The server implements it; the client calls it through a proxy. This page covers how to design that interface well.
Anatomy of a contract
A contract declares two kinds of members:
- Methods are operations the client calls on the server.
- Events are callbacks the server raises on the client.
public interface IExampleService
{
// Events (server-to-client callbacks)
event Action ProcessingStarted;
event Action<double> ProgressChanged;
event Action<string> ProcessingCompleted;
// Methods (client-to-server calls)
bool StartProcessing();
void StopProcessing();
Task<string> ProcessDataAsync(string data);
}Methods can return synchronously (bool, void) or asynchronously (Task, Task<T>); both styles can coexist in one contract. On the client side, calling a method sends a request to the server and returns its response; subscribing to an event registers a handler that runs whenever the server raises it.
No special attributes are required. WitRPC treats all public methods and events of the interface as the contract. The one optional attribute is [ProxyTarget("...")], a naming hint for the compile-time static proxy generator used in AOT scenarios; see Blazor WebAssembly & AOT →.
Best practices for contract design
Use simple, serializable types. Parameters and return values travel over the wire, so they must serialize cleanly: primitives, strings, enums, DTO classes, and collections of these. Framework types tied to a process or a machine (HttpContext, DbConnection, streams, delegates as parameters) do not belong in a contract.
Keep interfaces coherent. One interface should represent one area of responsibility. When a service grows several unrelated clusters of methods, split it into separate interfaces; a single server can host them all side by side (see Composite Services →).
Make long operations async. Any method that does real work should return Task or Task<T>. The client awaits it without blocking, and the server is free to run the work however it likes.
Design event signatures deliberately. An event fires across the network, so its payload should carry what subscribers need and nothing more. Prefer a small DTO over a long parameter list when the payload grows past two or three values; adding a field to a DTO later does not break the event signature.
Report progress through events, results through return values. A natural division: ProcessDataAsync returns the final result to its caller, while ProgressChanged keeps every subscriber informed along the way.
Sharing the contract
Both sides must compile against the same interface, so the contract lives in a shared location:
- A shared project in the same solution is the usual choice: a class library referenced by both server and client projects.
- A NuGet package works better when client and server live in different repositories or teams. The package version then becomes the API version, tracked alongside code like any other dependency.
Either way, the contract assembly should stay lean: interfaces, DTOs, enums, and delegate types only. Implementation logic belongs on the server.
Custom types
Complex data crosses the wire as ordinary classes that both sides reference from the shared contract assembly.
DTOs need a parameterless constructor and public properties with getters and setters. They carry data; logic lives elsewhere.
public class CustomerInfo
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public DateTime CreatedAt { get; set; }
}Enums serialize compactly and make states explicit:
public enum OrderStatus
{
Pending,
Processing,
Shipped,
Delivered,
Cancelled
}Collections (List<T>, IEnumerable<T>, arrays) are fully supported as parameters and return values, provided the element type is serializable:
public interface IOrderService
{
Task<List<OrderInfo>> GetOrdersAsync(int customerId);
Task<bool> UpdateOrdersAsync(IEnumerable<OrderUpdate> updates);
}Types to avoid: object graphs with circular references, classes without public properties, and anything bound to local process state. If a type cannot be meaningfully reconstructed on the other side of the wire, it does not belong in the contract.
If you plan to use a specific serializer in production (MessagePack, MemoryPack, ProtoBuf), check its attribute requirements for custom types early; Transports & Serialization → covers the differences.
Errors are part of the contract
When a service method throws, the exception travels to the client wrapped in WitExceptionFault, so callers can distinguish server-side failures from communication problems:
WitException— base class for all WitRPC errorsWitExceptionFault— the server implementation threw an exceptionWitExceptionTransport— a transport-level failure (connection lost, endpoint unreachable)WitExceptionSerialization— a payload could not be serialized or deserializedWitExceptionEncryption— the encryption handshake or a crypto operation failed
try
{
var result = await service.ProcessDataAsync(data);
}
catch (WitExceptionFault ex)
{
// The server-side implementation threw
Console.WriteLine($"Server error: {ex.Message}");
}
catch (WitException ex)
{
// Transport, serialization, or encryption problem
Console.WriteLine($"Communication error: {ex.Message}");
}Design your service to throw meaningful exceptions for business failures, and document them as part of the contract the way you would document return values.
Next
With the contract defined, the next step is hosting it and connecting to it: Server & Client Setup →.