A Practical Guide to ASP.NET Applications: The Structure (Part 1)

A Practical Guide to ASP.NET Applications: The Structure (Part 1)

In this article, we will discuss structuring an ASP.NET Core project to prepare it for large-scale commercial software solutions. Topics covered include project structure as well as service boundaries and responsibilities. To see a practical example of the concepts discussed, please check out the DevCoreApp repository.

Introduction

Every time I began a new project, I relied on the standard Visual Studio project template. Updated regularly by the Visual Studio team, it is a great way to explore new features. However, due to its simplicity, the project template lacks the necessary structure and organization required for robust, feature-rich applications.

As my projects expanded, I repeatedly found myself refactoring the code, implementing loose coupling, and incorporating layers, interfaces, and unit tests. Drawing inspiration from Angular architecture and SOLID design principles, I developed a three-layer architecture for my app, consisting of Controllers, Services, and Providers. This three-layer pattern can be applied to a wide range of applications.

To streamline the process for future projects, I created my own base app, or starting point, called DevCoreApp. By sharing it as an open-source project, I hope it can benefit others as well.

The general structure

The typical .NET Core Web API project in Visual Studio contains a Controllers folder, Startup.cs, and Program.cs. If authentication is chosen, it creates a database model and a "Migration" folder in the same project. This is a perfect project structure to start with.

As development progresses, controllers can grow to an enormous size. They might directly call EF code, execute queries, send emails, etc. Moving reusable code to a "BaseController" that every controller can inherit from may help, but this approach could complicate providing sufficient unit testing coverage (if any). This is when the following structure comes to the rescue:

  • /src/Server/Database: Includes database providers. Please refer to the Database ReadMe.

  • /src/Server/Email: Contains email provider(s).

  • /src/Server/WebService: Houses the Web App project.

  • /src/Server/WebService/Controller: Contains controllers implementing the APIs.

  • /src/Server/WebService/Services: Contains services (see more about the purpose of services below).

  • /src/Server/WebService/Authentication: Contains authentication and authorization logic.

  • /src/Server/WebService/Tools: Includes a set of classes to support the framework.

Here is the high-level diagram of the server’s architecture:

Controllers

Controllers live on the frontier of the web service. Their primary function is handling web requests. Controllers are responsible for accepting HTTP requests, reading inputs (query or request body), calling the service, returning results from the service back to the caller, and handling exceptions by converting them to HTTP codes. They "consume" a service. Any logic not directly related to HTTP should be handled by a dedicated service. Typically, controllers do not need to be unit tested.

Controllers should only work with shared model types (see more about Shared Models below). They should not reference any database models or any other internal types.

The following example demonstrates the typical Web API implementation:

[HttpGet]
[Route("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public ActionResult<WeatherForecastItem> GetById(string id)
{
    return HandleWebRequest((WebHandler<WeatherForecastItem>)(() =>
    {
        return Ok(Service.GetById(id));
    }));
}

The HandleWebRequest method encapsulates the error handling.

Services

A Service is a middleware component and contains all high-level logic, also known as business logic. It is the heart of the whole application and binds the controller, data access components, and other providers together. It is designed to abstract the controller from implementation details and keep the controller's code simple. In turn, the Service is protected by abstraction from details of database or other framework features implementations. The idea is that you can take a service, place it in a unit test, mock all the interfaces needed for it to operate, and it will just work without any changes.

As mentioned earlier, the Service layer should isolate controllers from exposure to database types. One of the tasks for a service is to translate between database model types and shared types.

A Service should follow the single responsibility pattern. It should not fall into the trap of satisfying the needs of a specific controller. It should have a single purpose. For instance, a "StudentService" should include functions needed to list, create, update, and delete students but have nothing to do with teachers.

Use the AppServiceAttribute to declare a service class. This attribute adds all the classes with this attribute to the dependency injection.

[AppService]
public class WeatherForecastService : BaseService
{
    ...
}

Make sure to call the AddAppServices method in Startup.cs.

...
using DevInstance.DevCoreApp.Server.WebService.Tools;

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        ...
        services.AddAppServices();
        ...
    }
}

The following example demonstrates the typical service method implementation:

public WeatherForecastItem GetById(string id)
{
    var record = GetRecordByPublicId(id);
    return record.ToView();
}

private WeatherForecast GetRecordByPublicId(string id)
{
    var q = Repository.GetWeatherForecastQuery(AuthorizationContext.CurrentProfile).ByPublicId(id);
    var record = q.Select().FirstOrDefault();
    if (record == null)
    {
        throw new RecordNotFoundException();
    }

    return record;
}

As you can see, the example above refers to the mysterious Repository and IWeatherForecastQuery. These types come from the database provider (see more about providers below) and will be subjects of future articles.

A typical service is located in the "Services" folder of the project. It refers to the "Tools" namespace for some basic tools for service configuration. Potentially, tools and all or some of the services can be moved into a separate assembly.

Providers

A provider is responsible for low-level data handling operations, such as SQL or LINQ queries, working with files, network requests, or sending emails. Providers offer an abstraction (a set of interfaces) and should only be accessed by services via these interfaces, rather than directly. The dependency hierarchy flows from top to bottom, with the controller dependent on the service and the service dependent on the provider. As a result, providers should be the most stable components, and any changes to them should be made with extra consideration. Conversely, controllers can be updated as frequently as needed.

The sample app currently has several providers:

  • Identity and Authentication: A set of interfaces implementing user and password validation and management. Interface declarations and implementations reside in the "Authentication" folder of the project. Potentially, it can be moved to a separate assembly if the authentication process gets more complicated.

  • Email provider: A set of interfaces for composing and sending emails wrapped into a separate assembly called "EmailProcessor." There is an implementation based on MailKit.

  • Database providers: As mentioned above, the database provider will be the subject of a future article. For now, please see more in the Database ReadMe.

Shared Model

The shared model is a set of entities shared between the client and server. These reside in the Shared.Model assembly. In the case of a Blazor project, this assembly is used by both the Server and Client. Shared model objects are not database objects and should not be treated as such.

There is a naming convention to distinguish between different types of objects. For instance, objects like Employee that can be returned as a list, inserted, updated, or deleted should end with "Item." Objects like login parameters or registration should end with "Parameters."

Next

In the following articles, I will dive deep into how to apply this architecture to specific use cases.