Introduction
This article goes over an advanced architecture in Visual Studio 2015 C# in connecting to MongoDB hosted in MongoLab and creating polymorphic and generic CRUDs for reusability across multiple classes.
We will be using the three layer architecture: Interface/Contract Model, Database Model, and View Model Context.
Background
Before you get started on this article, I'm assuming that you know how to setup a free node database in MongoLab, know how to connect to MongoDB, writing MongoDB CRUDs, and using polymorphic and generic types in C#.
The Required Classes
To start out let's create the interface, database, and view models before proceeding to the Mongo database handler.
IMongoEntity<TId>
public interface IMongoEntity<TId>
{
TId Id { get; set; }
}
TId
is a generic type. We'll be using type ObjectId
from the Mongo library. If you want, you could use other types for the Id as well. Just make sure you represent your Id property as type ObjectId
from Mongo's serialization attribute i.e. [BsonRepresentation(BsonType.ObjectId)]
.
IMongoCommon
public interface IMongoCommon : IMongoEntity<ObjectId>
{
string Name { get; set; }
bool IsActive { get; set; }
string Description { get; set; }
DateTime Created { get; set; }
DateTime Modified { get; set; }
}
IMongoCommon
will be the base interface for all collection documents to inherit. These properties will be the common properties that are shared throughout all collection documents. Most importantly, you will see a lot of IMongoCommon
in the generic CRUDs that we will be writing later in this article.
IAddress
public interface IAddress
{
string Street { get; set; }
string City { get; set; }
string State { get; set; }
string Zip { get; set; }
string Country { get; set; }
}
IAddress
doesn't implement IMongoCommon
because it will be an embedded document.
Address
[Serializable, JsonObject]
[BsonDiscriminator(Required = true)]
[BsonKnownTypes(typeof(Address))]
public class Address : IAddress
{
[BsonDefaultValue("")]
[BsonIgnoreIfDefault]
public string Street { get; set; }
[BsonDefaultValue("")]
[BsonIgnoreIfDefault]
public string City { get; set; }
[BsonDefaultValue("")]
[BsonIgnoreIfDefault]
public string State { get; set; }
[BsonDefaultValue("")]
[BsonIgnoreIfDefault]
public string Zip { get; set; }
[BsonDefaultValue("")]
[BsonIgnoreIfDefault]
public string Country { get; set; }
}
[BsonDiscriminator()]
and [BsonKnownTypes()]
lets the server know how to serialize/deserialize a document of this class type. You can read more about it in by clicking here
We are defaulting these strings to empty strings because when we pass a null string to a web page, we get it back as a string. Originally, I used [BsonIgnoreIfNull]
, but strings can never be null once it passes to the web page and it'll make it more complicated on the server side to account for this in the business logic. So basically, if the user doesn't specify anything for the string value, it is an empty string and the serializer will ignore it when we store it into the database.
AddressContext (Address view model)
[Serializable]
public class AddressContext
{
public string Street { get; set; }
public string City { get; set; }
public string State { get; set; }
public string Zip { get; set; }
public string Country { get; set; }
}
IEmployee
public interface IEmployee : IMongoCommon
{
string FirstName { get; set; }
string LastName { get; set; }
IEnumerable<IAddress> Addresses { get; set; }
}
Employee
[Serializable, JsonObject]
[BsonDiscriminator(Required = true)]
[BsonKnownTypes(typeof(Employee))]
public class Employee : IEmployee
{
private IEnumerable<IAddress> _addresses;
public Employee()
{
IsActive = true;
Created = DateTime.UtcNow;
Modified = DateTime.UtcNow;
}
[BsonId]
public ObjectId Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Name => FirstName + " " + LastName;
public bool IsActive { get; set; }
[BsonDefaultValue("")]
[BsonIgnoreIfDefault]
public string Description { get; set;
[BsonIgnoreIfNull]
public IEnumerable<IAddress> Addresses
{
get { return _addresses ?? (_addresses = new List<IAddress>(); }
set { _addresses = value; }
}
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
private bool ShouldSerializeAddresses() => Addresses.Any();
}
We set the [BsonIgnoreIfNull]
serialization attribute at Addresses
because we don't want to store null list/arrays of Addresses
into the database. ShouldSerializeAttribute
is a boolean method from the Mongo C# driver that lets the server know to serialize the property if it is not empty. In this case, the attribute is Addresses.
EmployeeContext (Employee view model)
public class EmployeeContext
{
public EmployeeContext()
{
IsActive = true;
Addresses = new List<AddressContext>();
Created = DateTime.UtcNow;
Modified = DateTime.UtcNow;
}
public string Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Name => FirstName + " " + LastName;
public bool IsActive { get; set; }
public string Description { get; set; }
public IEnumerable<AddressContext> Addresses { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
}
IOrganization
public interface IOrganization : IMongoCommon
{
string Uri { get; set; }
IEnumerable<IAddress> Addresses { get; set; }
}
Organization
[Serializable, JsonObject]
[BsonDiscriminator(Required = true)]
[BsonKnownTypes(typeof(Organization))]
public class Organization : IOrganization
{
private IEnumerable<IAddress> _addresses;
public Organization()
{
IsActive = true;
Created = DateTime.UtcNow;
Modified = DateTime.UtcNow;
}
[BsonId]
public ObjectId Id { get; set; }
public string Name { get; set; }
public bool IsActive { get; set; }
[BsonDefaultValue("")]
[BsonIgnoreIfDefault]
public string Uri { get; set; }
[BsonDefaultValue("")]
[BsonIgnoreIfDefault]
public string Description { get; set; }
[BsonIgnoreIfNull]
public IEnumerable<IAddress> Addresses
{
get { return _addresses ?? (_addresses = new List<IAddress>(); }
set { _addresses = value; }
}
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
private bool ShouldSerializeAddresses() => Addresses.Any();
}
OrganizationContext (Organization view model)
[Serializable]
public class OrganizationContext
{
public OrganizationContext()
{
IsActive = true;
Addresses = new List<AddressContext>();
Created = DateTime.UtcNow;
Modified = DateTime.UtcNow;
}
public string Id { get; set; }
public string Name { get; set; }
public bool IsActive { get; set; }
public string Uri { get; set; }
public string Description { get; set; }
public IEnumerable<AddressContext> Addresses { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
}
The Mongo Connection Handler
Let's move on to creating the Mongo connection handler. We'll create the interface class for the main connection in which the inheriting database handler class will implement. This class will take in a generic type for the nature of the IMongoCollection
.
IMyDatabase
public interface IMyDatabase<T>
{
IMongoDatabase Database { get; }
IMongoCollection<T> Collection { get; }
}
IMongoCollection
takes in a generic type T
, which is the interface class for all document objects. To make it easier for you understand, once we get into the CRUDs, T
is any class that implements IMongoCommon
.
Next we will create the inheriting class for the Mongo connection handler.
MyDatabase
public class MyDatabase<T> : IMyDatabase<T>
{
public IMongoDatabase { get; }
public IMongoCollection<T> Collection { get; }
public MyDatabase(string collectionName)
{
var client = new MongoClient("mongodb://username:password@ds012345.mongolab.com:12345/demo");
Database = client.GetDatabase("demo");
Collection = Database.GetCollection<T>(collectionName);
RegisterMapIfNeeded<Address>();
RegisterMapIfNeeded<Employee>();
RegisterMapIfNeeded<Organization>();
}
public void RegisterMapIfNeeded<TClass>()
{
if (!BsonClassMap.IsClassMapRegistered(typeof(TClass)))
BsonClassMap.RegisterMapClass<TClass>();
}
}
We are only registering the database layer classes because these are the class type that we will be storing into the Mongo database.
The Global CRUD Logic
Moving on the global CRUD logic, we will be creating a class called ILogic
that takes in a generic type of T
. This class will be the interface class for all logic to implement.
ILogic<T>
public interface ILogic<T>
{
Task<IEnumerable<T>> GetAllAsync();
Task<T> GetOneAsync(T context);
Task<T> GetOneAsync(string id);
Task<T> GetManyAsync(IEnumerable<T> contexts);
Task<T> GetManyAsync(IEnumerable<string> ids);
Task<T> SaveOneAsync(T Context);
Task<T> SaveManyAsync(IEnumerable<T> contexts);
Task<bool> RemoveOneAsync(T context);
Task<bool> RemoveOneAsync(string id);
Task<bool> RemoveManyAsync(IEnumerable<T> contexts);
Task<bool> RemoveManyAsync(IEnumerable<string> ids);
}
GlobalLogic<TCollection, TContext>
public class GlobalLogic<TCollection, TContext>
where TCollection : IDocumentCommon
where TContext : IDocumentCommon, new()
{
public async Task<IEnumerable<TCollection>> GetAllAsync(IMongoCollection<TCollection> collection)
{
return await collection.Find(f => true).ToListAsync();
}
public async Task<TCollection> GetOneAsync(IMongoCollection<TCollection> collection, TContext context)
{
return await collection.Find(new BsonDocument("_id", context.Id)).FirstOrDefaultAsync();
}
public async Task<TCollection> GetOneAsync(IMongoCollection<TCollection> collection, string id)
{
return await GetOneAsync(collection, new TContext { Id = new ObjectId(id) });
}
public async Task<IEnumerable<TCollection>> GetManyAsync(IMongoCollection<TCollection> collection,
IEnumerable<TContext> contexts)
{
var list = new List<TCollection>();
foreach (var context in contexts)
{
var doc = await GetOneAsync(collection, context);
if (doc == null) continue;
list.Add(doc);
}
return list;
}
public async Task<IEnumerable<TCollection>> GetManyAsync(IMongoCollection<TCollection> collection,
IEnumerable<string> ids)
{
var list new List<TCollection();
foreach (var id in ids)
{
var doc = await GetOneAsync(collection, id);
if (doc == null) continue;
list.Add(doc);
}
return list;
}
public async Task<bool> RemoveOneAsync(IMongoCollection<TCollection> collection, TContext context)
{
if (context == null || string.IsNullOrEmpty(context.Name)) return false;
await collection.UpdateOneAsync(
new BsonDocument("_id", context.Id),
new BsonDocument("$set", new BsonDocument { { nameof(IDocumentCommon.IsActive, false },
{ nameof(IDocument.Modified), DateTime.UtcNow } }));
return true;
}
public Task<bool> RemoveOneAsync(IMongoCollection<TCollection> collection, string id)
{
return await RemoveOneAsync(collection, new TContext { Id = new ObjectId(id) });
}
public async Task<bool> RemoveManyAsync(IMongoCollection<TCollection> collection,
IEnumerable<TContext> contexts)
{
foreach (var context in contexts)
await RemoveOneAsync(collection, context);
return true;
}
public async Task<bool> RemoveManyAsync(IMongoCollection<TCollection> collection,
IEnumerable<string> ids)
{
foreach (var id in ids)
await RemoveOneAsync(collection, id);
return true;
}
}
This GlobalLogic
class takes in two generic types, the type of the collection/interface and the type of the view model. I will explain each of these methods briefly and what they do.
1. GetAllAsync(TCollection collection)
This method gets gets all documents from the collection. You can specifiy f => true
or new BsonDocument()
for the filter.
2. GetOneAsync(TCollection collection, TContext context)
This method gets a document by Id. We passed in a new BsonDocument()
because with polymorphic types, we won't be able to access the Id property with the LINQ lambda. This will be consistent throughout all the CRUDs.
3. GetOneAsync(TCollection collection, string id)
This method is a passthrough method to method #2 to eliminate redundancy in our business logic.
4. GetManyAsync(TCollection collection, IEnumerable<TContext> contexts)
This is also a pass through method to method #2, except this time we process it as a foreach
loop and add it to a list to return. If the underlying context is null, it won't add it to the return list.
5. GetManyAsync(TCollection collection, IEnumerable<string> ids)
This is the same as method #4, except its pass through method is method #3.
6. RemoveOneAsync(TCollection collection, TContext)
We are implementing soft-delete in our system. The flag to indicate this is the IsActive
property in each of the documents. We mark the document as inactive and then update the modified date.
7. RemoveOneAsync(TCollection collection, string id)
This is a pass through method to method #6 if we want to pass the parameter in as a string rather than the context model.
8. RemoveManyAsync(TCollection collection, IEnumerable<TContext> contexts
9. RemoveManyAsync(TCollection collection, IEnumerable<string> ids
These two methods are basically the same as method #4 and #5 except they refer to their underlying methods, #6, and #7.
Employee Logic
For this next class, we will be creating the EmployeeLogic
class.
EmployeeLogic
public class EmployeeLogic : ILogic<EmployeeContext>
{
protected readonly MyDatabase<IEmployee> Employees;
protected readonly GlobalLogic<IEmployee, Employee> GlobalLogic = new GlobalLogic<IEmployee, Employee>();
public EmployeeLogic()
{
Employees = new MyDatabase<IEmployee>("employee");
}
public async Task<IEnumerable<EmployeeContext>> GetAllAsync()
{
var employee = await GlobalLogic.GetAllAsync(Employees.Collection);
return employee.Select(e => new EmployeeContext
{
Id = employee.Id.ToString(),
FirstName = employee.FirstName,
LastName = employee.LastName,
Name = employee.Name,
IsActive = employee.IsActive,
Description = employee.Description,
Addresses = employee.Addresses?.Select(a => new AddressContext
{
Street = a.Street,
City = a.City,
State a.State,
Zip = a.Zip,
Country = a.Country
},
Created = employee.Created,
Modified = employee.Modified
};
}
public async Task<EmployeeContext> GetOneAsync(EmployeeContext context)
{
return new NotImplementedException();
}
...
public async Task<EmployeeContext> SaveOneAsync(EmployeeContext context)
{
if (string.IsNullOrEmpty(context.Id))
{
var employee = context.AsNewEmployee();.
await Employees.InsertOneAsync(employee);
context.Id = employee.Id.ToString();
return context;
}
var update = context.ToEmployee();
await Employees.ReplaceOneAsync(new BsonDocument("_id", new ObjectId(context.Id)), update);
return context;
}
}
This EmployeeLogic
class is also known as the services class for Employees. Basically, it inherits the ILogic<T>
class in which T
is OrganizationContext
. We then get the database instance passing in the interface IEmployee
as the polymoprhic IMongoCollection
type. If you recall in MyDatabase
class, Employees = new MyDatabase<IEmployee>("employee")
returns the collection called "employee" of type IEmployee
. We then declare the GlobalLogic<IEmployee, Employee>
class to get the Mongo logic for our EmployeeLogic class. Of course, when IEmployee
implements ILogic<EmployeeContext>
, it'll inherit all of ILogic's members. Moving onto GetAllAsync()
, when the type comes back from GlobalLogic
, it is returned as IEnumerable
<IEmployee>
, but the method returns IEnumerable<EmployeeContext>
. That's where our translation comes in at the return statement.
To implement OrganizationLogic
, you would basically do the same thing, except you'll pass in the type IOrganization
, Organization
, and OrganizationContext
.
TIP: To make the translation easier, I suggest you write your own extension class to convert these three layer architecture type. This will make it easier for you because most of these translation are reusable. You can just call employee.ToEmployeeContext()
. Refer to the SaveOneAsync()
method. For example:
public static class EmployeeExtensions
{
public static Employee AsNewEmployee(this EmployeeContext context)
{
return new Employee
{
}
}
public static EmployeeContext ToEmployeeContext(this IEmployee employee)
{
return new EmployeeContext
{
};
}
public static IEnumerable<EmployeeContext> ToEmployeeContextList(this IEnumerable<IEmployee> contexts)
{
return contexts.Select(ToEmployeeContext);
}
public static Employee ToEmployee(this EmployeeContext context)
{
return new Employee
{
}
}
}
Using the Code
To use the code in other projects such as in an MVC Controller, Windows Forms App, or Console App, just call a new instance of EmployeeLogic
and/or OrganizationLogic
. Using the code in MVC Controller is a bit different. You'll have to register the service at startup, in the controller, and in the controller constructor. After declaring a new instance of the logic/service, all you need to do is _logic.GetAllAsync();
. To look at this in a console app, refer to the code below:
internal class Program
{
protected readonly EmployeeLogic Employees = new EmployeeLogic();
private static void Main(string[] args)
{
var m = MainAsync();
m.Wait();
Console.ReadLine();
}
private static async Task MainAsync()
{
var employees = await Employees.GetAllAsync();
Console.WriteLine(employees.ToJson(new JsonWriterSettings { Indent = true });
}
}
The Data in Json Format
{
"_id": ObjectId("...");
"_t": "Employee",
"FirstName": "Your",
"LastName": "Name",
"Name": "Your Name",
"IsActive": true,
"Addresses" [
{
"_t": "Address",
"Street": "123 Street Street",
"City": "Your City",
"State": "Your State",
"Zip: "12345",
"Country": "United States"
}
],
"Created": ISO(...),
"Modified": ISO(...),
}
"_t"
is the type of the document, also known as the database layer type. This is why we do the Bson Class Mappings in the database connection handler. This puts a strict layer on the client side so that if any data were altered, it would reject the resulting altered data and will only accept valid data.
Points of Interest
1. This design approach allows code to be reusable throughout all logic. As long as you have three layers:
- Interface/Contract
- Database Model
- View Model
...it'll just be plug and play from there. Oh yea, don't forget the extensions too. These extensions are extremely helpful for class translation.
2. Using interface/contracts allows data to be more easily tested in the NUnit framework. This allows us to mock data instead of having to create multiple tests for multiple types.
3. Using polymorphic collection type allows data to be rejected if the serialization/deserialization fails because the document is not the right type. Refer back to the Json for an explanation of the "_t" attribute.
I know I am missing some more stuff, but can't really think of them right now.
Any comments are appreciated! Happy coding!