[Updated] Building a Generic Service for CRUD Operations in C# .NET Core
In this article, we will explore how to create a generic service class in a C# .NET Core application to handle CRUD (Create, Read, Update, Delete) operations. This generic service will communicate with the database context and return results for the specified models and DTOs. We will go through the code implementation step by step and discuss how to use this generic service class effectively.
Prerequisites
To follow along with this tutorial, make sure you have the following:
- Basic knowledge of C# and .NET Core
- Working knowledge of Entity Framework Core and database operations
Overview of the Generic Service Class
The GenericCRUDService
class is a generic implementation of the IGenericCRUDService
interface. It provides methods to perform CRUD operations on entities, allowing flexibility for different models and DTOs. Let’s take a closer look at the interface and its methods:
public interface IGenericCRUDService<TModel, TDto>
{
Task<IEnumerable<TDto>> GetAll(Expression<Func<TModel, bool>>? where = null, params string[] includes);
Task<TDto?> GetById(Expression<Func<TModel, bool>> predicateToGetId, params string[] includes);
Task<TDto> Add(TDto dto, params Expression<Func<TModel, object>>[] references);
Task<TDto> Update(TDto dto, Expression<Func<TModel, bool>>? where = null, params Expression<Func<TModel, object>>[] references);
Task<bool> Delete(IGuid id);
}
The interface defines the following methods:
GetAll
: Retrieves all entities based on an optional predicate and includes related entities.GetById
: Retrieves a single entity using a predicate to determine the identifier.Add
: Adds a new entity to the database context.Update
: Updates an existing entity based on a provided predicate and supplied data.Delete
: Deletes an entity using an IGuid identifier.
Now let’s dive into the implementation details of the GenericCRUDService
class.
Implementation of the GenericService Class
The GenericCRUDService
class is a concrete implementation of the IGenericCRUDService
interface. It requires an instance of IMapper
(from AutoMapper) and an DbContext
object to perform the necessary operations.
public class GenericCRUDService<TModel, TDto> : IGenericCRUDService<TModel, TDto>
where TModel : class
where TDto : class
{
private readonly IMapper _mapper;
private readonly IContext _dbContext;
public GenericCRUDService(IMapper mapper, IContext dbContext)
{
_mapper = mapper;
_dbContext = dbContext;
}
// Rest of the methods will be implemented here
}
The constructor of the GenericCRUDService
class accepts an IMapper
instance and a IContext
object. These dependencies are injected using constructor injection to ensure loose coupling and easier testing.
Implementing the GetAll Method
public async Task<IEnumerable<TDto>> GetAll(Expression<Func<TModel, bool>>? where = null,
params string[] includes)
{
var query = ApplyIncludes(_dbContext.Set<TModel>(), includes);
if (where != null)
{
query = query.Where(where);
}
var entities = await query.ToListAsync();
return _mapper.Map<IEnumerable<TDto>>(entities);
}
The GetAll
method constructs an IQueryable
query based on the provided where clause and include parameters. It applies these conditions to the query and then maps the result to a list of DTOs.
Implementing the GetById Method
public async Task<TDto?> GetById(Expression<Func<TModel, bool>> predicateToGetId, params string[] includes)
{
var query = ApplyIncludes(_dbContext.Set<TModel>(), includes);
var entity = await query.FirstOrDefaultAsync(predicateToGetId);
return entity == null ? null : _mapper.Map<TDto>(entity);
}
The GetById
method first creates a query with the provided includes. It then retrieves the first entity that satisfies the predicateToGetId
. The entity, if found, is mapped to its corresponding DTO.
Implementing the Add Method
public async Task<TDto> Add(TDto dto, params Expression<Func<TModel, object>>[] references)
{
var entity = _mapper.Map<TModel>(dto);
LoadReferences(entity, references);
await _dbContext.Set<TModel>().AddAsync(entity);
await _dbContext.SaveChangesAsync();
return _mapper.Map<TDto>(entity);
}
The Add
method first maps the provided DTO to the entity type. Then it loads the entity's navigation properties, adds the entity to the database context, and saves the changes.
Implementing the Update Method
public async Task<TDto> Update(TDto dto, Expression<Func<TModel, bool>>? where = null, params Expression<Func<TModel, object>>[] references)
{
var entity = await _dbContext.Set<TModel>().FirstOrDefaultAsync(where ?? throw new ArgumentNullException(nameof(where)));
_mapper.Map(dto, entity);
LoadReferences(entity, references);
_dbContext.Update(entity);
await _dbContext.SaveChangesAsync();
return _mapper.Map<TDto>(entity);
}
The Update
method retrieves the entity to be updated based on a provided predicate, maps the DTO properties onto the entity, loads the navigation properties, and then updates the entity in the database context.
Implementing the Delete Method
The Delete
method deletes an entity by its identifier.
public async Task<bool> Delete(IGuid id)
{
var entity = await _dbContext.Set<TModel>().FindAsync(id);
if (entity == null) return false;
_dbContext.Remove(entity);
await _dbContext.SaveChangesAsync();
return true;
}
The Delete
method first retrieves the entity using the provided id. If it finds the entity, it removes it from the database context and saves the changes.
The ApplyIncludes
& LoadReferences
methods:
private IQueryable<TModel> ApplyIncludes(IQueryable<TModel> query, params string[] includes)
{
return includes.Aggregate(query, (current, include) => current.Include(include));
}
private void LoadReferences(TModel entity, params Expression<Func<TModel, object>>[] references)
{
foreach (var reference in references)
{
_dbContext.Entry(entity).Reference(reference).Load();
}
}
ApplyIncludes
is a helper method that applies multiple includes to the query. The LoadReferences
method loads navigation properties for a specific entity. This is necessary for cases where some properties might not be loaded by default due to lazy loading.
That covers the implementation details for the GenericCRUDService
class.
Here’s the full code: Updated GenericCRUDService
Using the Generic Service Class
Now that we have implemented the GenericService
class, let's see how we can use it to handle CRUD operations in our application.
// Example usage
IGenericCRUDService<Customer, CustomerDto> customerService = new GenericCRUDService<Customer, CustomerDto>(mapper, dbContext);
// Get all customers
var allCustomers = await customerService.GetAll();
// Get customers based on a predicate and include related entities
var filteredCustomers = await customerService.GetAll(c => c.Age > 18 && c.IsActive, "Orders");
// Get a customer using a predicate
var customer = await customerService.GetById(c => c.Id == 1);
// Add a new customer
var newCustomerDto = new CustomerDto { Name = "John Doe", Age = 30 };
var addedCustomer = await customerService.Add(newCustomerDto);
// Update an existing customer
var existingCustomerDto = new CustomerDto { Id = 1, Name = "Updated Name", Age = 35 };
var updatedCustomer = await customerService.Update(existingCustomerDto, c => c.Id == 1);
// Delete a customer
var isDeleted = await customerService.Delete(id);
By using the generic service class, we can handle CRUD operations for different models and DTOs in a consistent and efficient manner.
Unit Testing
To unit test this service, we will use the Xunit testing library along with the Moq mocking library which are popular tools in the .NET Core ecosystem.
Below is a basic example of how you can structure your unit tests for the GenericCRUDService
. The example will cover the GetById
method.
Let’s start by setting up the test class:
public class GenericCRUDServiceTests
{
private readonly Mock<IMapper> _mockMapper;
private readonly Mock<DbSet<MyEntity>> _mockDbSet;
private readonly Mock<IContext> _mockContext;
private readonly GenericCRUDService<MyEntity, MyDto> _service;
private readonly MyEntity _entity;
private readonly MyDto _dto;
public GenericCRUDServiceTests()
{
// Setup your test data
_entity = new MyEntity { Id = Guid.NewGuid(), Name = "TestName" };
_dto = new MyDto { Id = _entity.Id, Name = _entity.Name };
// Setup the Mocks
_mockMapper = new Mock<IMapper>();
_mockDbSet = new Mock<DbSet<MyEntity>>();
_mockContext = new Mock<IContext>();
// Setup the service with mocks
_service = new GenericCRUDService<MyEntity, MyDto>(_mockMapper.Object, _mockContext.Object);
}
// Tests go here
}
Here we’re initializing the required mock objects and setting up the GenericCRUDService
with mock implementations of IMapper
and IContext
.
Let’s write a test for the GetById
method:
[Fact]
public async Task GetById_ShouldReturnDto_WhenEntityExists()
{
// Arrange
var data = new List<MyEntity> { _entity }.AsQueryable();
_mockDbSet.As<IQueryable<MyEntity>>().Setup(m => m.Provider).Returns(data.Provider);
_mockDbSet.As<IQueryable<MyEntity>>().Setup(m => m.Expression).Returns(data.Expression);
_mockDbSet.As<IQueryable<MyEntity>>().Setup(m => m.ElementType).Returns(data.ElementType);
_mockDbSet.As<IQueryable<MyEntity>>().Setup(m => m.GetEnumerator()).Returns(data.GetEnumerator());
_mockContext.Setup(c => c.Set<MyEntity>()).Returns(_mockDbSet.Object);
_mockMapper.Setup(m => m.Map<MyDto>(_entity)).Returns(_dto);
// Act
var result = await _service.GetById(e => e.Id == _entity.Id);
// Assert
Assert.NotNull(result);
Assert.Equal(_dto, result);
}
In this test, we’re checking if GetById
returns the correct DTO when an entity with the requested ID exists. We first setup the mock DbSet
to return our test data, then setup the Set<TModel>
method of IContext
to return the mock DbSet
. We also setup the IMapper
to return our DTO when the correct entity is passed in.
The Act
section invokes the GetById
method with a predicate to find our test entity.
The Assert
section checks whether the result is not null and whether the returned DTO matches the expected DTO.
Similarly, you can write tests for all other methods (GetAll
, Add
, Update
, Delete
) provided in the GenericCRUDService
. The structure of the test will remain more or less the same - you arrange by setting up your mocks, act by invoking the method under test, and assert to verify the expected behavior.
Conclusion
In this article, we explored how to create a generic service class in a C# .NET Core application to handle CRUD operations. We discussed the implementation details of the GenericCRUDService class and learned how to use it effectively to perform database operations.
Using a generic service class can help reduce code duplication and provide a consistent way to handle CRUD operations across different models and DTOs in your application. It promotes reusability and simplifies maintenance, making your code more robust and maintainable.
I hope this article has been helpful in understanding how to build and use a generic service class for CRUD operations in C# .NET Core. Happy coding!
Here’s the GitHub Repo:
https://github.com/a-waris/dotnet-generic-crud-service
Feel free to clone it, contribute, and share it.