Organizing projects in ASP.NET Core is essential for maintaining clean and manageable code. Architectural patterns can help. Check out this blog post on implementing one of the most well-known architectural patterns, the onion pattern.
Architecture patterns are design approaches that help organize and structure web applications for optimal maintainability, scalability and flexibility.
This post will explore one of the most widely adopted standards today: the onion architecture. Additionally, we will discuss the practical application of this pattern in an ASP.NET Core application.
What Are Architectural Patterns?
Architecture patterns are high-level design solutions or models that provide a structured approach to organizing and designing the architecture of software systems.
These patterns offer a set of best practices and guidelines for solving common design problems that developers encounter while developing complex applications. Architectural patterns help software systems to be scalable, easy to maintain and adaptable to changing requirements.
Key features of architectural patterns include:
-
Reusability: Architectural patterns are reusable solutions that can be applied to different projects and domains. They encapsulate design expertise, making it easier for developers to apply proven design concepts.
-
Abstraction: Patterns provide a level of abstraction that focuses on the high-level structure and organization of a system rather than specific implementation details. This abstraction allows developers to think about system architecture in a more conceptual and general way.
-
Scalability: Architectural patterns are designed to accommodate future growth and changing requirements. They help ensure that a system can scale in functionality and performance.
-
Maintenance: By promoting a clear separation of concerns and modularization of components, architectural patterns facilitate the maintenance and extension of a software system over time.
-
Consistency: Patterns establish a consistent structure and design approach, which can be beneficial in team environments and large software projects.
-
Documentation: Standards come with a large amount of documentation and resources, which helps developers understand and apply them effectively.
In the context of ASP.NET Core, there are some widely used patterns, including:
- Onion architecture
- Hexagonal architecture (ports and adapters)
- Clean architecture (evolution of the onion architecture)
- Serverless architecture
In this post, we will learn about one of these patterns—the onion architecture pattern.
What Is the Onion Architecture Pattern?
The onion architecture pattern is a software architecture pattern widely used in ASP.NET Core and other modern application development frameworks. It is a variation on traditional layered architecture that promotes a more flexible and sustainable way of designing and structuring applications. Jeffrey Palermo popularized the onion architecture pattern, which is particularly suitable and recommended for building robust, maintainable and testable applications.
The main idea of onion architecture is to organize the application into circles or concentric layers, with each layer depending only on the inner layers.
Next, let’s learn about and implement each of the four main layers of a typical onion architecture application in ASP.NET Core.
Creating the Project
To create the sample project you need to have the following:
- .NET 7 or higher installed
- IDE of your choice (this post will use Visual Studio Code)
You can access the complete source code here: Source code.
At the end of the post, the complete project will have the following structure:
First, let’s create an ASP.NET Core solution project, where we will store the application layers. So, in the terminal run the following command:
dotnet new sln -n BookingFast
Implementing the Layers
1. Domain Layer
This is the most internal layer and contains the most critical part of the business logic, representing the core of the application, and must be completely independent of any external structures. In the core layer, you define your domain models, business rules and application-specific logic. This layer should have no dependencies on the other layers and is often called the “Domain” or “Entities” layer.
To create the domain layer in the project and add it to the solution class, use the following commands:
dotnet new classlib -n BookingFast.Domain
dotnet sln BookingFast.sln add BookingFast.Domain/BookingFast.Domain.csproj
Now inside the “BookingFast.Domain” folder, create a new folder called “Entities” and inside it create the following class:
- Reservation
namespace BookingFast.Domain.Entities;
public class Reservation
{
public Reservation(Guid id, string guestName, DateTime checkInDate, DateTime checkOutDate, string status)
{
Id = id;
GuestName = guestName;
CheckInDate = checkInDate;
CheckOutDate = checkOutDate;
Status = status;
}
public Guid Id { get; set; }
public string? GuestName { get; set; }
public DateTime CheckInDate { get; set; }
public DateTime CheckOutDate { get; set; }
public string? Status { get; set; }
}
The “Reservation” class is the main entity of our application, so it belongs to the domain layer, which is the innermost layer in an onion structure.
Next, let’s create the Infrastructure layer.
2. Infrastructure Layer
The infrastructure layer is responsible for interacting with external systems, structures and services. In the context of ASP.NET Core, this layer includes code related to data access, communication with external services and other infrastructure issues. This layer can have dependencies on external libraries, frameworks and ASP.NET Core itself.
To create the Infrastructure layer and add it to the solution, at the root of the project execute the following commands:
dotnet new classlib -n BookingFast.Infrastructure
dotnet sln BookingFast.sln add BookingFast.Infrastructure/BookingFast.Infrastructure.csproj
First, we need to download the dependencies to the infrastructure layer, so open a terminal in the infrastructure project and execute the following commands:
dotnet add package Microsoft.Extensions.Options.ConfigurationExtensions --version 8.0.0
dotnet add package MongoDB.Driver --version 2.22.0
Now, let’s create the class that will contain the variables responsible for storing the values of the database connection string.
Then, inside the “BookingFast.Infrastructure” folder, create a new folder called “Repositories.” Inside it, create a new class called “StudentDatabaseSettings” and place the following code in it:
namespace BookingFast.Infrastructure.Repositories;
public class ReservationsDatabaseSettings
{
public string ConnectionString { get; set; }
public string DatabaseName { get; set; }
public string CollectionName { get; set; }
}
In this example, we will create a database in MongoDB Atlas, which is a very simple database. To do this, you first need to create a server in MongoDB Atlas and create the database. If you’re new to MongoDB Atlas, I recommend this guide for creating and configuring your first cluster: MongoDB Atlas Getting Started (Atlas UI tab).
With the cluster configured, we can create a “reservation_db” database and a “students” collection as in the image below:
To connect our application to the cluster and access the created database, we need to obtain the connection string, which we will use later. To get it, just follow the steps shown in the images below:
In your database, click “Connect” > “Drivers” and in the window copy the connection string, as shown in the image below.
Now that we have the connection to the cluster, let’s implement the configuration in the project. Replace the code in the “appsettings.json” file of the “BookingFast.UI” layer with the code below:
{
"ReservationsDatabaseSettings": {
"ConnectionString": "<your cluster connection>",
"DatabaseName": "reservations_db",
"CollectionName": "reservations",
"IsSSL": true
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
In the code above, replace "<your cluster connection>"
with your previously obtained cluster connection. Also, remember to replace "<password>"
and"<username>"
with the cluster password and username.
Now let’s create the repository interface with the methods responsible for database operations.
In an onion architecture, a repository interface is usually found at the domain layer, as repositories are part of the data access logic and are a fundamental part of the application domain.
So, inside the “BookingFast.Domain” folder, create a new folder called “Infra.” Inside that, create another folder with the name “Interfaces” and add the following interface inside it:
- IReservationsRepository
using BookingFast.Domain.Entities;
namespace BookingFast.Domain.Infra.Interfaces;
public interface IReservationsRepository
{
Task<IEnumerable<Reservation>> FindAllReservations();
Task InsertReservation(Reservation reservation);
Task UpdateReservationStatus(string status);
}
To create the Repository class, we will use the infrastructure layer, so inside the “BookingFast.Infrastructure” folder, inside the “Repositories” folder create the class below:
ReservationsRepository
using BookingFast.Domain.Entities;
using BookingFast.Domain.Infra.Interfaces;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
namespace BookingFast.Infrastructure.Repositories;
public class ReservationsRepository : IReservationsRepository
{
private readonly IMongoCollection<Reservation> _reservations;
public ReservationsRepository(IOptions<ReservationsDatabaseSettings> options)
{
var mongoClient = new MongoClient(options.Value.ConnectionString);
_reservations = mongoClient
.GetDatabase(options.Value.DatabaseName)
.GetCollection<Reservation>(options.Value.CollectionName);
}
public async Task<IEnumerable<Reservation>> FindAllReservations()
{
if (_reservations == null)
return Enumerable.Empty<Reservation>();
return await _reservations.Find(_ => true).ToListAsync();
}
public async Task InsertReservation(Reservation reservation)
{
await _reservations.InsertOneAsync(reservation);
}
public async Task UpdateReservationStatus(string status, Guid id)
{
var reservation = await _reservations.Find(a => a.Id == id).SingleOrDefaultAsync();
reservation.Status = status;
await _reservations.ReplaceOneAsync(a => a.Id == id, reservation);
}
}
Note that we use the “IReservationsRepository” interface, which is from the domain layer. To use it, we need to add the reference to the domain layer in the infrastructure layer. So, double-click on the file “BookingFast.Infrastructure.csproj” and add the code below to it:
<ItemGroup>
<ProjectReference Include="..\BookingFast.Domain\BookingFast.Domain.csproj" />
</ItemGroup>
The Infrastructure layer is ready, the next step is to implement the Application layer where we will create the service class.
3. Application Layer
The next concentric circle is the application layer, which depends on the domain layer but should also not have dependencies on external frameworks. This layer contains application-specific services, use cases and application logic. It acts as an intermediary between the omain layer and external layers such as the UI and infrastructure layers.
To create the application layer and add it to the solution, execute the following commands in the terminal, in the project root:
dotnet new classlib -n BookingFast.Application
dotnet sln BookingFast.sln add BookingFast.Application/BookingFast.Application.csproj
First, let’s create the Data Transfer Object classes (DTOs) that will be the classes exposed to the UI layer and that represent the entity model. In this case, it will be the “ReservationDto” class.
So, inside the “BookingFast.Application” folder, create a new folder called “Dtos” and inside it create a new class called “ReservationDto.” Place the code below in it:
using BookingFast.Domain.Entities;
namespace BookingFast.Application.Dtos;
public class ReservationDto
{
public ReservationDto() { }
public ReservationDto(Reservation reservation)
{
Id = reservation.Id;
GuestName = reservation.GuestName;
CheckInDate = reservation.CheckInDate;
CheckOutDate = reservation.CheckOutDate;
Status = reservation.Status;
}
public Guid Id { get; set; }
public string? GuestName { get; set; }
public DateTime CheckInDate { get; set; }
public DateTime CheckOutDate { get; set; }
public string? Status { get; set; }
}
Here we also need to add the dependencies of other layers, which are the domain and infrastructure, so double-click on the “BookingFast.Application” file and add the code below:
<ItemGroup>
<ProjectReference Include="..\BookingFast.Domain\BookingFast.Domain.csproj" />
<ProjectReference Include="..\BookingFast.Infrastructure\BookingFast.Infrastructure.csproj" />
</ItemGroup>
The next step is to create the service class and methods to perform bank operations through the infrastructure layer. Inside the “BookingFast.Application” folder, create a new folder called “Services” and inside it create the following interface and class:
- IReservationsService
using BookingFast.Application.Dtos;
namespace BookingFast.Application.Services;
public interface IReservationsService
{
Task<List<ReservationDto>> FindAllReservations();
Task CreateNewReservation(ReservationDto reservation);
Task UpdateReservationStatus(string status, Guid id);
}
- ReservationsService
using BookingFast.Application.Dtos;
using BookingFast.Domain.Entities;
using BookingFast.Domain.Infra.Interfaces;
namespace BookingFast.Application.Services;
public class ReservationsService : IReservationsService
{
private readonly IReservationsRepository _reservationsRepository;
public ReservationsService(IReservationsRepository reservationsRepository)
{
_reservationsRepository = reservationsRepository;
}
public async Task<List<ReservationDto>> FindAllReservations()
{
var reservations = await _reservationsRepository.FindAllReservations();
return reservations.Select(reservation => new ReservationDto(reservation)).ToList();
}
public async Task CreateNewReservation(ReservationDto reservation)
{
var newReservation = new Reservation(reservation.Id, reservation.GuestName, reservation.CheckInDate, reservation.CheckOutDate, reservation.Status);
await _reservationsRepository.InsertReservation(newReservation);
}
public async Task UpdateReservationStatus(string status, Guid id)
{
await _reservationsRepository.UpdateReservationStatus(status, id);
}
}
4. UI Layer
The outermost circle is the UI layer, which includes the application’s user interface components. In the context of ASP.NET Core, this layer includes controllers, views and other components responsible for handling HTTP requests, user input and UI rendering. The UI layer depends on the application and infrastructure layers, but should not contain any business logic. It mainly handles user interactions and invokes application services.
To create the UI project, run the command below:
dotnet new web -n BookingFast.UI
This command will create a new project using the ASP.NET Core Minimal APIs template. Next, run the following commands to add the “BookingFast.UI” project to the solution class:
dotnet sln BookingFast.sln add BookingFast.UI/BookingFast.UI.csproj
Now let’s add the reference to the application layer. Double-click in the “BookingFast.UI.csproj” file and add the following code snippet:
<ItemGroup>
<ProjectReference Include="..\BookingFast.Application\BookingFast.Application.csproj" />
</ItemGroup>
Then, let’s download the NuGet packages to the UI layer. Open a terminal in the UI project and execute the following commands:
dotnet add package Microsoft.AspNetCore.OpenApi --version 8.0.0
dotnet add package Swashbuckle.AspNetCore --version 6.5.0
The next step is to create the controller, which will call the service class methods and expose the data through the endpoints.
In the UI layer, create a new folder called “Controllers.” Inside it, create a new file called “ReservationsController.cs” and place the code below in it:
using BookingFast.Application.Dtos;
using BookingFast.Application.Services;
using Microsoft.AspNetCore.Mvc;
namespace BookingFast.UI.Controllers;
[ApiController]
[Route("[controller]")]
public class ReservationsController : Controller
{
private readonly IReservationsService _reservationsService;
public ReservationsController(IReservationsService reservationsService)
{
_reservationsService = reservationsService;
}
[HttpGet]
public async Task<ActionResult<List<ReservationDto>>> FindAllReservations()
{
var reservations = await _reservationsService.FindAllReservations();
return Ok(reservations);
}
[HttpPost]
public async Task<IActionResult> CreateNewReservation([FromBody] ReservationDto reservation)
{
await _reservationsService.CreateNewReservation(reservation);
return Ok();
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateReservationStatus(string status, Guid id)
{
await _reservationsService.UpdateReservationStatus(status, id);
return Ok();
}
}
The last step is to configure the dependency injection of the classes. In the “Program.cs,” file replace the existing code with the code below:
using BookingFast.Application.Services;
using BookingFast.Domain.Infra.Interfaces;
using BookingFast.Infrastructure.Repositories;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<ReservationsDatabaseSettings>(builder.Configuration.GetSection("ReservationsDatabaseSettings"));
builder.Services.AddSingleton<IReservationsRepository, ReservationsRepository>();
builder.Services.AddScoped<IReservationsService, ReservationsService>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Testing the Application
To test the application, open a terminal in the UI project and execute the following command:
dotnet run
In the browser, access the address http://localhost:5202/swagger/index.html
and you can execute the operations in the Swagger interface, as shown in the GIF below:
Conclusion
In summary, the onion architectural pattern stands out as a notable approach to structuring and sustaining ASP.NET Core projects efficiently. Throughout this post, we explored the basics of this pattern and examined its practical application.
Whenever you create a new project, consider using the onion pattern. This way, you will not only take advantage of the structural advantages offered by the pattern, but you will also be investing in more readable, sustainable and easily maintained code.