DEV Community

Cover image for Unit of Work, Repositories Pattern, and Fluent Validation in Dotnet Core Web API 🛠️"
Abayomi Ogunnusi
Abayomi Ogunnusi

Posted on

Unit of Work, Repositories Pattern, and Fluent Validation in Dotnet Core Web API 🛠️"

Today, we will delve into the concept of the Unit of Work and explore its advantages when integrated into the Repository pattern.

If you're new to developing REST APIs with .NET and Entity Framework Core, be sure to explore this beginner-friendly guide for step-by-step instructions.Click here to read

Ingredients

🌻 IDE - I will use Rider
🌻 Dotnet Core SDK
🌻 C# Extension for Visual Studio Code
🌻 Dotnet CLI

Prerequisite

🏝️ Basic Knowledge of C#
🏝️ How to use the terminal
🏝️ Create a MSSQL database (refer to the link above).

Agenda
🪢 Introduction: Unit Of Work
🪢 Create a new C# project in Rider.
🪢 Add the necessary references to the .NET Framework and Entity Framework libraries.
🪢 Create a connection string to the database.
🪢 Map the entities to the tables in the database.
🪢 Write code to access the data from the database.
🪢 Run the application and test it.
🪢 Add fluent Validation vs Using Data Annotation.


Introduction

In simple terms, the Unit of Work in .NET acts as a manager that tracks changes made to data when working with a database. Once you're finished, it saves all those changes in one go. This pattern streamlines the management of multiple repositories by using a shared database context class. Reference: Microsoft ref:microsoft.

It basically helps us to coordinate the work of multiple repositories.

Example in a convention repository pattern having PlayerRepository, BookRepository, StudentRepository and their Interfaces we will have to inject all of them in the Program.cs file and then use them in the main method. But with the help of Unit of Work, we can inject only the Unit of Work in the Program.cs file and then use the repositories in the main method.

Without Unit Of Work

//...
// add repositories and their interfaces to the DI container
builder.Services.AddScoped<IPlayerRepository, PlayerRepository>();
builder.Services.AddScoped<IBookRepository, BookRepository>();
builder.Services.AddScoped<IStudentRepository, StudentRepository>();
//...
Enter fullscreen mode Exit fullscreen mode

With Unit Of Work

//...
// add UnitOfWork and its interface to the DI container
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
//...
Enter fullscreen mode Exit fullscreen mode
Create A solution and Project Name

Since we have seen some of the benefits of using this pattern, let's create a new Project.
Open Rider or any IDE and create solution name and project name

Image description

Choose a name for the Solution and click create
Image description

You should have something like

Image description

Clean up

Let's clean up the project by removing the WeatherForecast.cs and WeatherForecastController.cs files

Now let's create our folder structure

Image description
We will have the following folders

  • Controllers
  • Data
  • Models
  • Services
    • Services/Repositories
    • Services/IRepositories
  • Utils (For our Fluent Validation)
  • DTOs (For our Data Transfer Object)
Models

Models are the internal representation of data. They are the classes that represent the tables in the database. They are also called entities. In the Models folder, create a class called BaseEntity.cs and Player.cs
Image description

In BaseEntity.cs we will have the following

namespace PlayerApi.Models;

public class BaseEntity
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

And the Player Entity, we will inherit from the BaseEntity.cs class

namespace PlayerApi.Models;

public class Player: BaseEntity
{
    public string Name { get; set; } = string.Empty;
    public string Password { get; set; } =  string.Empty;
    public string Email { get; set; } =string.Empty;
}
Enter fullscreen mode Exit fullscreen mode

Data context
  • A data context in .NET is a bridge between your application and a database, providing methods to query and manipulate data.
  • In Entity Framework, a data context is represented by a class that inherits from the DbContext class. (Highlited in red in the image below)

Image description

We are getting the missing reference Error DbContext because we need to install the packages. So I will bring in all packages needed at once for this project

  • Microsoft.EntityFrameworkCore
  • Microsoft.EntityFrameworkCore.SqlServer
  • Microsoft.EntityFrameworkCore.Tools
  • FluentValidation.AspNetCore

Image description

Let's import the missing reference DbContext and DbSet in the DataContext.cs file.

Image description

using Microsoft.EntityFrameworkCore;

namespace PlayerApi.Data;

public class DataContext: DbContext // we inherit from the DbContext class coming from the Microsofy.EntityFrameworkCore we installed earlier
{

}
Enter fullscreen mode Exit fullscreen mode

⏭️ Now we need to inject our DbContextOptions in a Parameterize constructor, so we can pass in the options when we create an instance of the DataContext class.

using Microsoft.EntityFrameworkCore;
using PlayerApi.Models;

namespace PlayerApi.Data;

public class DataContext: DbContext
{
    public DataContext(DbContextOptions<DataContext> options): base(options)
    {

    }

    // DbSets are used to query and save instances of entities to a database
    // Here the DbSet is of type Player and the name of the table in the database will be Players
    public DbSet<Player> Players { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Interfaces

IGenericRepository: This interface will be used by all the repositories. It will have the basic CRUD operations that will be implemented by the repositories. It will be generic so that it can be used by all the repositories.

namespace PlayerApi.Services.IRepositories;

public interface IGenericRepository<T> where T : class // T is a generic type, it means that it can be of any type
{
    Task<IEnumerable<T>> All(); // Task is a type that represents an asynchronous operation that can return a value
    Task<T> GetById(int id);

    Task<bool> Add(T entity); // returns true if successful

    Task<bool> Delete(int id); 

    Task<bool> Upsert(T entity);
}
Enter fullscreen mode Exit fullscreen mode
Explaination: Task> All();
  • Task is a type that represents an asynchronous operation that can return a value
  • IEnumerable is used to iterate over a collection of the same type
  • T is a generic type, it means that it can be of any type
  • All is the name of the method
  • So Task> All(); means that we are returning a Task that will return a collection of type T
IPlayerRepository

The IGernericRepository will be implemented by the IPlayerRepository. The IPlayerRepository will have methods that are specific to the Player entity.

using PlayerApi.Models;

namespace PlayerApi.Services.IRepositories;

public interface IPlayerRepository: IGenericRepository<Player>
{
    // add methods that are specific to the Player entity
    // e.g Task<Player> GetByEmail(string email);
    // e.g Task<Player> GetByName(string name);
    // e.g Task<Player> GetByEmailAndPassword(string email, string password);
}
Enter fullscreen mode Exit fullscreen mode
IUnitOfWork

The IUnitOfWork will be used to coordinate the work of multiple repositories. It will have a property for each repository. It will also have a method that will save all the changes made to the database.

namespace PlayerApi.Services.IRepositories;

public interface IUnitOfWork
{
    IPlayerRepository Players { get; } // we have only get because we don't want to set the repository. setting the repository will be done in the UnitOfWork class

    Task CompleteAsync(); // this method will save all the changes made to the database
}
Enter fullscreen mode Exit fullscreen mode

Repositories

The repositories will implement the IGenericRepository and IPlayerRepository interfaces. They will have the implementation of the methods in the interfaces. They will also have a constructor that will take the DataContext and ILoggerFactory as parameters. The DataContext will be used to access the database and the ILoggerFactory will be used to log errors.

GenericRepository Implementation

The GenericRepository will implement the IGenericRepository interface. It will have the implementation of the methods in the interface. It will also have a constructor that will take the DataContext and ILoggerFactory as parameters. The DataContext will be used to access the database and the ILoggerFactory will be used to log errors.

using Microsoft.EntityFrameworkCore;
using PlayerApi.Data;
using PlayerApi.Services.IRepositories;

namespace PlayerApi.Services.Repositories;

public class GenericRepository<T> : IGenericRepository<T> where T : class
{
    protected DataContext _context;
    protected DbSet<T> dbSet;
    protected readonly ILogger _logger;

    // constructor will take the context and logger factory as parameters
    public GenericRepository(
        DataContext context,
        ILogger logger
    )
    {
        _context = context;
        _logger = logger;
        this.dbSet = _context.Set<T>();
    }

    public virtual async Task<IEnumerable<T>> All() // virtual means that this method can be overriden by a class that inherits from this class
    {
        return await dbSet.ToListAsync();
    }

    public  virtual async Task<T> GetById(int id)
    {
        try
        {
            return await dbSet.FindAsync(id);
        }
        catch (Exception e)
        {
            _logger.LogError(e, "Error getting entity with id {Id}", id);
            return null;
        }
    }

    public virtual async  Task<bool> Add(T entity)
    {
        try
        {
            await dbSet.AddAsync(entity);
            return true;
        }
        catch (Exception e)
        {
            _logger.LogError(e, "Error adding entity");
            return false;
        }
    }

   public virtual async Task<bool> Delete(int id)
{
    try
    {
        var entity = await dbSet.FindAsync(id);
        if (entity != null)
        {
            dbSet.Remove(entity);
            return true;
        }
        else
        {
            _logger.LogWarning("Entity with id {Id} not found for deletion", id);
            return false;
        }
    }
    catch (Exception e)
    {
        _logger.LogError(e, "Error deleting entity with id {Id}", id);
        return false;
    }
}


    public Task<bool> Upsert(T entity)
    {
        throw new NotImplementedException();
    }
}
Enter fullscreen mode Exit fullscreen mode
UnitOfWork Implementation

The UnitOfWork will implement the IUnitOfWork interface. It will have a property for each repository. It will also have a constructor that will take the DataContext and ILoggerFactory as parameters. The DataContext will be used to access the database and the ILoggerFactory will be used to log errors.

using PlayerApi.Data;
using PlayerApi.Services.IRepositories;

namespace PlayerApi.Services.Repositories;

public class UnitOfWork:IUnitOfWork, IDisposable // IDisposable is used to free unmanaged resources
{
    private readonly DataContext _context;
    private readonly ILogger _logger;

    public IPlayerRepository Players { get; private set; }

    // constructor will take the context and logger factory as parameters
    public UnitOfWork(
        DataContext context,
        ILoggerFactory loggerFactory
    )
    {
        _context = context;
        _logger = loggerFactory.CreateLogger("logs");

        Players = new PlayerRepository(_context, _logger);
    }

    public async Task CompleteAsync()
    {
        await _context.SaveChangesAsync();
    }

    public  void Dispose()
    {
        _context.Dispose();
    }

}

Enter fullscreen mode Exit fullscreen mode

A quick cheat: To read more about what an interface or a class does, over on the interface or class name.

Image description


Controller

The controller will be used to handle requests from the client. Clients can be web applications, mobile applications, or other servers. The controller will have methods that will be called when a request is made to a specific endpoint. The methods will return a response to the client. The response can be a success response or an error response. The controller will also have a constructor that will take the IUnitOfWork as a parameter. The IUnitOfWork will be used to access the repositories.

using Microsoft.AspNetCore.Mvc;
using PlayerApi.Models;
using PlayerApi.Services.IRepositories;

namespace PlayerApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class PlayerController: ControllerBase // ControllerBase is a base class for MVC controller without view support.
    {
        private readonly ILogger<PlayerController> _logger; // ILogger takes the type of the class as a parameter
        private readonly IUnitOfWork _unitOfWork; // readonly means that the variable can only be assigned a value in the constructor

        public PlayerController(
            ILogger<PlayerController> logger,
            IUnitOfWork unitOfWork
            )
        {
            _logger = logger;
            _unitOfWork = unitOfWork;
        }

        // create a new player
        [HttpPost]
        public async Task<IActionResult> CreateUser(Player player) // 
        {
           if(ModelState.IsValid)
            {


                await _unitOfWork.Players.Add(player); // add the player to the database
                await _unitOfWork.CompleteAsync(); // save the changes to the database

                return CreatedAtAction("GetItem", new { id = player.Id }, player);
            }

            return new JsonResult("Something went wrong"){StatusCode = 500};
        }

        //get a single player
        [HttpGet("{id}")]
        public async Task<IActionResult> GetItem(int id)
        {
            var player = await _unitOfWork.Players.GetById(id);
            if(player == null)
            {
                return NotFound();
            }

            return Ok(player);
        }

        //get all players
        [HttpGet]
        public async Task<IActionResult> GetItems()
        {
            var players = await _unitOfWork.Players.All();
            if(players == null)
            {
                return NotFound();
            }

            return Ok(players);
        }

        //update a player
        [HttpPut("{id}")]
        public async Task<IActionResult> UpdateItem(int id, Player player)
        {
            if(id != player.Id)
            {
                return BadRequest();
            }

            await _unitOfWork.Players.Upsert(player, player.Id);
            await _unitOfWork.CompleteAsync();

            return NoContent();
        }

        //delete a player
        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteItem(int id)
        {
            var player = await _unitOfWork.Players.GetById(id);
            if(player == null)
            {
                return NotFound();
            }

            await _unitOfWork.Players.Delete(id);
            await _unitOfWork.CompleteAsync();

            return NoContent();
        }
    }


Enter fullscreen mode Exit fullscreen mode
Connection Strings

A connection string is a string that specifies information about a data source and the means of connecting to it.
In the below

  • Server is the name of the server that hosts the database
  • Database is the name of the database(in our case it is playersdb)
  • User Id is the username of the user that has access to the database
  • Password is the password of the user that has access to the database

Image description

Program.cs

Program.cs is the entry point of the application. It is the first file that is executed when the application is run. It is also used to configure the application. Let's inject the IUnitOfWork into the Program.cs file. Also let's add the connection string to the configuration builder. The connection string will be used to connect to the database.

Image description

Build Application

Run dotnet build command to build the application. In the image below, you can see that the build was successful.
Image description

Run migration

The migration will create the tables in the database. Run the dotnet ef migrations add InitialCreateclear command to create the migration. The migration will be created in the Migrations folder. The name of the migration will be InitialCreateclear. The migration will have the code to create the tables in the database. Run the dotnet ef database update command to run the migration. The tables will be created in the database.

Image description

Sync Database

Image description

Let's check the Created Migration Table in the Db

In the image below, the part highlihted in orange references the Table in the Db called Players

Image description

Run the app

Run the dotnet run command to run the application. Or click on the run button in the IDE (Highlited in orange in the image below)

Image description

--

Test the APP

Let's test the application using the Http Client in Rider. Create a new file with the .http extension.

Create a new player

Image description

⚠️ In the above you can see we have a problem already

  1. We are not validating our request, on the get all we see that our appllication has email has string
  2. We are taking the entity(internal representaion) instead of a Data transfer Object
  3. We didn't hash our password (Not covered in this tutorial).

Get all Player

Image description

Check the Db

Image description

Add fluent Validation

Let's address the ⚠️ we saw earlier
Create a PlayerValidator class in the Utils folder

using FluentValidation;
using PlayerApi.Models;

namespace PlayerApi.Utils;

public class PlayerValidator : AbstractValidator<PlayerRequest>
{
    public PlayerValidator()
    {
        RuleFor(x => x)
            .NotNull();

        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(20);

        RuleFor(x => x.Password)
            .NotEmpty()
            .MaximumLength(20);

        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .MaximumLength(50);
    }
}
Enter fullscreen mode Exit fullscreen mode
Create a PlayerRequest class in the DTOs folder
namespace PlayerApi.Dtos;

public class PlayerRequest
{
    public string Name { get; set; } = string.Empty;
    public string Password { get; set; } =  string.Empty;
    public string Email { get; set; } =string.Empty;
}
Enter fullscreen mode Exit fullscreen mode

So instead of using the entity we will use the DTOs

Lets revamp our Add player method in the controller to use the PlayerRequest

// create a new player
[HttpPost]
public async Task<IActionResult> CreateUser(PlayerRequest playerRequest) // 
{

    var player = new Player
    {
        Name = playerRequest.Name,
        Password = playerRequest.Password,
        Email = playerRequest.Email
    };
    await _unitOfWork.Players.Add(player); // add the player to the database
    await _unitOfWork.CompleteAsync(); // save the changes to the database

    return CreatedAtAction("GetItem", new { id = player.Id }, player);
}
Enter fullscreen mode Exit fullscreen mode

In our Program.cs we add AddFluentValidation to the container.


builder.Services.AddFluentValidation(config => 
    config.RegisterValidatorsFromAssembly(Assembly.GetExecutingAssembly()));
Enter fullscreen mode Exit fullscreen mode

Now let's test our app again, this time I will use swagger to test the app
Visit the same url as before but this time add /swagger/index.html to the url
Image description

The request body no longer has the CreatedAt and UpdatedAt properties

{
  "name": "string",
  "password": "string",
  "email": "string"
}
Enter fullscreen mode Exit fullscreen mode

So Issue Number 1 has been addressed

Let's also ensure that the right email format is working as expected
Image description

Conclusion

In this article, we have seen how to implement the Unit of Work and Repository pattern in .NET. We have also seen how to use the Fluent Validation library to validate the request body. Thanks for reading. If you have any questions or comments, please leave them in the comment section below. You can also reach me on LinkedIn.

Top comments (3)

Collapse
 
mmagdy10 profile image
Mohamed Magdy

Great article. In real project, we can use Mapster or any mapping library from Nuget to map from DTO to entity class.

Collapse
 
drsimplegraffiti profile image
Abayomi Ogunnusi

Yes, you are absolutely correct. This particular project does not address that. Thank you for your note.

Collapse
 
sutharsonp profile image
Sutharson

Hi Admin

Can you help me to solve this issues?

Image description