As your application grows, you'll likely need more than one service. A user service, an order service, a notification service, each with its own interface and implementation. How do you organize these with WitRPC?
You could run multiple servers on different ports, but that adds operational complexity. You could create a "super-interface" that inherits from all your service interfaces, but that's unwieldy and tightly couples unrelated functionality.
WitRPC offers a better solution: composite services. Host multiple service interfaces on a single server, accessed through a single client connection. Today, we'll explore how to use this powerful feature.
The Problem: Growing Beyond One Service
Let's say you started with a simple task service:
public interface ITaskService
{
Task<TaskInfo> CreateTaskAsync(string name);
Task<List<TaskInfo>> GetTasksAsync();
event Action<TaskInfo> TaskCompleted;
}As your application evolved, you added more capabilities:
public interface IUserService
{
Task<User> GetCurrentUserAsync();
Task UpdatePreferencesAsync(UserPreferences prefs);
}
public interface INotificationService
{
event Action<Notification> NotificationReceived;
Task MarkAsReadAsync(int notificationId);
}
public interface IAnalyticsService
{
Task TrackEventAsync(string eventName, Dictionary<string, object> properties);
Task<AnalyticsReport> GetReportAsync(DateRange range);
}Now you have four services. What are your options?
Option 1: Multiple Servers (Not Ideal)
// Four servers, four ports, four connections
var taskServer = BuildServer<ITaskService>(5001);
var userServer = BuildServer<IUserService>(5002);
var notificationServer = BuildServer<INotificationService>(5003);
var analyticsServer = BuildServer<IAnalyticsService>(5004);
// Client needs four connections
var taskClient = ConnectTo(5001);
var userClient = ConnectTo(5002);
// ... and so onProblems:
- Multiple ports to configure and secure
- Multiple connections to manage
- More resources consumed
- Harder to deploy and monitor
Option 2: Super-Interface (Also Not Ideal)
// One giant interface
public interface IApplicationService :
ITaskService,
IUserService,
INotificationService,
IAnalyticsService
{
}
// One implementation that does everything
public class ApplicationService : IApplicationService
{
// Hundreds of methods...
}Problems:
- Tight coupling between unrelated services
- Single massive class (or awkward delegation)
- Adding a service requires changing the super-interface
- Violates single responsibility principle
Option 3: Composite Services (The WitRPC Way)
// Server: Register multiple independent services
var server = WitServerBuilder.Build(options =>
{
options.WithServices()
.AddService<ITaskService>(new TaskService())
.AddService<IUserService>(new UserService())
.AddService<INotificationService>(new NotificationService())
.AddService<IAnalyticsService>(new AnalyticsService())
.Build();
options.WithJson();
});
// Client: One connection, multiple services
await client.ConnectAsync();
var tasks = client.GetService<ITaskService>();
var users = client.GetService<IUserService>();
var notifications = client.GetService<INotificationService>();
var analytics = client.GetService<IAnalyticsService>();Benefits:
- Single port, single connection
- Services remain independent
- Add or remove services without affecting others
- Each service can be developed and tested in isolation
Setting Up Composite Services
Let's build a complete example step by step.
Step 1: Define Your Service Interfaces
Keep interfaces focused and cohesive:
// Contracts/IUserService.cs
public interface IUserService
{
Task<User> GetUserAsync(int userId);
Task<User> GetCurrentUserAsync();
Task<bool> UpdateProfileAsync(UserProfile profile);
event Action<User> UserUpdated;
}
// Contracts/IOrderService.cs
public interface IOrderService
{
Task<Order> CreateOrderAsync(CreateOrderRequest request);
Task<Order> GetOrderAsync(int orderId);
Task<List<Order>> GetUserOrdersAsync(int userId);
Task<bool> CancelOrderAsync(int orderId);
event Action<Order> OrderStatusChanged;
}
// Contracts/IInventoryService.cs
public interface IInventoryService
{
Task<int> GetStockLevelAsync(string productId);
Task<bool> ReserveStockAsync(string productId, int quantity);
Task ReleaseReservationAsync(string reservationId);
event Action<StockAlert> LowStockAlert;
}Step 2: Implement Each Service
Each service is a standalone class:
// Services/UserService.cs
public class UserService : IUserService
{
public event Action<User> UserUpdated = delegate { };
private readonly IUserRepository _repository;
public UserService(IUserRepository repository)
{
_repository = repository;
}
public async Task<User> GetUserAsync(int userId)
{
return await _repository.GetByIdAsync(userId);
}
public async Task<User> GetCurrentUserAsync()
{
// Get from current context
return await _repository.GetCurrentAsync();
}
public async Task<bool> UpdateProfileAsync(UserProfile profile)
{
var user = await _repository.UpdateAsync(profile);
UserUpdated(user); // Notify subscribers
return true;
}
}
// Services/OrderService.cs
public class OrderService : IOrderService
{
public event Action<Order> OrderStatusChanged = delegate { };
private readonly IOrderRepository _orderRepo;
private readonly IInventoryService _inventory;
public OrderService(IOrderRepository orderRepo, IInventoryService inventory)
{
_orderRepo = orderRepo;
_inventory = inventory;
}
public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
{
// Reserve inventory
foreach (var item in request.Items)
{
await _inventory.ReserveStockAsync(item.ProductId, item.Quantity);
}
var order = await _orderRepo.CreateAsync(request);
OrderStatusChanged(order);
return order;
}
// ... other methods
}
// Services/InventoryService.cs
public class InventoryService : IInventoryService
{
public event Action<StockAlert> LowStockAlert = delegate { };
private readonly IInventoryRepository _repository;
public async Task<int> GetStockLevelAsync(string productId)
{
return await _repository.GetStockAsync(productId);
}
public async Task<bool> ReserveStockAsync(string productId, int quantity)
{
var result = await _repository.ReserveAsync(productId, quantity);
// Check if stock is low
var remaining = await GetStockLevelAsync(productId);
if (remaining < 10)
{
LowStockAlert(new StockAlert(productId, remaining));
}
return result;
}
// ... other methods
}Step 3: Configure the Composite Server
Register all services on a single server:
// Program.cs (Server)
using OutWit.Communication.Server;
using OutWit.Communication.Server.Tcp;
// Create service instances (with dependencies)
var userRepo = new UserRepository(connectionString);
var orderRepo = new OrderRepository(connectionString);
var inventoryRepo = new InventoryRepository(connectionString);
var inventoryService = new InventoryService(inventoryRepo);
var userService = new UserService(userRepo);
var orderService = new OrderService(orderRepo, inventoryService);
// Build composite server
var server = WitServerBuilder.Build(options =>
{
options.WithServices()
.AddService<IUserService>(userService)
.AddService<IOrderService>(orderService)
.AddService<IInventoryService>(inventoryService)
.Build();
options.WithMessagePack(); // Binary serialization for performance
});
// Configure transport
var transport = TcpServerTransportOptions.Default
.WithPort(5000)
.WithMaxNumberOfClients(200);
server.WithTransport(transport);
server.StartWaitingForConnection();
Console.WriteLine("Composite server running on port 5000");
Console.WriteLine("Hosting: IUserService, IOrderService, IInventoryService");Step 4: Connect and Use Multiple Services
The client gets all services through one connection:
// Program.cs (Client)
using OutWit.Communication.Client;
using OutWit.Communication.Client.Tcp;
var client = WitClientBuilder.Build(options =>
{
options.WithMessagePack();
});
var transport = TcpClientTransportOptions.Default
.WithAddress("localhost", 5000);
client.WithTransport(transport);
await client.ConnectAsync(TimeSpan.FromSeconds(5));
// Get proxies for all services
var users = client.GetService<IUserService>();
var orders = client.GetService<IOrderService>();
var inventory = client.GetService<IInventoryService>();
// Subscribe to events from all services
users.UserUpdated += user =>
Console.WriteLine($"User updated: {user.Name}");
orders.OrderStatusChanged += order =>
Console.WriteLine($"Order {order.Id}: {order.Status}");
inventory.LowStockAlert += alert =>
Console.WriteLine($"Low stock warning: {alert.ProductId}");
// Use services
var currentUser = await users.GetCurrentUserAsync();
var userOrders = await orders.GetUserOrdersAsync(currentUser.Id);
foreach (var order in userOrders)
{
Console.WriteLine($"Order {order.Id}: {order.Total:C}");
}Dependency Injection Integration
For ASP.NET Core applications, WitRPC integrates with the built-in DI container:
// Startup.cs or Program.cs
using OutWit.Communication.Server.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// Register your service implementations
builder.Services.AddSingleton<IUserRepository, UserRepository>();
builder.Services.AddSingleton<IOrderRepository, OrderRepository>();
builder.Services.AddSingleton<IInventoryRepository, InventoryRepository>();
// Register WitRPC composite server with DI
builder.Services.AddWitRpcServerWithServices(
"MainServer",
options =>
{
options.WithTcp(5000, maxNumberOfClients: 100);
options.WithMessagePack();
},
services =>
{
// Services resolved from DI container
services.AddService<IUserService, UserService>();
services.AddService<IOrderService, OrderService>();
services.AddService<IInventoryService, InventoryService>();
},
autoStart: true // Start when application starts
);
var app = builder.Build();
app.Run();With DI integration, your services can have constructor dependencies that are automatically resolved:
public class OrderService : IOrderService
{
private readonly IOrderRepository _repository;
private readonly IInventoryService _inventory;
private readonly ILogger<OrderService> _logger;
// Dependencies injected by DI container
public OrderService(
IOrderRepository repository,
IInventoryService inventory,
ILogger<OrderService> logger)
{
_repository = repository;
_inventory = inventory;
_logger = logger;
}
}Architecture Patterns with Composite Services
Composite services enable several useful architectural patterns.
Pattern 1: Feature-Based Organization
Organize services by business capability:
Services/
├── Users/
│ ├── IUserService.cs
│ ├── UserService.cs
│ └── UserModels.cs
├── Orders/
│ ├── IOrderService.cs
│ ├── OrderService.cs
│ └── OrderModels.cs
├── Inventory/
│ ├── IInventoryService.cs
│ ├── InventoryService.cs
│ └── InventoryModels.cs
└── Notifications/
├── INotificationService.cs
├── NotificationService.cs
└── NotificationModels.csEach feature is self-contained. Adding a new feature means adding a new folder and registering the service.
Pattern 2: Read/Write Separation
Separate query and command services:
// Query services (read-only, cacheable)
public interface IOrderQueryService
{
Task<Order> GetOrderAsync(int orderId);
Task<List<Order>> SearchOrdersAsync(OrderSearchCriteria criteria);
Task<OrderStatistics> GetStatisticsAsync(DateRange range);
}
// Command services (write operations)
public interface IOrderCommandService
{
Task<Order> CreateOrderAsync(CreateOrderRequest request);
Task<bool> UpdateOrderAsync(int orderId, UpdateOrderRequest request);
Task<bool> CancelOrderAsync(int orderId);
event Action<Order> OrderChanged;
}
// Register both
options.WithServices()
.AddService<IOrderQueryService>(new OrderQueryService(readOnlyDb))
.AddService<IOrderCommandService>(new OrderCommandService(writeDb))
.Build();This allows different optimization strategies for reads vs writes.
Pattern 3: Layered Services
Expose different service levels for different clients:
// Public API (limited functionality)
public interface IPublicOrderService
{
Task<Order> GetOrderAsync(int orderId);
Task<Order> CreateOrderAsync(CreateOrderRequest request);
}
// Admin API (full functionality)
public interface IAdminOrderService : IPublicOrderService
{
Task<bool> ForceCompleteOrderAsync(int orderId);
Task<bool> RefundOrderAsync(int orderId, decimal amount);
Task<List<Order>> GetAllOrdersAsync(OrderFilter filter);
}
// Different servers for different access levels
var publicServer = WitServerBuilder.Build(options =>
{
options.WithServices()
.AddService<IPublicOrderService>(publicOrderService)
.Build();
options.WithAccessToken(publicToken);
});
var adminServer = WitServerBuilder.Build(options =>
{
options.WithServices()
.AddService<IAdminOrderService>(adminOrderService)
.Build();
options.WithAccessToken(adminToken);
options.WithEncryption();
});Pattern 4: Service Composition
Services can depend on each other internally:
public class CheckoutService : ICheckoutService
{
private readonly IInventoryService _inventory;
private readonly IPaymentService _payment;
private readonly IOrderService _orders;
private readonly INotificationService _notifications;
public CheckoutService(
IInventoryService inventory,
IPaymentService payment,
IOrderService orders,
INotificationService notifications)
{
_inventory = inventory;
_payment = payment;
_orders = orders;
_notifications = notifications;
}
public async Task<CheckoutResult> ProcessCheckoutAsync(Cart cart)
{
// 1. Reserve inventory
foreach (var item in cart.Items)
{
if (!await _inventory.ReserveStockAsync(item.ProductId, item.Quantity))
{
return CheckoutResult.OutOfStock(item.ProductId);
}
}
// 2. Process payment
var payment = await _payment.ChargeAsync(cart.Total, cart.PaymentMethod);
if (!payment.Success)
{
// Release inventory
foreach (var item in cart.Items)
{
await _inventory.ReleaseReservationAsync(item.ReservationId);
}
return CheckoutResult.PaymentFailed(payment.Error);
}
// 3. Create order
var order = await _orders.CreateOrderAsync(new CreateOrderRequest
{
Items = cart.Items,
PaymentId = payment.Id
});
// 4. Send confirmation
await _notifications.SendAsync(new OrderConfirmation(order));
return CheckoutResult.Success(order);
}
}All services are registered in the composite, but CheckoutService orchestrates the others:
options.WithServices()
.AddService<IInventoryService>(inventoryService)
.AddService<IPaymentService>(paymentService)
.AddService<IOrderService>(orderService)
.AddService<INotificationService>(notificationService)
.AddService<ICheckoutService>(checkoutService) // Depends on all above
.Build();Best Practices
1. Keep Interfaces Focused
Each interface should have a single responsibility:
// Good: focused interfaces
public interface IUserAuthService
{
Task<AuthResult> LoginAsync(string email, string password);
Task LogoutAsync();
Task<bool> ValidateTokenAsync(string token);
}
public interface IUserProfileService
{
Task<UserProfile> GetProfileAsync();
Task UpdateProfileAsync(UserProfile profile);
Task UploadAvatarAsync(byte[] imageData);
}
// Avoid: kitchen sink interface
public interface IUserService
{
// Auth methods
Task<AuthResult> LoginAsync(...);
Task LogoutAsync();
// Profile methods
Task<UserProfile> GetProfileAsync();
Task UpdateProfileAsync(...);
// Preferences
Task<Preferences> GetPreferencesAsync();
// Activity
Task<List<Activity>> GetActivityLogAsync();
// ... 20 more methods
}2. Consistent Event Naming
Use consistent patterns for events across services:
public interface IOrderService
{
event Action<Order> OrderCreated;
event Action<Order> OrderUpdated;
event Action<Order> OrderDeleted;
event Action<Order, OrderStatus> OrderStatusChanged;
}
public interface IUserService
{
event Action<User> UserCreated;
event Action<User> UserUpdated;
event Action<int> UserDeleted; // Just ID for deleted entities
}3. Version Your Interfaces
When interfaces evolve, consider versioning:
// Original
public interface IOrderService { ... }
// New version with breaking changes
public interface IOrderServiceV2 { ... }
// Register both for backward compatibility
options.WithServices()
.AddService<IOrderService>(legacyOrderService)
.AddService<IOrderServiceV2>(newOrderService)
.Build();4. Document Service Dependencies
Make inter-service dependencies clear:
/// <summary>
/// Handles order processing.
/// Dependencies: IInventoryService, IPaymentService
/// Events: OrderCreated, OrderStatusChanged
/// </summary>
public interface IOrderService
{
// ...
}Monitoring Composite Services
Track the health of all services:
// Add health checks for each service
builder.Services.AddHealthChecks()
.AddCheck<UserServiceHealthCheck>("user-service")
.AddCheck<OrderServiceHealthCheck>("order-service")
.AddCheck<InventoryServiceHealthCheck>("inventory-service");
// Custom health check example
public class OrderServiceHealthCheck : IHealthCheck
{
private readonly IOrderService _orderService;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken ct)
{
try
{
// Try a simple operation
await _orderService.GetOrderAsync(0);
return HealthCheckResult.Healthy();
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(ex.Message);
}
}
}Conclusion
Composite services solve the multi-service problem elegantly:
- Single connection for multiple services: simpler client code, fewer resources
- Independent services: each is developed, tested, and maintained separately
- Flexible composition: add or remove services without affecting others
- DI integration: works with ASP.NET Core dependency injection
- Consistent patterns: the same programming model whether you have one service or twenty
As your application grows, composite services help you maintain a clean architecture while keeping the operational simplicity of a single server endpoint.
Start with one service. Add more as needed. The composite service pattern scales with your application.
Next up: Using WitRPC with ASP.NET Core and Dependency Injection, wiring servers, clients, and proxies into the container.
This is part 7 of a series on WitRPC. See previous posts: Introducing WitRPC, Getting Started, Real-World Benefits, WitRPC vs. gRPC, Migrating to WitRPC, and Under the Hood.