DEV Community

Luce Carter for MongoDB

Posted on

Migrating From PostgreSQL to MongoDB in a .NET EF Core Application

For many reasons—including scalability, flexibility, and modernization—more and more development teams are looking to migrate away from PostgreSQL to MongoDB. In the past, these teams might have been hesitant if they used Entity Framework Core (EF Core) as their object relational mapper (ORM).

With EF Core being one of the most popular ORMs for .NET development, we received a lot of requests from the community for a provider, and so did Microsoft! So it made sense for MongoDB to develop a provider for EF Core. This went generally available (GA) in May 2024 and has been greatly received by the community.

Plus, developers can now use MongoDB's Relational Migrator, a free tool that uses smart algorithms and GenAI to streamline migrating from legacy relational databases to MongoDB, accelerating application modernization initiatives. So now is a great time to take a look at how you too can reap the benefits of all this by migrating your .NET application that uses PostgreSQL with EF Core, to use the MongoDB EF Core Provider.

In this tutorial, we will start off with a simple application that uses PostgreSQL as the database under the hood, and update it to still take advantage of EF Core but use MongoDB instead. So let's get started!

Preparing the data

For this tutorial, we won’t be going through the data migration process as we already have content on that with our getting started documentation and even a short but great demo video.

However, Relational Migrator was used to migrate the content, even making sure to take advantage of the benefits of the MongoDB data model, such as embedding documents.

Relational Database Schema for a Movie Database showing 7 tables

In the original PostgreSQL database, there are seven tables:

  • movies
  • actors
  • theaters
  • users
  • comments
  • movie_cast: This table contains two foreign keys, movie_id and actor_id, to show the actors in a film.
  • movie_genres: This table contains one foreign key, movie_id, and a column for genre. This is to show what genres belong to which film in the movies table.

Here at MongoDB, we have a mantra to help you understand how to think about your data from an application usage perspective: “Data that is accessed together should be stored together.”
So when connecting Relational Migrator to my PostgreSQL database, I selected the option to follow recommendations on how to model the data in collections for MongoDB.

This means we have gone from seven tables to four collections, as some fields have been embedded.

Relational Migrator's recommended schema for the MongoDB database, with 4 collections instead of 7 tables

The four collections it suggested are:

  • Movies: This contains all the movie information and embeds the comments as an array of comment documents inside that document, the same for movie_casts and movie_genre.
  • Actors.
  • Theaters.
  • Users.

Note: It is highly recommended that you refine any schemas within Relational Migrator to optimize your data model to suit your needs before migrating. The recommendations you can select within Relational Migrator are a fantastic start but not perfect.

However, you will need to have the data available in your own MongoDB cluster that will be used in the tutorial.

You can find the data stored as a ZIP file on Azure that you will need to download, unzip, and then restore to your own cluster (M0 is sufficient) with mongorestore.

Once you have mongorestore on your machine, you can run the following command from the terminal from within the folder where you extracted the ZIP file.

mongorestore <your connection string> MigratedMflix/
Enter fullscreen mode Exit fullscreen mode

The MigratedMflix folder will contain the database and collections within that database that contain all the documents we will make use of in this tutorial.

Prerequisites

In order to follow along with this tutorial, you will need a few things in place:

  • An M0 or above cluster with the data loaded, as mentioned in the previous section
  • The GitHub repo forked and cloned to your machine with the with-postgresql branch checked out - The connection string updated with your own value in appsettings.json and appsettings.Development.json
"ConnectionStrings": {
      "MongoDBAtlasConnectionString": "mongodb+srv://<username>:<password>@<your cluster url>/migrated_mflix"
    }
Enter fullscreen mode Exit fullscreen mode

If you choose to use VS Code, ensure you have the REST Client extension installed as we will use this later to test the endpoints.

Note: If you want to see the finished result, there is a branch on the repo called with-mongodb

Updating the NuGet package

The first thing we want to do in our code is update the NuGet packages from using Npgsql.EntityFrameworkCore.PostgreSQL to using MongoDB.EntityFrameworkCore.
You can do this either from the NuGet package manager inside your IDE of choice, or from the terminal with the .NET CLI:

dotnet remove package Npgsql.EntityFrameworkCore.PostgreSQL

dotnet add package MongoDB.EntityFrameworkCore
Enter fullscreen mode Exit fullscreen mode

This will of course lead to errors in the code because we removed the package, but we are going to update the code in the next section so it's fine to see errors for now.

Updating the models

The fields in our migrated documents don’t map directly to the columns in the tables in PostgreSQL. Plus, we don’t have as many collections compared to the number of tables in the relational database. So for this reason, the first step we are going to take is deleting model classes that are no longer required, and updating the existing ones to use MongoDB instead.

Delete old models

As mentioned, we don’t need some of the classes anymore as they are not collections in our new database, but instead embedded in the collections where they are accessed. So go ahead and delete the following files:

  • Comment.cs
  • MovieCast.cs
  • MovieGenre.cs

Updating existing models

Now, we can go ahead and update the remaining models to use the properties we care about now and add the MongoDB related attributes, available from the provider, that tell it how to work with our data.

  • Actor.cs
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.EntityFrameworkCore;

namespace MigratedMoviesEFCore.Models;

[Collection("actors")]
public class Actor
{
    [BsonId]
    [BsonElement("_id")]
    public ObjectId Id { get; set; }

    [BsonElement("name")] 
    public required string Name { get; set; }
    [BsonElement("dateOfBirth")] 
    public required DateTime DateOfBirth { get; set; }
    [BsonElement("placeOfBirth")] 
    public required string PlaceOfBirth { get; set; }
}

Enter fullscreen mode Exit fullscreen mode
  • Movie.cs
using System.ComponentModel.DataAnnotations;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.EntityFrameworkCore;

namespace MigratedMoviesEFCore.Models;

[Collection("movies")]
public class Movie
{
  [BsonId] [BsonElement("_id")] public ObjectId Id { get; set; } = ObjectId.GenerateNewId();

  [BsonElement("title")]
  public required string Title { get; set; }

  [BsonElement("year")]
  public required int Year { get; set; }

  [BsonElement("runtime")]
  public required int Runtime { get; set; }

  [BsonElement("plot")]
  public required string Plot { get; set; }

  [BsonElement("fullplot")]
  public string FullPlot { get; set; }

  [BsonElement("released")]
  public required DateTime Released { get; set; }

  [BsonElement("rated")]
  public required string Rated { get; set; }

  [BsonElement("poster")]
  public required string Poster { get; set; }

  [BsonElement("comments")] 
  public List<Comment>? Comments { get; set; } = new List<Comment>();

}
public class Comment
{
  [BsonElement("text")]
  public required string Text { get; set; }

  [BsonElement("commentDate")]
  public required DateTime CommentDate { get; set; }
}

Enter fullscreen mode Exit fullscreen mode
  • Theater.cs
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.EntityFrameworkCore;

namespace MigratedMoviesEFCore.Models;

[Collection("theaters")]
public class Theater
{

    [BsonId]
    [BsonElement("_id")]
    public ObjectId Id { get; set; }

    [BsonElement("theaterName")]
    public required string TheaterName { get; set; }

    [BsonElement("street1")]
    public string? Street1 { get; set; }

    [BsonElement("street2")]
    public string? Street2 { get; set; }

    [BsonElement("city")]
    public string? City { get; set; }

    [BsonElement("state")]
    public string? State { get; set; }

    [BsonElement("zipcode")]
    public string? Zipcode { get; set; }

    [BsonElement("location_lat")]
    public decimal? LocationLat { get; set; }

    [BsonElement("location_lon")]
    public decimal? LocationLon { get; set; }
}
Enter fullscreen mode Exit fullscreen mode
  • User.cs
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.EntityFrameworkCore;

namespace MigratedMoviesEFCore.Models;

[Collection("users")]
public class User
{
    [BsonId]
    [BsonElement("_id")]
    public ObjectId Id { get; set; }

    [BsonElement("name")]
    public string? Name { get; set; }

    [BsonElement("email")]
    public string? Email { get; set; }

    [BsonElement("password")]
    public string? Password { get; set; }

    [BsonElement("created_at")]
    public DateTime? CreatedAt { get; set; }
}

Enter fullscreen mode Exit fullscreen mode

Updating DbContext

Next, we need to update our MoviesDbContext.cs class inside the Services folder so it no longer holds DbSet properties for our deleted classes, as well as update the overridden method for our new data model. As you will see, not much is going to change as it is still going to use the EF Core paradigms and interaction patterns that you are used to.

Delete the DbSet property entries for the following:

  • Comments
  • MovieCasts
  • MovieGenres

The next thing we need to do is update the code for OnModelCreating.
This is to handle instances where the comments field is missing on a document. Going forward, if a document is created without passing a comments array, it will create an empty one. But this may not be the case in migrated data. You may also experience this in the future with error handling arrays in your own migrated data so it is a best practice worth learning.

protected override void OnModelCreating(ModelBuilder modelBuilder){    modelBuilder.Entity<Movie>().HasMany(m => m.Comments).WithOne().IsRequired(false);}
Enter fullscreen mode Exit fullscreen mode

Updating MovieService

Next, we need to remove the redundant methods from our IMovieService.cs interface and the corresponding Movie.cs class and make some adjustments to the data type for the id from int to string and add some return statements.

Update your IMovieService.cs class to contain the following:

public interface IMovieService
{
    IEnumerable<Movie> GetAllMovies();

    Movie GetMovieById(string id);

    void UpdateMovie(string movieId, Movie movie);

    void DeleteMovie(Movie movieToDelete);

    string AddMovie(Movie movie);
    IEnumerable<Actor> GetAllActors(); 

    Actor GetActorById(string id);

    void UpdateActor(string id, Actor actor);

    void DeleteActor(Actor actorToDelete);

    string AddActor(Actor actor);

    IEnumerable<Theater> GetAllTheaters();

    IEnumerable<User> GetAllUsers();
}

Enter fullscreen mode Exit fullscreen mode

We then need to make some changes to existing methods within MovieServices.cs.

  • GetAllMovies - Change the m.MovieId in the OrderBy call to be m.Title instead.
  • GetMovieById and GetActorById - Change the passed in parameter from int to string. We also need to parse from string to ObjectId (a special data type in MongoDB for the unique _id field in all documents.
    public Movie GetMovieById(string id)
    {
        return _moviesDbContext.Movies.Find(ObjectId.Parse(id));
    }

Enter fullscreen mode Exit fullscreen mode

Make sure you have

using MongoDB.Bson;

added at the top if your editor doesn’t automatically add it.


public Actor GetActorById(string id)
    {
        return _moviesDbContext.Actors.Find(ObjectId.Parse(id));
    }
Enter fullscreen mode Exit fullscreen mode
  • AddMovie and AddActor: We want to change the return type from void to string and return the id of the movie and actors we just added, respectively.
public string AddMovie(Movie movie)
    {
        _moviesDbContext.Movies.Add(movie);
        // Outputting for debugging purposes
        _moviesDbContext.ChangeTracker.DetectChanges();
        Console.WriteLine(_moviesDbContext.ChangeTracker.DebugView.LongView);

        _moviesDbContext.SaveChanges();

        return movie.Id.ToString();
    }

Enter fullscreen mode Exit fullscreen mode
public string AddActor(Actor actor)
    {
        _moviesDbContext.Actors.Add(actor);
        // Outputting for debugging purposes
        _moviesDbContext.ChangeTracker.DetectChanges();
        Console.WriteLine(_moviesDbContext.ChangeTracker.DebugView.LongView);

        _moviesDbContext.SaveChanges();

        return actor.Id.ToString();
    }

Enter fullscreen mode Exit fullscreen mode
  • UpdateMovie and UpdateActor now take an additional parameter for the id and the contents of each method have been changed slightly:
public void UpdateMovie(string movieId, Movie movie)
    {
        var movieToUpdate = _moviesDbContext.Movies.FirstOrDefault((m => m.Id == ObjectId.Parse(movieId)));
        _moviesDbContext.Movies.Update(movieToUpdate);

        _moviesDbContext.ChangeTracker.DetectChanges();
        Console.WriteLine(_moviesDbContext.ChangeTracker.DebugView.LongView);

        _moviesDbContext.SaveChanges();
    }

Enter fullscreen mode Exit fullscreen mode
 public void UpdateActor(string actorId, Actor actor)
    {
        var actorToUpdate = _moviesDbContext.Actors.FirstOrDefault(a => a.Id == ObjectId.Parse(actorId));
        _moviesDbContext.Actors.Update(actorToUpdate);
        // Outputting for debugging purposes
        _moviesDbContext.ChangeTracker.DetectChanges();
        Console.WriteLine(_moviesDbContext.ChangeTracker.DebugView.LongView);

        _moviesDbContext.SaveChanges();
    }
Enter fullscreen mode Exit fullscreen mode

Finally, if you haven’t already, make sure to delete the methods we removed from the interface for comments, moviecasts, and moviegenres.

Updating Program.cs

Phew! We are almost there. The last couple of steps involve updates in the Program.cs file. First, we need to change the code that configures EF Core to use MongoDB instead of PostgreSQL.

builder.Services.AddDbContext<MoviesDbContext>(options =>
options.UseMongoDB(builder.Configuration.GetConnectionString("MongoDBAtlasConnectionString"), "migrated_mflix").EnableSensitiveDataLogging());
Enter fullscreen mode Exit fullscreen mode

This project uses the Minimal API’s feature, so all our endpoints are defined within Program.cs. The final step we are going to take is to update our endpoints to remove the ones we no longer need and update some existing ones that currently have errors.

For simplicity, I will paste the final code here and then explain what has changed.

using Microsoft.EntityFrameworkCore;
using MigratedMoviesEFCore.Models;
using MigratedMoviesEFCore.Services;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddOpenApi();

builder.Services.AddDbContext<MoviesDbContext>(options =>
    options.UseMongoDB(builder.Configuration.GetConnectionString("MongoDBAtlasConnectionString"), "migrated_mflix")
           .EnableSensitiveDataLogging());

builder.Services.AddScoped<IMovieService, MovieService>();

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

#region movies
app.MapGet("/movies", (IMovieService movieService) =>
{
    return movieService.GetAllMovies();
})
.WithName("GetMovies");

app.MapGet("/movies/{id}", (IMovieService movieService, string id) =>
{
    return movieService.GetMovieById(id);
}).WithName("GetMovieById");

app.MapPost("/movies", (IMovieService movieService, Movie movie) =>
{
    string newId = movieService.AddMovie(movie);
    return newId;
}).WithName("AddMovie");

app.MapPut("/movies/{id}", (IMovieService movieService, string id, Movie movieToUpdate) =>
{
    movieService.UpdateMovie(id, movieToUpdate);
}).WithName("UpdateMovie");

app.MapDelete("/movies/{id}", (IMovieService movieService, string id) =>
{
    var movieToDelete = movieService.GetMovieById(id);
    movieService.DeleteMovie(movieToDelete);
}).WithName("DeleteMovie");
#endregion

#region actors
app.MapGet("/actors", (IMovieService movieService) =>
{
    return movieService.GetAllActors();
}).WithName("GetActors");

app.MapGet("/actors/{id}", (IMovieService movieService, string id) =>
{
    return movieService.GetActorById(id);
}).WithName("GetActorById");

app.MapPost("/actors", (IMovieService movieService, Actor actor) =>
{
    string newId = movieService.AddActor(actor);
    return newId;
}).WithName("AddActor");

app.MapPut("/actors/{id}", (IMovieService movieService, string id, Actor actorToUpdate) =>
{
    movieService.UpdateActor(id, actorToUpdate);
}).WithName("UpdateActor");

app.MapDelete("/actors/{id}", (IMovieService movieService, string id) =>
{
    var actorToDelete = movieService.GetActorById(id);
    movieService.DeleteActor(actorToDelete);
}).WithName("DeleteActor");
#endregion

app.MapGet("/theaters", (IMovieService movieService) =>
{
    return movieService.GetAllTheaters();
}).WithName("GetTheaters");

app.MapGet("/users", (IMovieService movieService) =>
{
    return movieService.GetAllUsers();
}).WithName("GetUsers");

app.Run();

Enter fullscreen mode Exit fullscreen mode
  • Updated the query parameter type from int to string in the endpoint for /movies/id and /actors/id
  • Removed the parsing to Int32 of the string id in the DELETE endpoints
  • Removed the endpoints that are no longer applicable

Just like that, we are ready to test it!

Testing with .http file

A feature that has been around for a few years in .NET, but that I always forget about, is .http files (shout-out to our amazing Champion Kev Smith who taught me they existed). These files act as a way to test your own APIs, or even endpoints on someone else’s API, without even needing to leave your IDE or VS Code (with an extension).

Note: If you would rather use Swagger, you can run the application in debug mode and access /swagger.

However, for this tutorial, we are going to test our endpoints by writing calls in the MigratedMoviesEFCore.http file in the root of the project.

Make sure to update appsettings.Development.json and appsettings.json with your connection string.

 "ConnectionStrings": {
      "MongoDBAtlasConnectionString": "<Your Atlas URL>/migrated_mflix"
    }
Enter fullscreen mode Exit fullscreen mode

From the terminal, run your application by calling dotnet run as the application needs to be running and available to test the endpoints.

Testing /movies

Inside the .http file, replace the existing GET with the following code to test the endpoint to get all movies:

GET {{MigratedMoviesEFCore_HostAddress}}/movies
Accept: application/json

###
Enter fullscreen mode Exit fullscreen mode

The existence of this declaration, plus the three # symbols, will tell it that this is a complete test and you will see a play button appear, or perhaps you will see ‘Send Request’ appear above it, depending on your IDE/editor of choice.

Send the request and you should get back a list of all the available movies.

Screenshot showing successfully fetching all movies

Next, we are going to create a new movie. This will look very similar to GET but instead uses POST and passes a JSON object to use in the request.

POST {{MigratedMoviesEFCore_HostAddress}}/movies
Content-Type: application/json

{
  "title": "The Shawshank Redemption",
  "year": 1994,
  "released": "1994-09-23T00:00:00Z",
  "rated": "R",
  "runtime": 142,
 "plot": "Two imprisoned men plot an escape",
  "fullplot": "Andy Dufresne is sentenced to life in prison...",
  "poster": "https://www.imdb.com/title/tt0111161/mediaviewer/rm10105600.jpg"
}
###
Enter fullscreen mode Exit fullscreen mode

If you run that, you will see in the response that there is an id value returned. Copy that to clipboard as we will use that to test getting a movie by its Id.

GET {{MigratedMoviesEFCore_HostAddress}}/movies/<your copied movie id>

###
Enter fullscreen mode Exit fullscreen mode

Make sure to update the id with the value that you copied. This will return a document containing the data of the movie you added in the previous step.

Succesfully returning document showing the data we just added

We can create and read the document, so now, let’s test updating it:

PUT {{MigratedMoviesEFCore_HostAddress}}/movies/<your copied id>
Content-Type: application/json

{
  "title": "The Shawshank Redemption",
  "year": 1994,
  "released": "1994-09-23T00:00:00Z",
  "rated": "18",
  "runtime": 142,
 "plot": "Two imprisoned men plot an escape",
  "fullplot": "Andy Dufresne is sentenced to life in prison...",
  "poster": "https://www.imdb.com/title/tt0111161/mediaviewer/rm10105600.jpg"
}

###
Enter fullscreen mode Exit fullscreen mode

Again, make sure to add your id value. This doesn't return anything but you should see a 200 OK value to show it updated.

You could always run the test for getting that specific movie again if you would like to see that your changes have been applied.

Finally, we have DELETE:

DELETE {{MigratedMoviesEFCore_HostAddress}}/movies/<your copied id>

###
Enter fullscreen mode Exit fullscreen mode

Testing /actors

Now that we have tested the /movies endpoints, it’s time to move on to /actors. Like the /movies endpoint, we are going to write two GET tests, a POST, a PUT, and a DELETE.

GET {{MigratedMoviesEFCore_HostAddress}}/actors

###
Enter fullscreen mode Exit fullscreen mode
POST {{MigratedMoviesEFCore_HostAddress}}/actors
Content-Type: application/json

{
  "name": "Morgan Freeman",
  "dateOfBirth": "1937-06-02",
  "placeOfBirth": "Memphis, Tennessee, USA"
}

###

Enter fullscreen mode Exit fullscreen mode

Copy the returned id of the new actor on insertion for use in the next tests.

GET {{MigratedMoviesEFCore_HostAddress}}/actors/<your copied actor document id>

###
Enter fullscreen mode Exit fullscreen mode
PUT {{MigratedMoviesEFCore_HostAddress}}/actors/<your copied actor document id>
Content-Type: application/json

{
  "name": "Leonardo DiCaprio",
  "dateOfBirth": "1974-11-11T00:00:00.000Z",
  "placeOfBirth": "Los Angeles, California, USA"
}
###
Enter fullscreen mode Exit fullscreen mode

Lastly, we can finish off this endpoint by deleting the recently added document.

DELETE {{MigratedMoviesEFCore_HostAddress}}/actors/<your copied actor document id>

###
Enter fullscreen mode Exit fullscreen mode

Testing /theaters and /users

We are going to combine the last two endpoints as these have been kept simple and only have a single GET endpoint for both to fetch all documents.

GET {{MigratedMoviesEFCore_HostAddress}}/theaters

###

GET {{MigratedMoviesEFCore_HostAddress}}/users

###
Enter fullscreen mode Exit fullscreen mode

Summary

Amazing! We have been able to update from using PostgreSQL as the database under the hood, to the flexible, scalable, and modern solution of MongoDB, while still enjoying the paradigms from EF Core that we are used to.

You can find the final full code to this on GitHub.

Give it a try today and see how easy it is to migrate over, with the great partnership of the Relational Migrator and our EF Core Provider. If you have any questions or want to share how you got on, you can visit our Community Forums.

Top comments (0)