I am currently building a REST webapi. Right now I am wondering if this design is in the right direction.
Firstly, I am using entity framework. I like to call the items in the database DbItems. The items returned by the api are called DtoItems. Just to keep them apart in this example.
Mapping
I use mappers that are added as services to the project. When mapping items, I can add a configuration to map items to certain depth. Next, the mappers don't create items, but only copy parameters. This is on purpose, so I can update existing database objects. Although the code could be simplified if this wasn't the case. (Now that I am writing this, I guess EF will solve this if I update an item with an existing Key or Id.)
Example of a mapper:
public class ProjectItemDbMapper : IMapper<ProjectItemDb, ProjectItemDbDto>
{
private readonly IMapper<User, UserDto> _userMapper;
private readonly AppDbContext _context;
public ProjectItemDbMapper(IMapper<User, UserDto> userMapper, AppDbContext context)
{
_userMapper = userMapper;
_context = context;
}
public async Task MapToDb(ProjectItemDb dbItem, ProjectItemDbDto dtoItem, MapperOptions options)
{
if (dtoItem.Id != null) dbItem.Id = dtoItem.Id.Value;
if (dtoItem.Title != null) dbItem.Title = dtoItem.Title;
if (dtoItem.Created != null) dbItem.Created = dtoItem.Created.Value;
if (dtoItem.Parent?.Id != null)
dbItem.Parent = await _context.ProjectItemDbs.FindAsync(dtoItem.Parent.Id);
if (dtoItem.CreatedByUser?.Id != null)
dbItem.CreatedByUser = await _context.Users.FindAsync(dtoItem.CreatedByUser.Id);
}
public async Task MapToDto(ProjectItemDb dbItem, ProjectItemDbDto dtoItem, MapperOptions options)
{
options.Depth--;
dtoItem.Id = dbItem.Id;
dtoItem.Title = dbItem.Title;
dtoItem.Created = dbItem.Created;
if(options.Depth > 0) //Only map children if required
{
if (dbItem.CreatedByUser != null)
{
await _userMapper.MapToDto(dbItem.CreatedByUser, dtoItem.CreatedByUser, options);
}
if (dbItem.Parent != null)
{
await _MapToDto(dbItem.Parent, dtoItem.Parent, options);
}
}
else if(options.Depth == 0)
{
//TODO: Only load the Id of the object. (Make this a setting in MapperOptions)
}
options.Depth++;
}
}
Repositories
The next problem I have is when deleting objects. When deleting objects, I also need to delete the children. Or throw an error if the children should be manually deleted. Or only detach the link between the objects. This is something I want to manage in repositories. (By lack of a better name) The repository works with the DBEntities. Since the repositories are more or less the same, I am considering making a generic one where the methods can be overridden.
In this example, I also delete child objects when deleting a projectItem. The reason I want to register these as services is, so I can use dependency injection to get other repositories if I want to delete the User for example.
public class GenericRepository<ProjectItem> : IRepository<ProjectItem>
where ProjectItem : class
{
protected readonly AppDbContext _context;
public GenericRepository(AppDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<ProjectItem?> CreateAsync(ProjectItem entity)
{
if (entity == null)
throw new ArgumentNullException(nameof(entity));
_context.Set<ProjectItem>().Add(entity);
await _context.SaveChangesAsync();
return entity;
}
public async Task<bool> DeleteAsync(int id)
{
var entity = await _context.Set<ProjectItem>().FindAsync(id);
if (entity == null)
return false;
var children = entity.Children.ToList();
foreach(var child in children)
await DeleteAsync(child.id);
_context.Set<ProjectItem>().Remove(entity);
await _context.SaveChangesAsync();
return true;
}
public async Task<IEnumerable<ProjectItem>> GetAllAsync(string? filter, string orderBy, int page, int pageSize)
{
IQueryable<ProjectItem> query = _context.Set<ProjectItem>();
// Apply filters to the query (you need to implement filter logic)
// Apply ordering to the query if orderBy is provided
if (!string.IsNullOrEmpty(orderBy))
{
// You need to implement your custom ordering logic here
// Example: query = query.OrderBy(orderBy);
}
// Apply paging to the query
if (pageSize > 0)
{
int skip = (page - 1) * pageSize;
query = query.Skip(skip).Take(pageSize);
}
return await query.ToListAsync();
}
public async Task<ProjectItem?> GetByIdAsync(int id)
{
return await _context.Set<ProjectItem>().FindAsync(id);
}
public async Task<ProjectItem?> UpdateAsync(int id, ProjectItem entity)
{
if (entity == null)
throw new ArgumentNullException(nameof(entity));
var existingEntity = await _context.Set<ProjectItem>().FindAsync(id);
if (existingEntity == null)
return null;
// Update the existing entity properties with the values from the entity
// Make sure to implement this update logic
// Example: existingEntity.Property = entity.Property;
await _context.SaveChangesAsync();
return existingEntity;
}
}
Controllers
An example of a controller to check how all this is used:
[ApiController]
[Route("[controller]")]
public class ProjectItemsController : ControllerBase
{
private readonly IRepository<DbItem> _repositoryContext;
private readonly AppDbContext _context;
private readonly IMapper<DbItem, DtoItem> _mapper;
private readonly UserManager _userManager;
public ProjectItemsController(IRepository<DbItem> repositoryContext, AppDbContext context, IMapper<DbItem, DtoItem> mapper, UserManager userManager)
{
_repositoryContext = repositoryContext;
_context = context;
_mapper = mapper;
_userManager = userManager;
}
[HttpGet]
[Route("GetById")]
virtual public async Task<ActionResult<DtoItem>> GetById(int id)
{
DbItem? dbItem = await _repositoryContext.GetByIdAsync(id);
if (dbItem == null)
return NotFound();
DtoItem dtoItem = Activator.CreateInstance<DtoItem>();
await _mapper.MapToDto(dbItem, dtoItem, new MapperOptions { Depth = 1 });
return Ok(dtoItem);
}
[HttpGet]
[Route("GetAll")]
virtual public async Task<ActionResult<DtoItem[]>> GetAll(string? filters, string? order, int? pageNo, int? pageSize)
{
var dbItems = await _repositoryContext.GetAllAsync(filters, order ?? "Id", pageNo ?? 1, pageSize ?? 25);
List<DtoItem> dtoItems = new List<DtoItem>();
foreach (var dbItem in dbItems)
{
DtoItem dtoItem = Activator.CreateInstance<DtoItem>();
await _mapper.MapToDto(dbItem, dtoItem, new MapperOptions { Depth = 1 });
dtoItems.Add(dtoItem);
}
return Ok(dtoItems);
}
[HttpPost]
[Route("Create")]
virtual public async Task<ActionResult<DtoItem>> Create(DtoItem dtoItem)
{
if (ModelState.IsValid)
{
DbItem dbItem = Activator.CreateInstance<DbItem>();
await _mapper.MapToDb(dbItem, dtoItem, new MapperOptions { Depth = 1 });
dbItem.CreatedByUser = await _userManager.GetUser(User);
dbItem.Created = DateTime.Now;
var newDbItem = await _repositoryContext.CreateAsync(dbItem);
DtoItem newDtoItem = Activator.CreateInstance<DtoItem>();
await _mapper.MapToDto(newDbItem, newDtoItem, new MapperOptions { Depth = 1 });
return CreatedAtAction("GetById", newDtoItem);
}
return BadRequest();
}
[HttpPatch]
[Route("Patch")]
virtual public async Task<ActionResult<DtoItem>> Patch(int id, DtoItem dtoItem)
{
if (ModelState.IsValid)
{
if (await _repositoryContext.GetByIdAsync(id) is DbItem dbItem)
{
if (dbItem.CreatedByUser?.Id != _userManager.GetUser(User).Id)
return BadRequest();
await _mapper.MapToDb(dbItem, dtoItem, new MapperOptions { Depth = 1 });
var newDbItem = await _repositoryContext.CreateAsync(dbItem);
DtoItem newDtoItem = Activator.CreateInstance<DtoItem>();
await _mapper.MapToDto(newDbItem, newDtoItem, new MapperOptions { Depth = 1 });
return Ok(newDtoItem);
}
return NotFound();
}
return BadRequest();
}
[HttpDelete]
[Route("Delete")]
virtual public async Task<IActionResult> Delete(int id)
{
var result = await _repositoryContext.DeleteAsync(id);
if (result)
return Ok();
return NoContent();
}
}
Program.cs
How I register the services.
builder.Services
.AddScoped<IRepository<User>, UserRepository>()
.AddScoped<IMapper<User, UserDto>, UserMapper>()
.AddScoped<IRepository<ProjectItemDb>, ProjectItemDbRepository>()
.AddScoped<IMapper<ProjectItemDb, ProjectItemDbDto>, ProjectItemDbMapper>();