El MongoDB C# driver tiene dos APIs para aggregation pipelines: una basada en BsonDocument (flexible, verbosa, sin type safety) y una basada en generics con expresiones LINQ (type-safe, más concisa, con limitaciones). En producción, la realidad es que necesitas ambas — los casos simples con el API tipado, los casos complejos con BsonDocument.
Este post es el patrón que uso para combinarlas.
La opción tipada: PipelineDefinition con expresiones
Para aggregations simples que mapean a clases existentes, el API con generics es limpio:
public record OrderSummary(string Region, decimal TotalAmount, int Count);
public async Task<List<OrderSummary>> GetOrderSummaryAsync(
DateTime startDate,
IMongoCollection<Order> collection)
{
var pipeline = collection.Aggregate()
.Match(o => o.Status == "active" && o.CreatedAt >= startDate)
.Group(
keySelector: o => o.Region,
resultSelector: g => new OrderSummary(
g.Key,
g.Sum(o => o.Amount),
g.Count()
)
)
.SortByDescending(s => s.TotalAmount);
return await pipeline.ToListAsync();
}
El compilador verifica los nombres de propiedad. Si Order no tiene campo Region, el código no compila. Excelente para pipelines que siguen la estructura de las clases existentes.
El problema: $lookup con proyección custom
El API tipado empieza a fallar cuando el resultado del pipeline no corresponde a ninguna clase existente — especialmente con $lookup que agrega campos de otra colección:
// Esto no compila — el resultado del $lookup no es Order ni Usuario
var pipeline = collection.Aggregate()
.Match(o => o.Status == "active")
.Lookup<Order, Usuario, ???>( // ¿Qué tipo va aquí?
foreignCollection: usuariosCollection,
localField: o => o.UsuarioId,
foreignField: u => u.Id,
@as: ??? => ???
);
Para estos casos, BsonDocument es más práctico:
public async Task<List<BsonDocument>> GetOrdersWithUsersAsync(
IMongoDatabase db)
{
var collection = db.GetCollection<BsonDocument>("orders");
var pipeline = new[]
{
new BsonDocument("$match", new BsonDocument
{
{ "status", "active" }
}),
new BsonDocument("$lookup", new BsonDocument
{
{ "from", "usuarios" },
{ "localField", "usuario_id" },
{ "foreignField", "_id" },
{ "as", "usuario" }
}),
new BsonDocument("$unwind", "$usuario"),
new BsonDocument("$project", new BsonDocument
{
{ "_id", 1 },
{ "monto", 1 },
{ "usuario.nombre", 1 },
{ "usuario.region", 1 },
}),
};
return await collection.Aggregate<BsonDocument>(pipeline).ToListAsync();
}
Con BsonDocument puedes construir cualquier pipeline que MongoDB soporte, sin limitaciones del API tipado.
El patrón híbrido: BsonDocument → tipo final
Para el mejor de los dos mundos, construir el pipeline con BsonDocument y deserializar el resultado a un tipo:
public record OrderWithUser(
string Id,
decimal Amount,
string UserName,
string UserRegion
);
public async Task<List<OrderWithUser>> GetOrdersWithUsersTypedAsync(
IMongoDatabase db)
{
// Pipeline con BsonDocument para $lookup
var pipeline = new[]
{
// ... mismos stages que arriba
new BsonDocument("$project", new BsonDocument
{
{ "_id", 0 },
{ "id", new BsonDocument("$toString", "$_id") },
{ "amount", "$monto" },
{ "userName", "$usuario.nombre" },
{ "userRegion", "$usuario.region" },
}),
};
var collection = db.GetCollection<BsonDocument>("orders");
// Deserializar resultado a tipo C#
var rawResults = await collection.Aggregate<BsonDocument>(pipeline).ToListAsync();
return rawResults.Select(doc => new OrderWithUser(
Id: doc["id"].AsString,
Amount: doc["amount"].AsDecimal,
UserName: doc["userName"].AsString,
UserRegion: doc["userRegion"].AsString
)).ToList();
}
El $project final mapea los nombres BSON a los nombres de la clase C# (camelCase o PascalCase según convenga). La deserialización manual es verbosa pero explícita — ves exactamente qué campo viene de dónde.
Tipos BSON que no se mapean automáticamente
El driver MongoDB C# mapea la mayoría de tipos automáticamente, pero hay casos que requieren atención:
ObjectId → string
// Sin el atributo, _id se mapea como ObjectId, no como string
public class Order
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } // Guardado como ObjectId, expuesto como string
}
[BsonRepresentation(BsonType.ObjectId)] permite que el campo sea string en C# pero ObjectId en MongoDB. Sin esto, tendrías que usar ObjectId como tipo en la clase — que es un tipo específico del driver, no de tu dominio.
Decimal128 → decimal
// MongoDB almacena decimales como Decimal128 (precisión BSON)
// C# decimal se mapea automáticamente, pero hay gotcha:
public class Order
{
[BsonRepresentation(BsonType.Decimal128)]
public decimal Amount { get; set; }
}
Sin [BsonRepresentation(BsonType.Decimal128)], el driver puede serializar decimal como double (pérdida de precisión para valores monetarios). El atributo garantiza uso de Decimal128.
DateTime → siempre UTC
// El driver serializa DateTime como UTC por convención BSON
// Si tu DateTime es local, se guarda como UTC pero se lee como UTC naive
// Siempre usar DateTime.UtcNow, nunca DateTime.Now
public class Order
{
[BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
public DateTime CreatedAt { get; set; }
}
PipelineStageDefinitionBuilder para stages reutilizables
Para pipelines donde algunos stages se repiten en diferentes queries, PipelineStageDefinitionBuilder permite definir stages como variables:
public static class OrderPipelineStages
{
// Stage reutilizable: solo órdenes activas del mes actual
public static PipelineStageDefinition<Order, Order> ActiveOrdersThisMonth =>
new BsonDocumentPipelineStageDefinition<Order, Order>(
new BsonDocument("$match", new BsonDocument
{
{ "status", "active" },
{ "created_at", new BsonDocument
{
{ "$gte", new BsonDateTime(new DateTime(
DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1)) }
}
}
})
);
// Stage reutilizable: proyección estándar para listados
public static PipelineStageDefinition<Order, BsonDocument> StandardProjection =>
new BsonDocumentPipelineStageDefinition<Order, BsonDocument>(
new BsonDocument("$project", new BsonDocument
{
{ "_id", 1 }, { "monto", 1 }, { "status", 1 }, { "created_at", 1 }
})
);
}
// Uso
var pipeline = collection.Aggregate()
.AppendStage(OrderPipelineStages.ActiveOrdersThisMonth)
.AppendStage(OrderPipelineStages.StandardProjection);
Los stages reutilizables evitan duplicar lógica de filtro en múltiples queries.
Indexes desde C#
Los índices se deben crear desde el código, no manualmente en la shell de MongoDB:
public static async Task EnsureIndexesAsync(IMongoCollection<Order> collection)
{
var indexModels = new[]
{
new CreateIndexModel<Order>(
Builders<Order>.IndexKeys
.Ascending(o => o.Status)
.Descending(o => o.CreatedAt),
new CreateIndexOptions { Name = "idx_status_created_at" }
),
new CreateIndexModel<Order>(
Builders<Order>.IndexKeys.Ascending(o => o.UsuarioId),
new CreateIndexOptions { Name = "idx_usuario_id" }
),
};
await collection.Indexes.CreateManyAsync(indexModels);
}
CreateManyAsync es idempotente — si el índice ya existe con las mismas opciones, no hace nada. Llamar en startup de la aplicación garantiza que los índices existen sin fallar en re-deploys.
Lo que aprendí
BsonDocument para pipelines complejos, API tipado para CRUD simple. No fuerces el API tipado para aggregations con múltiples $lookup o proyecciones que no corresponden a clases existentes. BsonDocument es más trabajo inicial pero más mantenible a largo plazo.
[BsonRepresentation(BsonType.ObjectId)] en IDs es la convención correcta. Exponer ObjectId como tipo en las clases de dominio crea acoplamiento al driver. string con el atributo BSON da la misma funcionalidad con mejor separación.
Los índices desde código, no desde la shell. Si los índices están solo en MongoDB y no en código, el próximo developer que clona el repo y levanta MongoDB fresco no sabe qué índices crear. Los EnsureIndexes en startup son la documentación ejecutable de los índices necesarios.