В этом посте я хочу поделиться архитектурой приложений, которую использую в реальных проектах и которая хорошо показала себя на практике.
- При проектировании архитектуры приложения я задаю следующие требования:
- Приложение должно быть легко покрывать тестами.
- Его должно быть легко обновлять и мигрировать на новые версии .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, и в большинстве случаев они сразу работают.