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>();
//...
With Unit Of Work
//...
// add UnitOfWork and its interface to the DI container
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
//...
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
Choose a name for the Solution and click create
You should have something like
Clean up
Let's clean up the project by removing the WeatherForecast.cs
and WeatherForecastController.cs
files
Now let's create our folder structure
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
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; }
}
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;
}
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)
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
Let's import the missing reference DbContext
and DbSet
in the DataContext.cs
file.
using Microsoft.EntityFrameworkCore;
namespace PlayerApi.Data;
public class DataContext: DbContext // we inherit from the DbContext class coming from the Microsofy.EntityFrameworkCore we installed earlier
{
}
βοΈ 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; }
}
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);
}
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);
}
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
}
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();
}
}
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();
}
}
A quick cheat: To read more about what an interface or a class does, over on the interface or class name.
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();
}
}
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
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.
Build Application
Run dotnet build
command to build the application. In the image below, you can see that the build was successful.
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.
Sync Database
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
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)
--
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
β οΈ In the above you can see we have a problem already
- We are not validating our request, on the get all we see that our appllication has email has string
- We are taking the entity(internal representaion) instead of a Data transfer Object
- We didn't hash our password (Not covered in this tutorial).
Get all Player
Check the Db
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);
}
}
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;
}
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);
}
In our Program.cs we add AddFluentValidation to the container.
builder.Services.AddFluentValidation(config =>
config.RegisterValidatorsFromAssembly(Assembly.GetExecutingAssembly()));
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
The request body no longer has the CreatedAt and UpdatedAt properties
{
"name": "string",
"password": "string",
"email": "string"
}
So Issue Number 1 has been addressed
Let's also ensure that the right email format is working as expected
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)
Great article. In real project, we can use Mapster or any mapping library from Nuget to map from DTO to entity class.
Yes, you are absolutely correct. This particular project does not address that. Thank you for your note.
Hi Admin
Can you help me to solve this issues?