DEV Community

Quy Vu
Quy Vu

Posted on AI-assisted

gRPC data mapping in .NET microservices: fill DTOs from other services without N+1 calls

Your Order service stores a UserId. The API that returns orders needs the user's name and email, and those live in the User service. So every team ends up writing the same glue: loop over the orders, call the User service, add a cache, add retries, and hope it doesn't become N+1 calls across the network.

This post shows how to do that mapping over gRPC in .NET with FxMap, an open-source library (Apache-2.0) where you declare where a property comes from and one call fills it.

FxMap filling UserName and UserEmail from the User service

The usual way

foreach (var order in orders)
{
    var user = await _userClient.GetUserAsync(new GetUserRequest { Id = order.UserId });
    order.UserName = user.Name;
    order.UserEmail = user.Email;
}
Enter fullscreen mode Exit fullscreen mode

A page of 50 orders is 50 gRPC calls. You fix it with a batch endpoint, then a cache, then you write the same thing again for ProductName, ProvinceName, and every other key in every other service.

Declaring the mapping instead

Both services share a key name. It is just a marker:

public sealed class UserDistributedKey : IDistributedKey;
Enter fullscreen mode Exit fullscreen mode

The User service (owner of the data) says how to find a user and what it can return:

public class UserConfig : EntityConfigureOf<User>
{
    protected override void Configure()
    {
        Id(x => x.Id);
        DefaultProperty(x => x.Name);
        UseDistributedKey<UserDistributedKey>();
    }
}
Enter fullscreen mode Exit fullscreen mode
builder.Services.AddGrpc();
builder.Services.AddFxMap(cfg => cfg.AddEntitiesFromAssemblyContaining<UserConfig>())
    .AddEntityFrameworkCore(o => o.AddDbContexts(typeof(UserDbContext)));

var app = builder.Build();
app.MapFxMapperGrpc();
app.Run();
Enter fullscreen mode Exit fullscreen mode

The Order service (needs the data) describes its DTO:

public class OrderResponse
{
    public string UserId { get; set; }
    public string UserName { get; set; }
    public string UserEmail { get; set; }
}

public class OrderResponseProfile : ProfileOf<OrderResponse>
{
    protected override void Configure() =>
        UseDistributedKey<UserDistributedKey>()
            .Of(x => x.UserId)
            .For(x => x.UserName)            // the default property (Name)
            .For(x => x.UserEmail, "Email");
}
Enter fullscreen mode Exit fullscreen mode
builder.Services.AddFxMap(cfg =>
{
    cfg.AddProfilesFromAssemblyContaining<OrderResponseProfile>();
    cfg.AddGrpcClients(c => c.AddGrpcHosts("http://user-service:5001"));
});
Enter fullscreen mode Exit fullscreen mode

And in the endpoint:

var orders = await db.Orders.Select(o => new OrderResponse { UserId = o.UserId }).ToListAsync();
await mapper.MapDataAsync(orders);
Enter fullscreen mode Exit fullscreen mode

What happens on the wire

  • FxMap collects the distinct UserIds of all 50 orders and sends one gRPC request to the User service.
  • The User service answers from its DbContext in one query and FxMap writes UserName and UserEmail back.
  • Chains resolve level by level: UserId → ProvinceId → CountryId is one request per level, not per object.
  • The services share only a key name. No generated client per field, no shared DTO assembly.

You are not limited to plain properties. The expression language reads navigations, filters and aggregates on the owner's side:

.For(x => x.CountryName, "Country.Name")
.For(x => x.DoneTotal, "Orders:where(Status = 'Done'):sum(Total)")
Enter fullscreen mode Exit fullscreen mode

Not only gRPC, not only .NET

gRPC is one of six transports. NATS, RabbitMQ, Kafka, Azure Service Bus and Amazon SQS use the same profiles, so switching transport doesn't touch your mapping code.

The wire format is the same in jfxmap (Java, Spring Boot) and nfxmap (Node.js, TypeScript), so a .NET Order service can fill its DTO from a Java User service.

Is it fast?

The README has a BenchmarkDotNet comparison against AutoMapper ProjectTo with the network removed: past a few dozen objects FxMap is on par or faster and allocates a half to a third of the memory, because each distinct key is fetched once. Numbers and the command to re-run them are here.

Try it

dotnet add package FxMap
dotnet add package FxMap.Grpc
dotnet add package FxMap.EntityFrameworkCore
Enter fullscreen mode Exit fullscreen mode

I'm the author and I'd like to hear how you solve this today. If you have a microservice setup where this would or wouldn't fit, tell me in the comments.

Top comments (0)