Overview
In a Silky microservice cluster, when service A needs to call service B's interface, service A does not have a local implementation of service B's interface. This means you cannot inject service B's interface directly and expect it to work.
Silky solves this using Castle.DynamicProxy: at startup, the framework automatically generates a runtime proxy object for every remote service interface (any [ServiceRoute] interface with no local implementation) and registers it in the IoC container. When application code injects the interface, it receives the proxy object. When a method is called on the proxy, it transparently executes an RPC call to the remote service.
Identifying Proxy Types
ServiceHelper.FindServiceProxyTypes() identifies which interfaces need proxies — all service interfaces that have no local implementation class:
public static IEnumerable<Type> FindServiceProxyTypes(ITypeFinder typeFinder)
{
// FindAllServiceTypes returns (Type, IsLocal) tuples
// Filter for IsLocal = false (remote service interfaces)
return FindAllServiceTypes(typeFinder)
.Where(p => !p.Item2) // Item2 = IsLocal
.Select(p => p.Item1); // Item1 = interface type
}
Example: If the order service depends on the account.contract package that defines IAccountAppService, but the order service does not implement IAccountAppService, then IAccountAppService is identified as a remote service interface needing a proxy.
Proxy Registration
RpcProxyCollectionExtensions.AddRpcProxy() creates and registers all proxies during RpcProxyModule.ConfigureServices():
public static IServiceCollection AddRpcProxy(this IServiceCollection services)
{
var serviceProxyTypes =
ServiceHelper.FindServiceProxyTypes(EngineContext.Current.TypeFinder);
foreach (var serviceType in serviceProxyTypes)
{
AddAServiceClientProxy(services, serviceType);
}
return services;
}
private static void AddAServiceClientProxy(IServiceCollection services, Type type)
{
// The Castle interceptor that handles RPC dispatch
var rpcProxyInterceptorType = typeof(RpcClientProxyInterceptor);
services.AddTransient(rpcProxyInterceptorType);
// Wrap the Castle interceptor for DI compatibility
var rpcInterceptorAdapterType =
typeof(SilkyAsyncDeterminationInterceptor<>).MakeGenericType(rpcProxyInterceptorType);
// Create and register the interface proxy (no real implementation target)
services.AddTransient(
type, // e.g., IAccountAppService
serviceProvider => ProxyGeneratorInstance
.CreateInterfaceProxyWithoutTarget(
type,
(IInterceptor)serviceProvider.GetRequiredService(rpcInterceptorAdapterType)
)
);
}
Each remote service interface is registered as Transient — a new proxy instance is created per injection; proxies are stateless.
The RpcClientProxyInterceptor
RpcClientProxyInterceptor is the core interceptor. When business code calls any method on a proxy object, Castle routes the call through InterceptAsync():
public class RpcClientProxyInterceptor : SilkyInterceptor
{
private readonly IExecutor _executor;
public RpcClientProxyInterceptor(IExecutor executor)
{
_executor = executor;
}
public override async Task InterceptAsync(IAsyncInvocation invocation)
{
// 1. Locate the service entry by interface type + method info
var serviceEntry = _serviceEntryLocator.GetServiceEntryById(
invocation.Method.GetServiceEntryId());
// 2. Extract service key (if any) from ambient RpcContext
var serviceKey = RpcContext.Context.GetServiceKey();
// 3. Delegate execution to IExecutor (local or remote decision)
invocation.Result = await _executor.Execute(serviceEntry, invocation.Arguments, serviceKey);
}
}
ServiceKey Routing
When multiple implementations of the same interface exist (e.g., multiple tenants or plugin variants), the caller sets a ServiceKey in the RPC context to route to the correct implementation:
// Caller side: set the key before calling
using (RpcContext.Context.SetServiceKey("premium"))
{
var result = await _accountAppService.GetProfileAsync();
}
The key is propagated through RpcContext.Attachments across the RPC call and used by the server side to select the correct implementation.
Proxy Data Flow
Business code
│ accountAppService.GetProfileAsync(userId)
▼
RpcClientProxyInterceptor.InterceptAsync()
│ Locate ServiceEntry
│ Read ServiceKey from RpcContext
▼
IExecutor.Execute(serviceEntry, args, serviceKey)
│ IsLocal? No → RemoteExecutor
▼
Polly policies (Timeout → Retry → CircuitBreaker → Fallback)
▼
Load balancing → select endpoint
▼
DotNetty TransportClient → TCP → remote AccountService
▼
Result returned to caller
