Introduction

When we write a project, our main goal is to make it run as scheduled and meet all user needs as much as possible.

But don’t you think that creating a working project is not enough? Shouldn’t this project also be maintainable and readable?

It turns out that we need to focus more on the readability and maintainability of our projects. The main reason behind this is that we may not be the only authors of this project. Once we are done, it is very likely that others will join in.

So where should we focus our attention?

In this guide, about developing .NET Core Web API projects, we will describe some of the ways we think will be best practices. This makes our project better and more maintainable.

Now let’s start thinking about some best practices that can be applied to ASP.NET Web API projects.

Startup class and service configuration

STARTUP CLASS AND THE SERVICE CONFIGURATION

In Startupclass, there are two methods: ConfigureServicesfor service registration, Configureis added to the request pipeline middleware application.

Therefore, the best way is to keep the ConfigureServicesmethod is simple and readable as possible. Of course, we need to write the code inside the method to register the service, but we can use 扩展方法to make our code more readable and maintainable manner.

For example, let’s look at a bad way to register for a CORS service:

Copypublic void ConfigureServices(IServiceCollection services)
{
    services.AddCors(options => 
    {
        options.AddPolicy("CorsPolicy", builder => builder.AllowAnyOrigin()
            .AllowAnyMethod()
            .AllowAnyHeader()
            .AllowCredentials());
    });
}

Although this approach looks good, the CORS service can be registered successfully. But imagine the length of this method body after registering a dozen services.

This is not readable at all.

A good way is by creating static methods in the extension class:

Copypublic static class ServiceExtensions
{
    public static void ConfigureCors(this IServiceCollection services)
    {
        services.AddCors(options =>
        {
            options.AddPolicy("CorsPolicy", builder => builder.AllowAnyOrigin()
                .AllowAnyMethod()
                .AllowAnyHeader()
                .AllowCredentials());
        });
    }
}

Then, just call this extension method:

Copypublic void ConfigureServices(IServiceCollection services)
{
    services.ConfigureCors();
}

To learn more about .NET Core project configuration, please see:
.NET Core Project Configuration

Project organization

PROJECT ORGANIZATION

We should try to split our application into multiple small projects. In this way, we can get the best project organization and separation of concerns (SoC). The business logic for our entities, contracts, accessing database operations, logging information, or sending emails should always be placed in separate .NET Core class library projects.

Each small project in the application should contain multiple folders to organize business logic.

Here is a simple example to show how a complex project should be organized:


ASP.NET_Core_Web_API_Best_Practice_Guide_1.png

 

Environment-based settings

ENVIRONMENT BASED SETTINGS

When we develop an application, it is in a development environment. But once we release it, it will be in production. Therefore, it is often a good practice to configure each environment in isolation.

In .NET Core, this is easy to achieve.

Once we create a good project, there is already a appsettings.jsonfile, expand it when we will see the appsettings.Development.jsonfile:

 

 

All settings in this file will be used in the development environment.

We should add another file appsettings.Production.jsonand use it for production:

 

 

The production files will be located below the development files.

After the settings are modified, we can load different configurations through different appsettings files. Depending on the current environment of our application, .NET Core will provide us with the correct settings. For more on this topic, check out:
Multiple Environments in ASP.NET Core.

Data Access Layer

DATA ACCESS LAYER

In some different example tutorials, we may see that the implementation of DAL is in the main project and there are instances in each controller. We do not recommend this.

When we write a DAL, we should create it as a standalone service. This is important in .NET Core projects because when we treat DAL as a standalone service, we can inject it directly into an IOC (Reverse Control) container. IOC is a built-in feature of .NET Core. In this way, we can use constructor injection in any controller.

Copypublic class OwnerController: Controller
{
    private readonly IRepository _repository;
    public OwnerController(IRepository repository)
    {
        _repository = repository;
    }
}

Controller

CONTROLLERS

The controller should always be as tidy as possible. We should not put any business logic inside.

Therefore, our controller should receive service instances through constructor injection, and organize HTTP operation methods (GET, POST, PUT, DELETE, PATCH …):

Copypublic class OwnerController : Controller
{
    private readonly ILoggerManager _logger;
    private readonly IRepository _repository;
    public OwnerController(ILoggerManager logger, IRepository repository)
    {
        _logger = logger;
        _repository = repository;
    }

    [HttpGet]
    public IActionResult GetAllOwners()
    {
    }
    [HttpGet("{id}", Name = "OwnerById")]
    public IActionResult GetOwnerById(Guid id)
    {
    }
    [HttpGet("{id}/account")]
    public IActionResult GetOwnerWithDetails(Guid id)
    {
    }
    [HttpPost]
    public IActionResult CreateOwner([FromBody]Owner owner)
    {
    }
    [HttpPut("{id}")]
    public IActionResult UpdateOwner(Guid id, [FromBody]Owner owner)
    {
    }
    [HttpDelete("{id}")]
    public IActionResult DeleteOwner(Guid id)
    {
    }
}

Our actions should be as concise as possible, and their responsibilities should include handling HTTP requests, validating models, catching exceptions, and returning responses.

Copy[HttpPost]
public IActionResult CreateOwner([FromBody]Owner owner)
{
    try
    {
        if (owner.IsObjectNull())
        {
            return BadRequest("Owner object is null");
        }
        if (!ModelState.IsValid)
        {
            return BadRequest("Invalid model object");
        }
        _repository.Owner.CreateOwner(owner);
        return CreatedAtRoute("OwnerById", new { id = owner.Id }, owner);
    }
    catch (Exception ex)
    {
        _logger.LogError($"Something went wrong inside the CreateOwner action: { ex} ");
        return StatusCode(500, "Internal server error");
    }
}

In most cases, our action should be IActonResultused as the return type (sometimes we want to return to a particular type or JsonResult…). By using this method, we can make good use of the return value and status code of the built-in methods in .NET Core.

The most used methods are:

  • OK => returns the 200 status code
  • NotFound => returns the 404 status code
  • BadRequest => returns the 400 status code
  • NoContent => returns the 204 status code
  • Created, CreatedAtRoute, CreatedAtAction => returns the 201 status code
  • Unauthorized => returns the 401 status code
  • Forbid => returns the 403 status code
  • StatusCode => returns the status code we provide as input

Handling global exceptions

HANDLING ERRORS GLOBALLY

In the example above, we have action inside a try-catchblock of code. This is important, we need to handle all exceptions (including unhandled) in our action method body. Some developers used in the action try-catchblock, clearly this way without any problems. But we want the action to be as concise as possible. Therefore, try-catchit would be better to remove it from our action and put it in a centralized place. .NET Core gives us a way to handle global exceptions, with only a few modifications, you can use built-in and complete middleware. We need to do is modify the Startupmodified class Configuremethod:

Copypublic void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseExceptionHandler(config => 
    {
        config.Run(async context => 
        {
            context.Response.StatusCode = 500;
            context.Response.ContentType = "application/json";

            var error = context.Features.Get<IExceptionHandlerFeature>();
            if (error != null)
            {
                var ex = error.Error;
                await context.Response.WriteAsync(new ErrorModel
                {
                    StatusCode = 500,
                    ErrorMessage = ex.Message
                }.ToString());
            }
        });
    });

    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

We can also implement our custom exception handling by creating custom middleware:

Copy// You may need to install the Microsoft.AspNetCore.Http.Abstractions package into your project
public class CustomExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<CustomExceptionMiddleware> _logger;
    public CustomExceptionMiddleware(RequestDelegate next, ILogger<CustomExceptionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task Invoke(HttpContext httpContext)
    {
        try
        {
            await _next(httpContext);
        }
        catch (Exception ex)
        {
            _logger.LogError("Unhandled exception....", ex);
            await HandleExceptionAsync(httpContext, ex);
        }
    }

    private Task HandleExceptionAsync(HttpContext httpContext, Exception ex)
    {
        //todo
        return Task.CompletedTask;
    }
}

// Extension method used to add the middleware to the HTTP request pipeline.
public static class CustomExceptionMiddlewareExtensions
{
    public static IApplicationBuilder UseCustomExceptionMiddleware(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<CustomExceptionMiddleware>();
    }
}

After that, we just need to inject it into the application’s request pipeline:

Copypublic void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseCustomExceptionMiddleware();
}

Remove duplicate code with filters

USING ACTIONFILTERS TO REMOVE DUPLICATED CODE

ASP.NET Core filters let us run some code before or after a specific state of the request pipeline. So if there is duplicate verification in our action, we can use it to simplify the verification operation.

When we process a PUT or POST request in the action method, we need to verify that our model object meets our expectations. As a result, this will lead to duplication of our validation code, and we want to avoid this, (basically, we should do our best to avoid any duplication of code.) We can use ActionFilter in our code instead of our validation Code:

Copyif (!ModelState.IsValid)
{
    //bad request and logging logic
}

We can create a filter:

Copypublic class ModelValidationAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            context.Result = new BadRequestObjectResult(context.ModelState);
        }
    }
}

Then Startupthe class ConfigureServicesin the function of its injection:

Copyservices.AddScoped<ModelValidationAttribute>();

Now we can apply the above-injected filter to our action.

Microsoft.AspNetCore.All Meta Package

MICROSOFT.ASPNETCORE.ALL META-PACKAGE

Note: If you are using ASP.NET Core 2.1 and later. The Microsoft.AspNetCore.App package is recommended instead of Microsoft.AspNetCore.All. It’s all for security reasons. In addition, if we create a new WebAPI project using version 2.1, we will automatically get the AspNetCore.App package instead of AspNetCore.All.

This meta package contains all the AspNetCore related packages, the EntityFrameworkCore package, the SignalR package (version 2.1) and the support packages that depend on the framework to run. Creating a new project this way is convenient because we don’t need to manually install some packages that we might use.

Of course, in order to use the Microsoft.AspNetCore.all meta package, you need to make sure that your machine has the .NET Core Runtime installed.

Routing

ROUTING

In .NET Core Web API projects, we should use attribute routing instead of traditional routing, because attribute routing can help us match the route parameter name with the actual parameter method in the Action. Another reason is the description of the routing parameters. For us, a parameter named “ownerId” is more readable than “id”.

We can use the [Route] attribute to label at the top of the controller:

Copy[Route("api/[controller]")]
public class OwnerController : Controller
{
    [Route("{id}")]
    [HttpGet]
    public IActionResult GetOwnerById(Guid id)
    {
    }
}

There is another way to create routing rules for controllers and actions:

Copy[Route("api/owner")]
public class OwnerController : Controller
{
    [Route("{id}")]
    [HttpGet]
    public IActionResult GetOwnerById(Guid id)
    {
    }
}

There is disagreement over which of these two approaches is better, but we often recommend the second approach. This is the approach we have been using in our projects.

When we talk about routing, we need to mention the naming rules for routes. We can use descriptive names for our operations, but for routes / nodes we should use NOUNS instead of VERBS.

A poor example:

Copy[Route("api/owner")]
public class OwnerController : Controller
{
    [HttpGet("getAllOwners")]
    public IActionResult GetAllOwners()
    {
    }
    [HttpGet("getOwnerById/{id}"]
    public IActionResult GetOwnerById(Guid id)
    {
    }
}

A better example:

Copy[Route("api/owner")]
public class OwnerController : Controller
{
    [HttpGet]
    public IActionResult GetAllOwners()
    {
    }
    [HttpGet("{id}"]
    public IActionResult GetOwnerById(Guid id)
    {
    }
}

For a more detailed explanation of Restful practices, please see:
Top REST API Best Practices

Log

LOGGING

If we intend to publish our application to a production environment, we should add a logging mechanism where appropriate. Logging in a production environment is very helpful for us to sort out the operation of the application.

.NET Core through inheritance ILoggerinterface of its own logging. It can be easily used with the help of dependency injection.

Copypublic class TestController: Controller
{
    private readonly ILogger _logger;
    public TestController(ILogger<TestController> logger)
    {
        _logger = logger;
    }
}

Then, in our action, we can use the _logger object to record logs with different log levels.

.NET Core supports providers for various logging. Therefore, we may use different providers in our project to implement our logging logic.

NLog is a very good library that can be used for our custom log logic. It is extremely extensible. Supports structured logging and is easy to configure. We can log information to the console, files or even databases.

To learn more about the application of this library in .NET Core, please refer to:
.NET Core series-Logging With NLog.

Serilog is also a very good class library for the logging system built into .NET Core.

Encryption

CRYPTOHELPER

We do not recommend storing passwords in the database in clear text. For security reasons, we need to hash it. This is beyond the scope of this guide. There are a lot of hashing algorithms on the Internet, and there are some good ways to hash passwords.

But if you need to provide easy-to-use cryptographic libraries for .NET Core applications, CryptoHelper is a good choice.

CryptoHelper is a standalone password hashing library for .NET Core, which is implemented based on PBKDF2. By creating Data Protectionto the hashed password stack. This class library is available on NuGet and it is easy to use:

Copyusing CryptoHelper;

// Hash a password
public string HashPassword(string password)
{
    return Crypto.HashPassword(password);
}

// Verify the password hash against the given password
public bool VerifyPassword(string hash, string password)
{
    return Crypto.VerifyHashedPassword(hash, password);
}

Content negotiation

CONTENT NEGOTIATION

By default, the .NET Core Web API returns results in JSON format. In most cases, this is what we want.

But what if the client wants our Web API to return other response formats, such as XML?

To solve this problem, we need to perform server-side configuration to format our response results on demand:

Copypublic void ConfigureServices(IServiceCollection services)
{
    services.AddControllers().AddXmlSerializerFormatters();
}

But sometimes the client will request a format that our Web API does not support, so the best practice is to uniformly return a 406 status code for unprocessed request formats. This method can also be easily configured in the ConfigureServices method:

Copypublic void ConfigureServices(IServiceCollection services)
{
    services.AddControllers(options => options.ReturnHttpNotAcceptable = true).AddXmlSerializerFormatters();
}

We can also create our own formatting rules.

This part is a big topic, if you want to learn more, please check:
Content Negotiation in .NET Core

Using JWT

USING JWT

In today’s web development, JSON Web Tokens (JWT) are becoming more and more popular. Thanks to .NET Core’s built-in support for JWT, it’s very easy to implement. JWT is a development standard that allows us to perform secure data transmission in JSON format on the server and client.

We can configure JWT authentication in ConfigureServices:

Copypublic void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options => 
        {
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuer = true,
                ValidIssuer = _authToken.Issuer,

                ValidateAudience = true,
                ValidAudience = _authToken.Audience,

                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_authToken.Key)),

                RequireExpirationTime = true,
                ValidateLifetime = true,

                //others
            };
        });
}

In order to use it in our application, we also need to call the following piece of code in Configure:

Copypublic void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseAuthentication();
}

In addition, you can use the following methods to create a token:

Copyvar securityToken = new JwtSecurityToken(
                claims: new Claim[]
                {
                    new Claim(ClaimTypes.NameIdentifier,user.Id),
                    new Claim(ClaimTypes.Email,user.Email)
                },
                issuer: _authToken.Issuer,
                audience: _authToken.Audience,
                notBefore: DateTime.Now,
                expires: DateTime.Now.AddDays(_authToken.Expires),
                signingCredentials: new SigningCredentials(
                    new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_authToken.Key)),
                    SecurityAlgorithms.HmacSha256Signature));

Token = new JwtSecurityTokenHandler().WriteToken(securityToken)

Token-based user authentication can be used in the controller as follows:

Copyvar auth = await HttpContext.AuthenticateAsync();
var id = auth.Principal.Claims.FirstOrDefault(x => x.Type.Equals(ClaimTypes.NameIdentifier))?.Value;

We can also use JWT for the authorization part, just add a role declaration to the JWT configuration.

For more information about JWT authentication and authorization in .NET Core, please see:
authentication-aspnetcore-jwt-1

and
authentication-aspnetcore-jwt-2

Summary

After reading this, some friends may not agree with some of the above best practices, because the whole article does not talk about more practical guidelines for the project, such as TDD , DDD, etc. But I personally think that all the above best practices are the foundation, and only by mastering these foundations can we better understand some higher-level practice guidelines. Millions of tall buildings rise flat, so you can think of this as a guide to best practices for novices.

In this guide, our main purpose is to familiarize you with some best practices when developing web API projects with .NET Core. Some of this content is also applicable in other frameworks. Therefore, mastering them is useful.