Published:

.NET 5.0 - CRUD API Example and Tutorial

Tutorial built with .NET 5.0

Other versions available:

In this tutorial we'll show how to build a .NET 5.0 API that supports CRUD operations. The example API includes routes to retrieve, update, create and delete records in the database, the records in the example app are for users but this is only for demonstration purposes, the same CRUD pattern and code structure could be used to manage any type of data e.g. products, services, articles etc.

EF Core InMemory database used for testing

To keep the API code as simple as possible, it is configured to use the EF Core InMemory database provider which allows Entity Framework Core to create and connect to an in-memory database rather than you having to install a real db server. This can be easily switched out to a real db provider when you're ready to work with a database such as SQL Server, Oracle, MySQL etc. For instructions on how to connect it to a SQL Server database see .NET 5.0 - Connect to SQL Server with Entity Framework Core, for MySQL see .NET 5.0 - Connect to MySQL Database with Entity Framework Core.

Code on GitHub

The tutorial project is available on GitHub at https://github.com/cornflourblue/dotnet-5-crud-api.


.NET 5.0 CRUD Tutorial Contents


Tools required to run the .NET 5.0 Tutorial API Locally

To develop and run .NET 5.0 applications locally, download and install the following:

  • .NET SDK - includes the .NET runtime and command line tools
  • Visual Studio Code - code editor that runs on Windows, Mac and Linux
  • C# extension for Visual Studio Code - adds support to VS Code for developing .NET applications


Run the .NET CRUD Example API Locally

  1. Download or clone the tutorial project code from https://github.com/cornflourblue/dotnet-5-crud-api
  2. Start the api by running dotnet run from the command line in the project root folder (where the WebApi.csproj file is located), you should see the message Now listening on: http://localhost:4000.
  3. You can test the API directly with a tool such as Postman or hook it up with the example Angular or React application below.

Starting in debug mode

You can also start the application in debug mode in VS Code by opening the project root folder in VS Code and pressing F5 or by selecting Debug -> Start Debugging from the top menu, running in debug mode allows you to attach breakpoints to pause execution and step through the application code. For detailed instructions including a short demo video see VS Code + .NET - Debug a .NET Web App in Visual Studio Code.


Run an Angular App with the .NET CRUD API

For full details about the Angular CRUD app see the post Angular 11 - CRUD Example with Reactive Forms. But to get up and running quickly just follow the below steps.

  1. Install Node.js and npm from https://nodejs.org.
  2. Download or clone the Angular tutorial code from https://github.com/cornflourblue/angular-11-crud-example
  3. Install all required npm packages by running npm install from the command line in the project root folder (where the package.json is located).
  4. Remove or comment out the line below the comment // provider used to create fake backend located in the /src/app/app.module.ts file.
  5. Start the application by running npm start from the command line in the project root folder, this will launch a browser displaying the application and it should be hooked up with the .NET CRUD API that you already have running.


Run a React App with the .NET CRUD API

For full details about the React CRUD app see the post React - CRUD Example with React Hook Form. But to get up and running quickly just follow the below steps.

  1. Install Node.js and npm from https://nodejs.org.
  2. Download or clone the React tutorial code from https://github.com/cornflourblue/react-hook-form-crud-example
  3. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  4. Remove or comment out the 2 lines below the comment // setup fake backend located in the /src/index.jsx file.
  5. Start the application by running npm start from the command line in the project root folder, this will launch a browser displaying the application and it should be hooked up with the .NET 5.0 CRUD API that you already have running.


.NET CRUD Example Project Structure

The .NET CRUD project is organised into the following folders:

Controllers
Define the end points / routes for the web api, controllers are the entry point into the web api from client applications via http requests.

Models
Represent request and response models for controller methods, request models define parameters for incoming requests and response models define custom data returned in responses when required. The example only contains request models because it doesn't contain any routes that require custom response models, entities are returned directly by the user GET routes.

Services
Contain business logic, validation and database access code.

Entities
Represent the application data that is stored in the database.
Entity Framework Core (EF Core) maps relational data from the database to instances of C# entity objects to be used within the application for data management and CRUD operations.

Helpers
Anything that doesn't fit into the above folders.

Click any of the below links to jump down to a description of each file along with its code:

 

Users Controller

Path: /Controllers/UsersController.cs

The .NET users controller defines and handles all routes / endpoints for the api that relate to users, this includes standard CRUD operations for retrieving, updating, creating and deleting users. Within each route the controller calls the user service to perform the action required, this enables the controller to stay 'lean' and completely separate from the business logic and data access code.

using AutoMapper;
using Microsoft.AspNetCore.Mvc;
using WebApi.Models.Users;
using WebApi.Services;

namespace WebApi.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class UsersController : ControllerBase
    {
        private IUserService _userService;
        private IMapper _mapper;

        public UsersController(
            IUserService userService,
            IMapper mapper)
        {
            _userService = userService;
            _mapper = mapper;
        }

        [HttpGet]
        public IActionResult GetAll()
        {
            var users = _userService.GetAll();
            return Ok(users);
        }

        [HttpGet("{id}")]
        public IActionResult GetById(int id)
        {
            var user = _userService.GetById(id);
            return Ok(user);
        }

        [HttpPost]
        public IActionResult Create(CreateRequest model)
        {
            _userService.Create(model);
            return Ok(new { message = "User created" });
        }

        [HttpPut("{id}")]
        public IActionResult Update(int id, UpdateRequest model)
        {
            _userService.Update(id, model);
            return Ok(new { message = "User updated" });
        }

        [HttpDelete("{id}")]
        public IActionResult Delete(int id)
        {
            _userService.Delete(id);
            return Ok(new { message = "User deleted" });
        }
    }
}
 

Role Enum

Path: /Entities/Role.cs

The role enum defines all the available roles in the example api. I created it to avoid passing roles around as strings, so instead of 'Admin' we can use Role.Admin.

namespace WebApi.Entities
{
    public enum Role
    {
        Admin,
        User
    }
}
 

User Entity

Path: /Entities/User.cs

The user entity class represents the data stored in the database for users.

Entity classes are also used to pass data between different parts of the application (e.g. between services and controllers) and can be used to return http response data from controller action methods.

The [JsonIgnore] attribute prevents the PasswordHash property from being serialized and returned in API responses.

When returned in API responses, the Role enum property is serialized into a string (instead of the default number) by the JsonStringEnumConverter() configured in the Startup class.

using System.Text.Json.Serialization;

namespace WebApi.Entities
{
    public class User
    {
        public int Id { get; set; }
        public string Title { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public string Email { get; set; }
        public Role Role { get; set; }

        [JsonIgnore]
        public string PasswordHash { get; set; }
    }
}
 

App Exception

Path: /Helpers/AppException.cs

The app exception is a custom exception class used to differentiate between handled and unhandled exceptions in the .NET API. Handled exceptions are generated by application code and used to return friendly error messages, for example business logic or validation exceptions caused by invalid request parameters, whereas unhandled exceptions are generated by the .NET framework or caused by bugs in application code.

using System;
using System.Globalization;

namespace WebApi.Helpers
{
    // custom exception class for throwing application specific exceptions (e.g. for validation) 
    // that can be caught and handled within the application
    public class AppException : Exception
    {
        public AppException() : base() {}

        public AppException(string message) : base(message) { }

        public AppException(string message, params object[] args) 
            : base(String.Format(CultureInfo.CurrentCulture, message, args))
        {
        }
    }
}
 

AutoMapper Profile

Path: /Helpers/AutoMapperProfile.cs

The automapper profile contains the mapping configuration used by the application, AutoMapper is a package available on Nuget that enables automatic mapping between different C# types. In this example we're using it to map between User entities and a couple of different model types - CreateRequest and UpdateRequest.

using AutoMapper;
using WebApi.Entities;
using WebApi.Models.Users;

namespace WebApi.Helpers
{
    public class AutoMapperProfile : Profile
    {
        public AutoMapperProfile()
        {
            // CreateRequest -> User
            CreateMap<CreateRequest, User>();

            // UpdateRequest -> User
            CreateMap<UpdateRequest, User>()
                .ForAllMembers(x => x.Condition(
                    (src, dest, prop) =>
                    {
                        // ignore both null & empty string properties
                        if (prop == null) return false;
                        if (prop.GetType() == typeof(string) && string.IsNullOrEmpty((string)prop)) return false;

                        // ignore null role
                        if (x.DestinationMember.Name == "Role" && src.Role == null) return false;

                        return true;
                    }
                ));
        }
    }
}
 

Data Context

Path: /Helpers/DataContext.cs

The data context class is used for accessing application data through Entity Framework. It derives from the Entity Framework DbContext class and has a public Users property for accessing and managing user data. The data context is used by the user service for handling all low level data (CRUD) operations.

options.UseInMemoryDatabase() configures Entity Framework to create and connect to an in-memory database so the API can be tested without a real database, this can be easily updated to connect to a real db server such as SQL Server, Oracle, MySQL etc. For instructions on how to connect it to a SQL Server database see .NET 5.0 - Connect to SQL Server with Entity Framework Core, for MySQL see .NET 5.0 - Connect to MySQL Database with Entity Framework Core.

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using WebApi.Entities;

namespace WebApi.Helpers
{
    public class DataContext : DbContext
    {
        protected readonly IConfiguration Configuration;

        public DataContext(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        protected override void OnConfiguring(DbContextOptionsBuilder options)
        {
            // in memory database used for simplicity, change to a real db for production applications
            options.UseInMemoryDatabase("TestDb");
        }

        public DbSet<User> Users { get; set; }
    }
}
 

Global Error Handler Middleware

Path: /Helpers/ErrorHandlerMiddleware.cs

The global error handler is used catch all errors and remove the need for duplicated error handling code throughout the .NET api. It's configured as middleware in the Configure() method of the project startup class.

Errors of type AppException are treated as custom (app specific) errors that return a 400 Bad Request response, the .NET built-in KeyNotFoundException class is used to return 404 Not Found responses, all other exceptions are unhandled and return a 500 Internal Server Error response as well as being logged to the console.

See the user service for examples of custom errors and not found errors thrown by the api.

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;

namespace WebApi.Helpers
{
    public class ErrorHandlerMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly ILogger _logger;

        public ErrorHandlerMiddleware(RequestDelegate next, ILogger<ErrorHandlerMiddleware> logger)
        {
            _next = next;
            _logger = logger;
        }

        public async Task Invoke(HttpContext context)
        {
            try
            {
                await _next(context);
            }
            catch (Exception error)
            {
                var response = context.Response;
                response.ContentType = "application/json";

                switch (error)
                {
                    case AppException e:
                        // custom application error
                        response.StatusCode = (int)HttpStatusCode.BadRequest;
                        break;
                    case KeyNotFoundException e:
                        // not found error
                        response.StatusCode = (int)HttpStatusCode.NotFound;
                        break;
                    default:
                        // unhandled error
                        _logger.LogError(error, error.Message);
                        response.StatusCode = (int)HttpStatusCode.InternalServerError;
                        break;
                }

                var result = JsonSerializer.Serialize(new { message = error?.Message });
                await response.WriteAsync(result);
            }
        }
    }
}
 

Create Request Model

Path: /Models/Users/CreateRequest.cs

The create request model defines the parameters for incoming POST requests to the /users route, it is attached to the route by setting it as the parameter to the Create action method of the users controller. When an HTTP POST request is received by the route, the data from the body is bound to an instance of the CreateRequest class, validated and passed to the method.

.NET Data Annotations are used to automatically handle model validation, [Required] makes all properties required, [EmailAddress] validates that the email property contains a valid email address, [EnumDataType(typeof(Role))] validates that the role property matches one of the api roles (Admin or User), [MinLength(6)] validates that the password contains at least six characters, and [Compare("Password")] validates that the confirm password property matches the password property.

using System.ComponentModel.DataAnnotations;
using WebApi.Entities;

namespace WebApi.Models.Users
{
    public class CreateRequest
    {
        [Required]
        public string Title { get; set; }

        [Required]
        public string FirstName { get; set; }

        [Required]
        public string LastName { get; set; }

        [Required]
        [EnumDataType(typeof(Role))]
        public string Role { get; set; }

        [Required]
        [EmailAddress]
        public string Email { get; set; }

        [Required]
        [MinLength(6)]
        public string Password { get; set; }

        [Required]
        [Compare("Password")]
        public string ConfirmPassword { get; set; }
    }
}
 

Update Request Model

Path: /Models/Users/UpdateRequest.cs

The update request model defines the parameters for incoming PUT requests to the /users/{id} route, it is attached to the route by setting it as the parameter to the Update action method of the users controller. When an HTTP PUT request is received by the route, the data from the body is bound to an instance of the UpdateRequest class, validated and passed to the method.

.NET Data Annotations are used to automatically handle model validation, [EnumDataType(typeof(Role))] validates that the role property matches one of the api roles (Admin or User), [EmailAddress] validates that the email property contains a valid email address, [MinLength(6)] validates that the password contains at least six characters, and [Compare("Password")] validates that the confirm password property matches the password property.

None of the properties have the [Required] attribute making them all optional, and any omitted fields are not updated in the database.

Some validation attributes don't handle empty strings well, the password properties replace empty strings with null on set to ensure that empty strings are ignored, because password fields are optional on the update user form in the example Angular and React client apps.

using System.ComponentModel.DataAnnotations;
using WebApi.Entities;

namespace WebApi.Models.Users
{
    public class UpdateRequest
    {
        public string Title { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }

        [EnumDataType(typeof(Role))]
        public string Role { get; set; }

        [EmailAddress]
        public string Email { get; set; }

        // treat empty string as null for password fields to 
        // make them optional in front end apps
        private string _password;
        [MinLength(6)]
        public string Password
        {
            get => _password;
            set => _password = replaceEmptyWithNull(value);
        }

        private string _confirmPassword;
        [Compare("Password")]
        public string ConfirmPassword 
        {
            get => _confirmPassword;
            set => _confirmPassword = replaceEmptyWithNull(value);
        }

        // helpers

        private string replaceEmptyWithNull(string value)
        {
            // replace empty string with null to make field optional
            return string.IsNullOrEmpty(value) ? null : value;
        }
    }
}
 

User Service

Path: /Services/UserService.cs

The user service is responsible for all database interaction and core business logic related to user CRUD operations.

The top of the file contains an interface that defines the user service, just below that is the concrete user service class that implements the interface. BCrypt is used to hash and verify passwords, for more info see .NET 5.0 - Hash and Verify Passwords with BCrypt.

using AutoMapper;
using BCryptNet = BCrypt.Net.BCrypt;
using System.Collections.Generic;
using System.Linq;
using WebApi.Entities;
using WebApi.Helpers;
using WebApi.Models.Users;

namespace WebApi.Services
{
    public interface IUserService
    {
        IEnumerable<User> GetAll();
        User GetById(int id);
        void Create(CreateRequest model);
        void Update(int id, UpdateRequest model);
        void Delete(int id);
    }

    public class UserService : IUserService
    {
        private DataContext _context;
        private readonly IMapper _mapper;

        public UserService(
            DataContext context,
            IMapper mapper)
        {
            _context = context;
            _mapper = mapper;
        }

        public IEnumerable<User> GetAll()
        {
            return _context.Users;
        }

        public User GetById(int id)
        {
            return getUser(id);
        }

        public void Create(CreateRequest model)
        {
            // validate
            if (_context.Users.Any(x => x.Email == model.Email))
                throw new AppException("User with the email '" + model.Email + "' already exists");

            // map model to new user object
            var user = _mapper.Map<User>(model);

            // hash password
            user.PasswordHash = BCryptNet.HashPassword(model.Password);

            // save user
            _context.Users.Add(user);
            _context.SaveChanges();
        }

        public void Update(int id, UpdateRequest model)
        {
            var user = getUser(id);

            // validate
            if (model.Email != user.Email && _context.Users.Any(x => x.Email == model.Email))
                throw new AppException("User with the email '" + model.Email + "' already exists");

            // hash password if it was entered
            if (!string.IsNullOrEmpty(model.Password))
                user.PasswordHash = BCryptNet.HashPassword(model.Password);

            // copy model to user and save
            _mapper.Map(model, user);
            _context.Users.Update(user);
            _context.SaveChanges();
        }

        public void Delete(int id)
        {
            var user = getUser(id);
            _context.Users.Remove(user);
            _context.SaveChanges();
        }

        // helper methods

        private User getUser(int id)
        {
            var user = _context.Users.Find(id);
            if (user == null) throw new KeyNotFoundException("User not found");
            return user;
        }
    }
}
 

.NET App Settings JSON

Path: /appsettings.json

The appsettings.json file is the base configuration file in a .NET app that contains settings for all environments (e.g. Development, Production). You can override values for different environments by creating environment specific appsettings files (e.g. appsettings.Development.json, appsettings.Production.json).

{
    "Logging": {
        "LogLevel": {
            "Microsoft": "Warning",
            "Microsoft.Hosting.Lifetime": "Information"
        }
    }
}
 

OmniSharp Config

Path: /omnisharp.json

This file contains configuration options for the C# extension in VS Code. The useBundledOnly option tells the C# extension to use the bundled version of MSBuild instead of the global version to prevent errors if you have an older version of MSBuild installed globally (e.g. as part of Visual Studio).

{
    "msbuild": {
        "useBundledOnly": true
    }
}
 

.NET Program Class with Main Method

Path: /Program.cs

The Main() method is the entry point for a .NET application, when an app is started it searches for the Main() method to begin execution. The method can be located anywhere in a project but is typically placed in the Program class.

A .NET web app is run within a host which handles app startup, lifetime management, web server configuration and more. A host is created and launched by calling Build().Run() on a host builder (an instance of the IHostBuilder interface). A generic host builder with pre-configured defaults is created with the CreateDefaultBuilder() convenience method provided by the static Host class (Microsoft.Extensions.Hosting.Host).

The ConfigureWebHostDefaults() extension method configures the host builder for hosting a web app including setting Kestrel as the web server, adding host filtering middleware and enabling IIS integration. For more info on the default host builder settings see https://docs.microsoft.com/aspnet/core/fundamentals/host/generic-host#default-builder-settings.

The x.UseStartup<Startup>() method specifies which startup class to use when building a host for the web app.

using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;

namespace WebApi
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args)
        {
            return Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(x => 
                {
                    x.UseStartup<Startup>();
                    x.UseUrls("http://localhost:4000");
                });
        }
    }
}
 

.NET Startup Class

Path: /Startup.cs

The Startup class configures the services available to the .NET Dependency Injection (DI) container in the ConfigureServices() method, and configures the .NET request pipeline for the application in the Configure() method. Both methods are called by the .NET runtime when the app starts, first ConfigureServices() followed by Configure().

The .NET host passes an IApplicationBuilder to the Configure() method, all DI services are also available to Configure() and can be added as parameters to the method (e.g. public void Configure(IApplicationBuilder app, DataContext context) { ... }). For more info on the startup class and both configure methods see https://docs.microsoft.com/aspnet/core/fundamentals/startup.

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Text.Json.Serialization;
using WebApi.Helpers;
using WebApi.Services;

namespace WebApi
{
    public class Startup
    {
        // add services to the DI container
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<DataContext>();
            services.AddCors();
            services.AddControllers().AddJsonOptions(x =>
            {
                // serialize enums as strings in api responses (e.g. Role)
                x.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());

                // ignore omitted parameters on models to enable optional params (e.g. User update)
                x.JsonSerializerOptions.IgnoreNullValues = true;
            });
            services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());

            // configure DI for application services
            services.AddScoped<IUserService, UserService>();
        }

        // configure the HTTP request pipeline
        public void Configure(IApplicationBuilder app)
        {
            app.UseRouting();

            // global cors policy
            app.UseCors(x => x
                .AllowAnyOrigin()
                .AllowAnyMethod()
                .AllowAnyHeader());

            // global error handler
            app.UseMiddleware<ErrorHandlerMiddleware>();

            app.UseEndpoints(x => x.MapControllers());
        }
    }
}
 

.NET MSBuild C# Project File (.csproj)

Path: /WebApi.csproj

The csproj (C# project) is an MSBuild based file that contains target framework and NuGet package dependency information for the application.

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
        <TargetFramework>net5.0</TargetFramework>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="AutoMapper" Version="10.1.1" />
        <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
        <PackageReference Include="BCrypt.Net-Next" Version="4.0.2" />
        <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="5.0.6" />
    </ItemGroup>
</Project>

 


Subscribe or Follow Me For Updates

Subscribe to my YouTube channel or follow me on Twitter, Facebook or GitHub to be notified when I post new content.

Other than coding...

I'm currently attempting to travel around Australia by motorcycle with my wife Tina on a pair of Royal Enfield Himalayans. You can follow our adventures on YouTube, Instagram and Facebook.


Need Some .NET Help?

Search fiverr to find help quickly from experienced .NET developers.



Supported by