Build a Modern, Extensible, Type-Safe gRPC Client Factory in C#

Build a Modern, Extensible, Type-Safe gRPC Client Factory in C#

Build a Modern, Extensible, Type-Safe gRPC Client Factory in C#

Overview

In modern distributed systems, managing gRPC clients efficiently and safely is critical. This article presents a clean and powerful design for a gRPC client factory in C# that is:

  • ✅ Type-safe
  • ✅ Performant (no reflection or boxing)
  • ✅ Extensible by design (via both static contracts and delegate overloads)
  • ✅ Aligned with best practices for gRPC in .NET

This solution is ideal for leaders and architects who want reusable infrastructure without unnecessary complexity.


The Goal

We want a factory that:

  • Automatically constructs and caches gRPC clients by address
  • Allows developers to plug in new clients easily
  • Requires no public constructors or manual registration
  • Supports both standard and custom creation logic


Interface Contracts

We define two interfaces:

public interface IGRPCClient {}

public interface ISelfGRPCClient
{
    static abstract IGRPCClient Create(string fullAddress);
}
        

  • IGRPCClient is the common runtime interface for all gRPC clients
  • ISelfGRPCClient defines a static factory method contract


Example Client Implementation

💡 Note: Your gRPC client implementations can include constructors that accept parameters (e.g., DI services or shared components). These can be used with the delegate overload of the factory to construct clients with custom dependencies.
public class GrpcEntityDiscoveryClient : EntityDiscoveryProtoService.EntityDiscoveryProtoServiceClient, ISelfGRPCClient, IGRPCClient
{

    private GrpcEntityDiscoveryClient(string fullAddress): base(GrpcChannel.ForAddress(grpcAddress))
    {

    }

    public static IGRPCClient Create(string fullAddress)
    {
        return new GrpcEntityDiscoveryClient(fullAddress);
    }
}
        
You can optionally retain a reference to the channel if you intend to implement channel management logic.
    public class GrpcEntityDiscoveryClient : EntityDiscoveryProtoService.EntityDiscoveryProtoServiceClient, ISelfGRPCClient, IGRPCClient
    {
        private readonly GrpcChannel _channel;

        private static GrpcChannel CreateChannel(string address, out GrpcChannel channel)
        {
            channel = GrpcChannel.ForAddress(address);
            return channel;
        }

        private GrpcEntityDiscoveryClient(string grpcAddress) : base(CreateChannel(grpcAddress, out var ch))
        {
            _channel = ch;
        }

        public static IGRPCClient Create(string grpcAddress)
        {
            return new GrpcEntityDiscoveryClient(grpcAddress);
        }
    }        

Example Client Implementation with Method Overload

     public class GrpcMonitorClient : MonitorService.MonitorServiceClient, IGRPCClient, ISelfGRPCClient
 {
     public GrpcMonitorClient() // This is going to be used by the factory to instantiale an instance which can call GetClient
     {
     }
     private GrpcMonitorClient(string grpcAddress) : base(GrpcChannel.ForAddress(grpcAddress))
     {
     }


     public async Task<bool> MonitorAsync(string fqdn, CancellationToken token = default) // Optional overloaded method if that adds a benefit like removing code redunduncy. This approach is also composition over inheritance
     {
         try
         {
             var request = new MonitorRequest { FullyQualifiedName = fqdn };
             var response = await base.MonitorAsync(request, cancellationToken: token);

             if (!response.Success)
             {
                 throw new InvalidOperationException($"Monitor call failed: {response.Message}");
             }

             return true;
         }
         catch (RpcException rpcEx)
         {
             // Optional: handle gRPC-specific errors
             throw new InvalidOperationException($"gRPC Monitor call failed: {rpcEx.Status.Detail}", rpcEx);
         }
     }

     public static IGRPCClient Create(string grpcAddress)
     {
        return new GrpcMonitorClient(grpcAddress);
     }
 }        


Once implemented like this, the client is ready to be used by the factory — no registration required.


The Factory Implementation

public interface ICustomGrpcClientFactory
{
    TClient GetClient<TClient>(string host)
        where TClient : ISelfGRPCClient, IGRPCClient;

    TClient GetClient<TClient>(string host, Func<string, TClient> factory)
        where TClient : IGRPCClient;
}
        

Concrete Factory

public class CustomGrpcClientFactory : ICustomGrpcClientFactory
{
    private readonly ConcurrentDictionary<(Type, string), IGRPCClient> _instances = new();
    private readonly IClusterConfiguration _clusterConfiguration;

    public CustomGrpcClientFactory(IClusterConfiguration clusterConfiguration)
    {
        _clusterConfiguration = clusterConfiguration;
    }

    public TClient GetClient<TClient>(string host)
        where TClient : ISelfGRPCClient, IGRPCClient
    {
        var key = (typeof(TClient), host);

        return (TClient)_instances.GetOrAdd(key, _ =>
        {
            var fullAddress = $"http://{host}:{_clusterConfiguration.GRPCPort}";
            return TClient.Create(fullAddress);
        });
    }

    public TClient GetClient<TClient>(string host, Func<string, TClient> factory)
        where TClient : IGRPCClient
    {
        var key = (typeof(TClient), host);

        return (TClient)_instances.GetOrAdd(key, _ =>
        {
            var fullAddress = $"http://{host}:{_clusterConfiguration.GRPCPort}";
            return factory(fullAddress);
        });
    }
}
        

The factory automatically creates gRPC clients by calling a static Create method defined in each client that implements the ISelfGRPCClient interface. This design allows clients to self-register through interface implementation—no manual registration or configuration is needed—enabling the factory to instantiate, cache, and return the correct client based solely on its type and host.



🔥 Why You Should Care

This design offers:

  • Scalability: Add new gRPC clients by simply implementing the interface.
  • Performance: No reflection, no dynamic dispatch, and optimized caching.
  • Clean Architecture: Contracts clearly define responsibility.
  • Future-Readiness: Built on C# 11 and .NET 7+ static polymorphism.

Whether you're leading a team or building infrastructure for others, this factory is a production-ready solution that minimizes friction and maximizes clarity.


Summary

You now have a clean, modern, and extensible gRPC factory that:

  • Simplifies client usage
  • Encourages best practices
  • Delivers high performance with minimal boilerplate

This approach fits perfectly in systems where reliability, reusability, and developer experience are top priorities.

Lastly, feel free to expand the factory logic for better client management — this is just a starting point.



Ali Al Zoobi, PhD

Research Engineer | Operational Research | Graph Theory

1w

Interesting!

Mohamad H.

Microsoft | Sr. Embedded Escalation Engineer | Software engineering | Performance Testing.

1w

Feel free to expand the factory logic for better client management, this is just a starting point.

To view or add a comment, sign in

More articles by Mohamad H.

Insights from the community

Others also viewed

Explore topics