Build CRUD API Endpoints with Nano ASP.NET Boilerplate
In this walkthrough, we’re going to create a new entity and build a service for it with an API controller to handle CRUD operations.
We’ll use the Nano boilerplate, a starter template for building web applications with .NET. You can follow along even if you don’t have the boilerplate, the code here could be applied to any .NET project.
Create a Supplier entity
We will start by creating a new entity called Supplier. This entity will be part of a one-to-many relationship with the Products entity, a sample entity provided by the boilerplate.
The code is divided into layers, with the entities being part of the Domain layer.
Open the Domain layer and in the /Catalog folder, create a new entity class called Supplier. Supplier will have a Name field and an ICollection navigation property for Products. Inherit from AuditableEntity.
public class Supplier : AuditableEntity
{
public string Name { get; set; }
public ICollection<Product> Products { get; set; }
}
In the Product entity, add a GUID SupplierId and navigation property to complete the one-to-many relationship. Once these changes are applied, a SupplierId will be required when creating Products.
public class Product : AuditableEntity
{
public string Name { get; set; }
public string Description { get; set; }
public Supplier Supplier { get; set; }
public Guid SupplierId { get; set; }
}
All entities should derive from BaseEntity, found in /Entities/Common. TenantBaseEntity ensures that the ID will be of type GUID. It implements the IMustHaveTenant interface and tables will isolate tenant data.
AuditableEntity is a base class that derives from TenantBaseEntity and adds CreatedBy, CreatedOn, LastModifiedBy, and LastModifiedOn fields. The values of these fields are handled automatically whenever save changes occurs.
Create Migration
Open up ApplicationDbContext in Infrastructure/Persistence/Contents/ and copy the add-migration command. Provide a name for the migration like AddSupplierEntity and remember to set the default project in PMC to infrastructure. Any pending migrations will be applied on app startup so you don’t need to explicitly run update-database.
Supplier Interface
Create a new folder called /SupplierService in Application/Services and a new interface ISupplierService.
Make sure ISupplierService inherits from ITransientService. This will ensure the interface is registered automatically to the application’s service container with a transient lifecycle.
A quick and easy way to build new services is to simply copy code from the Product example. Copy all the code from IProductService, and paste it in to ISupplierService. Use find and replace in current document, with preserve case, to replace Product with Supplier.
public interface ISupplierService : ITransientService
{
Task<Response<IEnumerable<SupplierDTO>>> GetSuppliersAsync(string keyword = "");
Task<PaginatedResponseTanstack<SupplierDTO>> GetSuppliersTanstackPaginatedAsync(SupplierTableFilter filter); // used by Tanstack Table v8 (React, Vue)
Task<Response<SupplierDTO>> GetSupplierAsync(Guid id);
Task<Response<Guid>> CreateSupplierAsync(CreateSupplierRequest request);
Task<Response<Guid>> UpdateSupplierAsync(UpdateSupplierRequest request, Guid id);
Task<Response<Guid>> DeleteSupplierAsync(Guid id);
}
The interface contains two paginated methods but one of them can be removed. Remove the JQuery Datatables method unless you are planning to use Razor pages.
DTOs
Create a new /DTOs folder within Application/Services/SupplierService and create a SupplierDTO. Create the DTO with an Id and Name field and an IList of ProductDTO. Implement the IDto interface on all DTOs.
public class SupplierDTO : IDto
{
public Guid Id { get; set; }
public string Name { get; set; }
public IList<ProductDTO> Products { get; set; }
}
Create two new DTOs for the create and update requests. Ensure the name field is not empty using fluent validation.
public class CreateSupplierRequest : IDto
{
public string Name { get; set; }
}
public class CreateSupplierValidator : AbstractValidator<CreateSupplierRequest>
{
public CreateSupplierValidator()
{
RuleFor(x => x.Name).NotEmpty();
}
}
Filters
Create a new folder called /Filters with a SupplierTableFilter class and inherit from the common PaginationFilterTanstack class. PaginationFilterTanstack adds fields for pagination. Add an optional string field for keyword to allow searching paginated requests.
public class SupplierTableFilter : PaginationFilterTanstack
{
public string? Keyword { get; set; }
}
Service Class & Specifications
Copy over the code from the Product service and do a search and replace while preserving the case on Product; replace with Supplier. At this point, the only missing elements should be the Ardalis specification classes, which we will now create.
Create a new folder called /Specifications and create a new class called SupplierSearchList. Inherit from Specification and pass Supplier as the type.
public class SupplierSearchList : Specification<Supplier>
{
public SupplierSearchList(string? keyword)
{
// filters
if (!string.IsNullOrWhiteSpace(keyword))
{
_ = Query.Where(x => x.Name.Contains(keyword));
}
_ = Query.OrderByDescending(x => x.CreatedOn); // default sort order
}
}
In this specification, we will check an optional keyword string to search the Supplier name field and provide a default sort order. This specification will be used in the GetSuppliersAsync method to search a full list of suppliers.
public async Task<Response<IEnumerable<SupplierDTO>>> GetSuppliersAsync(string keyword = "")
{
SupplierSearchList specification = new(keyword); // ardalis specification
IEnumerable<SupplierDTO> list = await _repository.GetListAsync<Supplier, SupplierDTO, Guid>(specification); // full list, entity mapped to dto
return Response<IEnumerable<SupplierDTO>>.Success(list);
}
The nice thing about using specification classes to query data is that they can be descriptively named and easily reused.
Next create a class called SupplierSearchTable and inherit from Specification. Like the list specification, check the optional keyword string to search the Supplier name field.
public class SupplierSearchTable : Specification<Supplier>
{
public SupplierSearchTable(string? keyword, string? dynamicOrder = "")
{
if (!string.IsNullOrWhiteSpace(keyword))
{
_ = Query.Where(h => h.Name.Contains(keyword));
}
// sort order
if (string.IsNullOrEmpty(dynamicOrder))
{
_ = Query.OrderBy(h => h.Name);
}
else
{
_ = Query.OrderBy(dynamicOrder); // dynamic sort order
}
}
}
Data table components like Tanstack Table or JQuery Datatables allow the user to dynamically sort table data by columns. To handle this, in the service method GetSuppliersTanstackPaginatedAsync, check the sorting field on SupplierTableFilter. If the sorting field contains data, generate a standardized sorting string using the NanoHelpers function. The string can then be evaluated by Ardalis specification.
public async Task<PaginatedResponseTanstack<SupplierDTO>> GetSuppliersTanstackPaginatedAsync(SupplierTableFilter filter)
{
if (!string.IsNullOrEmpty(filter.Keyword)) // set to first page if any search filters are applied
{
filter.PageNumber = 1;
}
string dynamicOrder = (filter.Sorting != null) ? NanoHelpers.GenerateOrderByStringFromTanstack(filter) : ""; // possible dynamic ordering from datatable
SupplierSearchTable specification = new(filter?.Keyword, dynamicOrder); // ardalis specification
PaginatedResponseTanstack<SupplierDTO> pagedResponse = await _repository.GetTanstackPaginatedResultsAsync<Supplier, SupplierDTO, Guid>(filter.PageNumber, filter.PageSize, specification); // paginated response, entity mapped to dto
return pagedResponse;
}
In the SupplierSearchTable specification, accept an optional string field called dynamicOrder. If the dynamicOrder string is null, return a default order. Otherwise, pass the dynamicOrder to the OrderBy method. Add a reference to the ArdalisSpecificationExtensions class, an extension method provided by the boilerplate, so that it knows how to evaluate the dynamic string.
Create one more specification called SupplierMatchName with a required string field name. This will be used in the CreateSupplierAsync method to check if any suppliers exist already with the given name.
public class SupplierMatchName : Specification<Supplier>
{
public SupplierMatchName(string name)
{
Query.Where(h => h.Name == name);
}
}
public async Task<Response<Guid>> CreateSupplierAsync(CreateSupplierRequest request)
{
SupplierMatchName specification = new(request.Name); // ardalis specification
bool supplierExists = await _repository.ExistsAsync<Supplier, Guid>(specification);
if (supplierExists)
{
return Response<Guid>.Fail("Supplier already exists");
}
Supplier newSupplier = _mapper.Map(request, new Supplier()); // map dto to domain entity
try
{
Supplier response = await _repository.CreateAsync<Supplier, Guid>(newSupplier); // create new entity
_ = await _repository.SaveChangesAsync(); // save changes to db
return Response<Guid>.Success(response.Id); // return id
}
catch (Exception ex)
{
return Response<Guid>.Fail(ex.Message);
}
}
Update Product DTOs
Products now require a supplier ID. Navigate to Services/ProductService/DTOs and add a GUID SupplierId property for the Update and Create product requests.
public class CreateProductRequest : IDto
{
public string Name { get; set; }
public string Description { get; set; }
public Guid SupplierId { get; set; }
}
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
public CreateProductValidator()
{
_ = RuleFor(x => x.Name).NotEmpty();
_ = RuleFor(x => x.Description).NotEmpty();
_ = RuleFor(x => x.SupplierId).NotEmpty();
}
}
On the ProductDTO, add a GUID SupplierId and a SupplierName string property.
public class ProductDTO : IDto
{
public Guid Id { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public DateTime CreatedOn { get; set; }
public string SupplierName { get; set; }
public Guid SupplierId { get; set; }
}
Mapping Configuration
Now that we’ve added all the new DTOs for Supplier, we need to ensure that Automapper knows how to map them correctly.
Open up MappingProfiles in Infrastructure/Mapper/ and add the following configurations from Supplier to the SupplierDTO, and from CreateSupplierRequest and UpdateSupplierRequest to Supplier
// product mappings
_ = CreateMap<Product, ProductDTO>().ForMember(x => x.SupplierName, o => o.MapFrom(s => s.Supplier.Name));
_ = CreateMap<CreateProductRequest, Product>();
_ = CreateMap<UpdateProductRequest, Product>();
// supplier mappings...
_ = CreateMap<Supplier, SupplierDTO>();
_ = CreateMap<CreateSupplierRequest, Supplier>();
_ = CreateMap<UpdateSupplierRequest, Supplier>();
We can also configure Automapper to populate the SupplierName field with the Name field whenever mapping is performed on products.
Supplier Controller
Create a new API controller in WebApi/Controllers called SuppliersController. Copy all of the code from the Products controller. Perform a find and replace on Product for Supplier with preserve case.
Testing with Postman
Now we can test with Postman. Create a new folder for Suppliers with a Post request called create-supplier. Create a few suppliers; remember to first obtain a token.
Create a new Get request called get-suppliers-full-list and retrieve the list of suppliers. Copy one of the IDs so that we can create a product in the next step.
In the Products folder, modify the create-product request body to have a SupplierId field. Create a new product for one of the suppliers
Now when you retrieve a list of suppliers, an array of products will be returned.
Returning a list of products works as well, with Automapper taking care of the supplier name field for us
That concludes the walkthrough, hopefully now you have a better understanding of how to build API endpoints with the Nano ASP.NET boilerplate!