← Back to all articles

Architecture I Use in My Projects

Published

In this post I want to share the application architecture I use in real-world projects, an approach that has proven itself in practice.

  • When designing application architecture, I set the following requirements:
  • The application should be easy to cover with tests
  • It should be easy to update and migrate to new versions of .NET
  • The code should be simple, making it easier to maintain and helping new developers get onboard quickly
  • We should be able to easily switch the data provider, for example, from a database to an external service. This is common in large companies with multiple development teams
  • Avoid using libraries that impact architecture (e.g., Revo, Marten, MediatR, etc.)

In practice, I’ve seen many projects that didn’t meet these criteria. Supporting them and adding new features was difficult, and there were no tests either.

This architecture is clean and uses only three layers: Api, Core, and Infrastructure. However, it should not be confused with a traditional three-tier architecture; in our case, the dependency inversion principle is applied. For example, the business layer (Core) does not use or reference the Infrastructure layer directly.

Project structure example:

Api.csproj

├── Controllers
│  └── ProductController.cs

Core.csproj

├── RepositoriesContracts
│  └── IProductRepository.cs
├── UseCases
│  └── GetProduct
│    ├── GetProductUseCase.cs
│    └── IGetProductUseCase.cs

Infrastructure.csproj

├── Repositories
│  └── ProductRepository.cs

Using just three layers helps avoid confusion and potential abstraction leaks in the long term. All business logic is located in the Core layer. Often, the core logic of an application comes down to retrieving data from various sources, aggregating it, mapping it, and then either sending it to the frontend or processing incoming data and saving it, or forwarding it to an external service.

One of the core requirements was to be able to replace the data source without changing the business layer. This is essential because data sources often change, for example, what was once handled via REST might later need to be sent to Kafka, or data that used to come from a database might now come from a REST API published by another team.

To support this, the business logic in the Core layer works with data only via interfaces. In my case, these interfaces use the Repository suffix to abstract away the type of data source.

The data model returned from a data source must be mapped to a different model from IProductRepository. This mapping should happen in the Infrastructure layer. Remember, the Core layer should not reference any other project, but Infrastructure is allowed to reference Core because it implements the IProductRepository interface.

Let’s look at the call chain

The endpoint in the API layer calls the GetProductUseCase, which lives in the Core layer

[ApiController]
[Route("api/[controller]")]
public class ProductController : ControllerBase
{
    private readonly IGetProductUseCase _getProductUseCase;

    public ProductController(IGetProductUseCase getProductUseCase)
    {
        _getProductUseCase = getProductUseCase;
    }
    [HttpGet("{id}")]
    public async Task<ActionResult<ProductResponse>> GetProduct(int id)
    {
        var productCoreResult = await _getProductUseCase.Execute(id);
        var productResponse = productCoreResult.ToResponse();

        return Ok(productResponse);
    }
}

In the Core layer you have the use-case interface and its implementation; the implementation calls the IProductRepository interface (also defined in Core)

public class GetProductUseCase : IGetProductUseCase
{
    private readonly IProductRepository _productRepository;

    public GetProductUseCase(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public async Task<ProductCoreResult> Execute(int id)
    {
        var productDataResult = await _productRepository.GetById(id);
        var productCoreResult = productDataResult.ToResult();
        return productCoreResult;
    }
}

In the Infrastructure layer you have the ProductRepository implementation and all the database-related logic

public class ProductRepository : IProductRepository
{
    public async Task<ProductDataResult> GetById(int id)
    {
        const string query = "SELECT Id, Name, Price FROM Products WHERE Id = @Id";

        using var connection = new SqlConnection("DbConnection");
        await connection.OpenAsync();

        var productSourceModel = await connection
            .QuerySingleOrDefaultAsync<ProductSourceModel>(query, new { Id = id });

        var productDataResult = productSourceModel.ToDataResult();

        return productDataResult;
    }
}

This abstraction also makes it easy to write unit tests. Since our team started using Copilot, GitHub Copilot has handled this task very well.

I’ve used this architecture for over three years, and it has worked excellently. We’ve started applying it to other projects in the team. During this time, we’ve changed data sources multiple times without any major code rewrites. Thanks to the simplicity of the architecture, Copilot generates unit tests effectively, and in most cases, they work right away.