← Назад ко всем статьям

Архитектура, которую я использую в проектах

Опубликовано

В этом посте я хочу поделиться архитектурой приложений, которую использую в реальных проектах и которая хорошо показала себя на практике.

  • При проектировании архитектуры приложения я задаю следующие требования:
  • Приложение должно быть легко покрывать тестами.
  • Его должно быть легко обновлять и мигрировать на новые версии .NET.
  • Код должен быть простым, чтобы его было легче поддерживать, а новые разработчики быстрее входили в проект.
  • Должна быть возможность легко заменить data provider, например с базы данных на внешний сервис. Это часто встречается в больших компаниях с несколькими development teams.
  • Нужно избегать библиотек, которые влияют на архитектуру, например Revo, Marten, MediatR и так далее.

На практике я видел много проектов, которые не соответствовали этим критериям. Поддерживать их и добавлять новые features было сложно, а тестов тоже не было.

Эта архитектура clean и использует всего три слоя: Api, Core и Infrastructure. Но ее не стоит путать с традиционной three-tier architecture: в нашем случае применяется dependency inversion principle. Например, business layer (Core) не использует Infrastructure layer напрямую и не ссылается на него.

Пример структуры проекта:

Api.csproj

├── Controllers
│  └── ProductController.cs

Core.csproj

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

Infrastructure.csproj

├── Repositories
│  └── ProductRepository.cs

Всего три слоя помогают избежать путаницы и возможных abstraction leaks в долгосрочной перспективе. Вся business logic находится в Core layer. Часто core logic приложения сводится к получению данных из разных sources, их aggregation, mapping, а затем отправке на frontend, обработке входящих данных и сохранению или пересылке во внешний сервис.

Одно из ключевых требований - возможность заменить data source без изменения business layer. Это важно, потому что data sources часто меняются: например, то, что раньше шло через REST, позже может понадобиться отправлять в Kafka; или данные, которые раньше приходили из базы, теперь могут приходить из REST API, опубликованного другой командой.

Чтобы это поддержать, business logic в Core layer работает с данными только через interfaces. В моем случае эти interfaces используют suffix Repository, чтобы абстрагироваться от типа источника данных.

Модель данных, возвращаемая из data source, должна быть mapped в другую модель из IProductRepository. Этот mapping должен происходить в Infrastructure layer. Важно помнить: Core layer не должен ссылаться ни на какой другой project, но Infrastructure может ссылаться на Core, потому что реализует interface IProductRepository.

Посмотрим на call chain.

Endpoint в API layer вызывает GetProductUseCase, который живет в 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);
    }
}

В Core layer есть use-case interface и его implementation; implementation вызывает interface IProductRepository, который тоже определен в 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;
    }
}

В Infrastructure layer находится implementation ProductRepository и вся 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;
    }
}

Такая абстракция также упрощает написание unit tests. С тех пор как наша команда начала использовать Copilot, GitHub Copilot отлично справляется с этой задачей.

Я использую эту архитектуру больше трех лет, и она показала себя отлично. Мы начали применять ее и в других проектах команды. За это время мы несколько раз меняли data sources без серьезного переписывания кода. Благодаря простоте архитектуры Copilot эффективно генерирует unit tests, и в большинстве случаев они сразу работают.