Using Polymorphism with MongoDB and C#
Rate this tutorial
In comparison to relational database management systems (RDBMS), MongoDB's flexible schema is a huge step forward when handling object-oriented data. These structures often make use of polymorphism where common base classes contain the shared fields that are available for all classes in the hierarchy; derived classes add the fields that are relevant only to the specific objects. An example might be to have several types of vehicles, like cars and motorcycles, that have some fields in common, but each type also adds some fields that make only sense if used for a type:
For RDBMS, storing an object hierarchy is a challenge. One way is to store the data in a table that contains all fields of all classes, though for each row, only a subset of fields is needed. Another approach is to create a table for the base class that contains the shared fields and add a table for each derived class that stores the columns for the specific type and references the base table. Neither of these approaches is optimal in terms of storage and when it comes to querying the data.
However, with MongoDB's flexible schema, one can easily store documents in the same collection that do only share some but not all fields. This article shows how the MongoDB C# driver makes it easy to use this for storing class hierarchies in a very natural way.
Example use cases include storing metadata for various types of documents, e.g., offers, invoices, or other documents related to business partners in a collection. Common fields could be a document title, a summary, the date, a vector embedding, and the reference to the business partner, whereas an invoice would add fields for the line items and totals but would not add the fields for a project report.
Another possible use case is to serve both an overview and a detail view from the same collection. We will have a closer look at how to implement this in the summary of this article.
When accessing a collection from C#, we use an object that implements
IMongoCollection<T>
interface. This object can be created like this:1 var vehiclesColl = db.CreateCollection<Vehicle>("vehicles");
When serializing or deserializing documents, the type parameter
T
and the actual type of the object provide the MongoDB C# driver with a hint on how to map the BSON representation to a C# class and vice versa. If only documents of the same type reside in the collection, the driver uses the class map of the type.However, to be able to handle class hierarchies correctly, the driver needs more information. This is where the type discriminator comes in. When storing a document of a derived type in the collection, the driver adds a field named
_t
to the document that contains the name of the class, e.g.:1 await vehiclesColl.InsertOneAsync(new Car());
leads to the following document structure:
1 { 2 "_id": ObjectId("660d7d43e042f8f6f2726f6a"), 3 "_t": "Car", 4 // ... fields for vehicle 5 // ... fields specific to car 6 }
When deserializing the document, the value of the
_t
field is used to identify the type of the object that is to be created.Though this works out of the box without specific configuration, it is advised to support the driver by specifying the class hierarchy explicitly by using the
BsonKnownTypes
attribute, if you are using declarative mapping:1 [ ]2 public abstract class Vehicle 3 { 4 // ... 5 }
If you configure the class maps imperatively, just add a class map for each type in the hierarchy to reach the same effect.
By default, only the name of the class is used as value for the type discriminator. Especially if the hierarchy spans several levels and you want to query for any level in the hierarchy, you should store the hierarchy as an array in the type discriminator by using the
BsonDiscriminator
attribute:1 [ ]2 [ ]3 public abstract class Vehicle 4 { 5 // ... 6 }
This applies a different discriminator convention to the documents and stores the hierarchy as an array:
1 { 2 "_id": ObjectId("660d81e5825f1c064024a591"), 3 "_t": [ 4 "Vehicle", 5 "Car" 6 ], 7 // ... 8 }
For additional details on how to configure the class maps for polymorphic objects, see the documentation of the driver.
When reading objects from a collection, the MongoDB C# driver uses the type discriminator to identify the matching type and creates a C# object of the corresponding class. The following query might yield both
Car
and Motorcycle
objects:1 var vehiclesColl = db.GetCollection<Vehicle>("vehicles"); 2 var vehicles = (await vehiclesColl.FindAsync(FilterDefinition<Vehicle>.Empty)) 3 .ToEnumerable();
If you are only interested in documents of a specific type, you can create another instance of
IMongoCollection<T>
that returns only these:1 var carsColl = vehiclesColl.OfType<Car>(); 2 var cars = (await carsColl.FindAsync(FilterDefinition<Car>.Empty)) 3 .ToEnumerable();
This new collection instance respects the corresponding type discriminator whenever an operation is performed. The following statement removes only
Car
documents from the collection but keeps the Motorcycle
documents as they are:1 await carsColl.DeleteManyAsync(FilterDefinition<Car>.Empty);
If you are using the LINQ provider brought by the MongoDB C# driver, you can also use the LINQ
OfType<T>
extension method to only retrieve the Car
objects:1 var cars = vehiclesColl.AsQueryable().OfType<Car>();
As promised before, we now take a closer look at a use case for polymorphism: Let's suppose we are building a system that supports monitoring sensors that are distributed over several sites. The system should provide an overview that lists all sites with their name and the last value that was reported for the site along with a timestamp. When selecting a site, the system shows detailed information for the site that consists of all the data on the overview and also lists the sensors that are located at the specific site with their last value and its timestamp.
This can be depicted by creating a base class for the documents that contains the id of the site, a name to identify the document, and the last measurement, if available. A derived class for the site overview adds the site address; another one for the sensor detail contains the location of the sensor:
1 using MongoDB.Bson; 2 using MongoDB.Bson.Serialization.Attributes; 3 4 public abstract class BaseDocument 5 { 6 [ ]7 public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); 8 9 [ ]10 public string SiteId { get; set; } = ObjectId.GenerateNewId().ToString(); 11 12 public string Name { get; set; } = string.Empty; 13 14 public Measurement? Last { get; set; } 15 } 16 17 public class Measurement 18 { 19 public int Value { get; set; } 20 21 public DateTime Timestamp { get; set; } 22 } 23 24 public class Address 25 { 26 // ... 27 } 28 29 public class SiteOverview : BaseDocument 30 { 31 public Address Address { get; set; } = new(); 32 } 33 34 public class SensorDetail : BaseDocument 35 { 36 public string Location { get; set; } = string.Empty; 37 }
When ingesting new measurements, both the site overview and the sensor detail are updated (for simplicity, we do not use a multi-document transaction):
1 async Task IngestMeasurementAsync( 2 IMongoCollection<BaseDocument> overviewsColl, 3 string sensorId, 4 int value) 5 { 6 var measurement = new Measurement() 7 { 8 Value = value, 9 Timestamp = DateTime.UtcNow 10 }; 11 var sensorUpdate = Builders<SensorDetail> 12 .Update 13 .Set(x => x.Last, measurement); 14 var sensorDetail = await overviewsColl 15 .OfType<SensorDetail>() 16 .FindOneAndUpdateAsync( 17 x => x.Id == sensorId, 18 sensorUpdate, 19 new() { ReturnDocument = ReturnDocument.After }); 20 if (sensorDetail != null) 21 { 22 var siteUpdate = Builders<SiteOverview> 23 .Update 24 .Set(x => x.Last, measurement); 25 var siteId = sensorDetail.SiteId; 26 await overviewsColl 27 .OfType<SiteOverview>() 28 .UpdateOneAsync(x => x.SiteId == siteId, siteUpdate); 29 } 30 }
Above sample uses
FindAndUpdateAsync
to both update the sensor detail document and also retrieve the resulting document so that the site id can be determined. If the site id is known beforehand, a simple update can also be used.When retrieving the documents for the site overview, the following code returns all the relevant documents:
1 var siteOverviews = (await overviewsColl 2 .OfType<SiteOverview>() 3 .FindAsync(FilterDefinition<SiteOverview>.Empty)) 4 .ToEnumerable();
When displaying detailed data for a specific site, the following query retrieves all documents for the site by its id in a single request:
1 var siteDetails = await (await overviewsColl 2 .FindAsync(x => x.SiteId == siteId)) 3 .ToListAsync();
The result of the query can contain objects of different types; you can use the LINQ
OfType<T>
extension method on the list to discern between the types, e.g., when building a view model.This approach allows for efficient querying from different perspectives so that central views of the application can be served with minimum load on the server.
Polymorphism is an important feature of object-oriented languages and there is a wide range of use cases for it. As you can see, the MongoDB C# driver provides a solid bridge between object orientation and the MongoDB flexible document schema. If you want to dig deeper into the subject from a data modeling perspective, be sure to check out the polymorphic pattern part of the excellent series "Building With Patterns" on the MongoDB Developer Center.
Top Comments in Forums
There are no comments on this article yet.