Что такое эта "луковая" архитектура? Термин предложил Джеффри Палермо (Jeffrey Palermo) в 2008 году, и с тех пор подход только набирал обороты. Суть проста - представьте себе лук с его концентрическими слоями. В центре находится ядро приложения с бизнес-логикой и доменными моделями, а вокруг него наслаиваются уровни с разными зонами ответственности. Чем дальше от центра, тем ближе к внешнему миру - базам данных, интерфейсам, внешним сервисам. Главная фишка этой архитектуры в том, что зависимости всегда направлены к центру. Внутренние слои никогда не зависят от внешних. Это радикально отличается от классической многоуровневой архитектуры, где слои часто тесно связаны между собой.
Я работал с проектами, где MVC использовался буквально "из коробки", и, поверьте, со временем в них становилось все труднее вносить изменения. Контроллеры распухали, бизнес-логика смешивалась с доступом к данным, а о тестировании отдельных компонентов можно было только мечтать.
Onion Architecture решает три ключевые проблемы:
1. Разделение ответственности - каждый слой делает только то, что должен.
2. Слабая связанность - компоненты не имеют жестких зависимостей друг от друга.
3. Тестируемость - можно легко создавать модульные тесты для любого компонента.
Особенно круто работает эта архитектура в связке с принципом инверсии зависимостей (DI), который встроен в ASP.NET Core. Это позволяет писать код, ориентированный на абстракции, а не на конкретные реализации. Многие путают луковую архитектуру с чистой архитектурой (Clean Architecture) или гексагональной архитектурой (Hexagonal Architecture). И хотя между ними есть сходства - все они ставят доменную модель в центр, - есть и различия в организации слоев и взаимодействии между ними.
Основные принципы луковой архитектуры
Раньше мне казалось, что суть хорошей архитектуры сводится к грамотному разбиению на классы и интерфейсы. Но как только проект разростается до промышленных масштабов, такого подхода становится недостаточно. Луковая архитектура предлагает нечто большее - целую философию организации кода. Представьте, что вы держите в руках настоящую луковицу. В центре находится ядро, вокруг которого концентрическими кругами располагаются слои. Внутренние слои не знают ничего о внешних, но внешние - осведомлены о внутренних. Это фундаментальный принцип направленых зависимостей: они всегда направлены внутрь, к центру.
Я часто задумываюсь, почему это так важно? Потому что слои, находящиеся внутри, защищены от изменений в слоях, расположеных снаружи. Например, если я решу поменять базу данных с SQL Server на MongoDB, внутренние слои с бизнес-логикой не пострадают.
Слои зависимостей и их взаимодействие
В классической луковой архитектуре обычно выделяют следующие слои:
1. Доменный слой (Domain Entities) - самое сердце приложения. Здесь находятся доменные сущности, которые представляют основные бизнес-объекты и их поведение. Они максимально чисты и не имеют зависимостей от инфраструктуры, фреймворков или внешних библиотек.
2. Слой репозиториев (Repository Layer) - отвечает за абстракцию доступа к данным. Здесь определяются интерфейсы репозиториев, которые позволяют получать и сохранять доменные объекты.
3. Слой сервисов (Service Layer) - содержит бизнес-логику приложения, которая оперирует доменными объектами. Взаимодействует с репозиториями через интерфейсы.
4. Внешний слой (UI/Infrastructure Layer) - включает пользовательский интерфейс, контроллеры, адаптеры для внешних API, реализации репозиториев и т.д.
Помню, как на одном проекте мы ошиблись, позволив слою сервисов напрямую ссылаться на конкретные реализации репозиториев. Потом нам пришлось переписывать кучу кода из-за смены хранилища данных. Больше я такой ошибки не допускаю - теперь всегда использую интерфейсы для разделения абстракции от реализации.
Инверсия управления как основа
Ключевой элемент луковой архитектуры - это принцип инверсии управления (Inversion of Control, IoC), который является частью набора принципов SOLID. Особенно важен принцип инверсии зависимостей (Dependency Inversion Principle):
1. Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба типа модулей должны зависеть от абстракций.
2. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Если говорить проще, то высокоуровневые компоненты (бизнес-логика) не должны напрямую зависеть от низкоуровневых компонентов (доступ к БД, работа с файлами). Вместо этого оба типа компонентов зависят от абстракций. На практике это означает, что в доменном слое мы определяем интерфейсы репозиториев, а их реализации находятся во внешнем слое. Таким образом, стрелка зависимости направлена внутрь - от реализации к интерфейсу. Например, в ASP.NET Core реализация выглядит примерно так:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // Интерфейс в Domain слое
public interface IUserRepository
{
User GetById(int id);
void Save(User user);
}
// Реализация во внешнем слое
public class SqlUserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
public SqlUserRepository(ApplicationDbContext context)
{
_context = context;
}
public User GetById(int id)
{
return _context.Users.Find(id);
}
public void Save(User user)
{
_context.Users.Update(user);
_context.SaveChanges();
}
} |
|
Эту схему дополняет механизм внедрения зависимостей (Dependency Injection), который встроен в ASP.NET Core. Он позволяет регистрировать зависимости и разрешать их во время выполнения:
| C# | 1
| services.AddScoped<IUserRepository, SqlUserRepository>(); |
|
Я нередко видел, как разработчики путаются в этих понятиях. Инверсия управления - это принцип, а внедрение зависимостей - это конкретная техника его реализации.
Принцип внешних и внутренних слоев
Представьте, что каждый слой луковицы - это защитный барьер для слоев, расположеных внутри. Внутренние слои не должны знать о существовании внешних слоев, это ключевое правило. На практике это означает следующее:- Доменные сущности никогда не ссылаются на классы из внешних слоев.
- Интерфейсы репозиториев не должны включать детали реализации хранения данных.
- Сервисный слой взаимодействует с репозиториями только через интерфейсы.
Такая изоляция приносит несколько важных преимуществ:
1. Модульное тестирование становится проще, так как компоненты можно тестировать изолированно.
2. Изменения в инфраструктуре (БД, UI, внешние API) не влияют на бизнес-логику.
3. Код становится более понятным, так как каждый слой имеет четкую ответственность.
Я помню, как в одном крупном банковском проекте мы потратили месяцы на рефакторинг, потому что бизнес-логика была плотно переплетена с кодом доступа к данным. Если бы с самого начала использовали луковую архитектуру, такой проблемы бы не возникло.
В луковой архитектуре есть два типа связей между компонентами - тесная связь (tight coupling) и слабая связь (loose coupling).
Тесная связь возникает, когда класс напрямую зависит от конкретной реализации. Например, если сервис напрямую создает экземпляр репозитория с помощью оператора new, он тесно связан с этим репозиторием. В такой ситуации изменение одного компонента часто требует изменений в другом компоненте, что может привести к каскадным изменениям во всем приложении.
Слабая связь означает, что компоненты не зависят напрямую друг от друга. Например, сервис взаимодействует с репозиторием через интерфейс, а конкретная реализация предоставляется во время выполнения. Это позволяет изменять один компонент без необходимости изменять другие.
На практике я обнаружил, что новичкам бывает трудно определить, где именно проходят границы между слоями. Мой совет - представить, что вы можете взять внутренний слой и использовать его в совершенно другом проекте. Если при этом не возникает проблем с зависимостями, значит, вы правильно определили границы.
Еще один момент, о котором часто забывают - круговые зависимости. Они могут появиться даже при использовании интерфейсов, если не соблюдать дисциплину направления зависимостей. Однажды я столкнулся с ситуацией, когда интерфейс из домена ссылался на класс из внешнего слоя, что противоречило всей концепции луковой архитектуры и привело к каскаду проблем при тестировании.
Разница между ASP.NET Core 2, ASP.NET Core MVC, ASP.NET MVC 5 и ASP.NET WEBAPI 2 Здравствуйте. Я в бекенд разработке полный ноль. В чем разница между вышеперечисленными... ASP.NET MVC 4,ASP.NET MVC 4.5 и ASP.NET MVC 5 большая ли разница между ними? Начал во всю осваивать технологию,теперь хочу с книжкой посидеть и вдумчиво перебрать всё то что... Какая разница между ASP .Net Core и ASP .Net Core MVC? Какая разница между ASP .Net Core и ASP .Net Core MVC? Или я может что-то не так понял? И... ASP.NET MVC VS .NET CORE MVC Ку, можете подкинуть статейку где подробно описывается разница между этими двумя технологиями плз....
Практическая реализация в ASP.NET Core
Структура проекта и организация слоев
В идеальном мире каждый слой архитектуры стоит оформлять как отдельный проект в решении Visual Studio. Это помогает явно контролировать зависимости и делает границы между слоями физически осязаемыми. Я обычно создаю следующую структуру проектов:
| C# | 1
2
3
4
5
6
| Solution 'MyApp'
├── MyApp.Domain
├── MyApp.Application
├── MyApp.Infrastructure
├── MyApp.Services
└── MyApp.Web |
|
Эти проекты формируют четкую иерархию зависимостей, которую можно представить следущим образом:
MyApp.Web зависит от MyApp.Services и MyApp.Application,
MyApp.Services зависит от MyApp.Application и MyApp.Domain,
MyApp.Application зависит от MyApp.Domain,
MyApp.Infrastructure зависит от MyApp.Domain,
MyApp.Domain не зависит ни от кого.
Иногда мне задают вопрос - обязательно ли создавать столько проектов, особенно если приложение относительно небольшое? На это я отвечаю, что не всегда. Для маленьких приложений можно объединить некоторые слои, например, Domain и Application в один проект. Но здесь важен принцип: как только вы чувствуете, что границы между слоями начинают размываться, лучше разделить их на отдельные проекты.
Domain Layer - ядро бизнес-логики
Начнем с самого сердца нашей луковицы - доменного слоя. Проект MyApp.Domain содержит:
1. Доменные сущности - классы, представляющие основные бизнес-объекты приложения.
2. Интерфейсы репозиториев - контракты для доступа к данным.
3. Доменные сервисы - логика, которая по своей природе принадлежит самому домену.
4. События доменной модели - если используется подход Domain-Driven Design.
Важный момент - доменные сущности должны быть максимально чистыми. Я часто видел, как разработчики добавляют в них атрибуты для валидации формы или ORM-маппинга. Это грубая ошибка! Доменный слой не должен знать ничего о UI или базе данных. Вот как может выглядеть простая доменная модель:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
| // Базовая сущность
public abstract class BaseEntity
{
public long Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ModifiedAt { get; set; }
public string CreatedBy { get; set; }
public string ModifiedBy { get; set; }
}
// Доменная сущность
public class User : BaseEntity
{
public string UserName { get; private set; }
public string Email { get; private set; }
public string PasswordHash { get; private set; }
public UserProfile Profile { get; private set; }
// Конструктор
public User(string userName, string email, string passwordHash)
{
if (string.IsNullOrEmpty(userName))
throw new ArgumentException("UserName cannot be empty");
if (string.IsNullOrEmpty(email))
throw new ArgumentException("Email cannot be empty");
UserName = userName;
Email = email;
PasswordHash = passwordHash;
CreatedAt = DateTime.UtcNow;
ModifiedAt = DateTime.UtcNow;
}
// Методы, выражающие поведение
public void ChangeEmail(string newEmail)
{
if (string.IsNullOrEmpty(newEmail))
throw new ArgumentException("Email cannot be empty");
Email = newEmail;
ModifiedAt = DateTime.UtcNow;
}
public void SetProfile(UserProfile profile)
{
Profile = profile ?? throw new ArgumentNullException(nameof(profile));
ModifiedAt = DateTime.UtcNow;
}
}
// Еще одна доменная сущность
public class UserProfile : BaseEntity
{
public string FirstName { get; private set; }
public string LastName { get; private set; }
public string Address { get; private set; }
public User User { get; private set; }
// Конструктор и методы
// ...
} |
|
Заметьте несколько важных деталей:
1. Все свойства с сеттерами имеют модификатор private - это защищает от неконтролируемых изменений.
2. Любые изменения происходят через методы, которые обеспечивают инвариантность сущности.
3. Конструктор проверяет входные данные и устанавливает начальное состояние.
В этом же слое я определяю интерфейсы репозиториев:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public interface IRepository<T> where T : BaseEntity
{
IEnumerable<T> GetAll();
T GetById(long id);
void Add(T entity);
void Update(T entity);
void Delete(T entity);
void SaveChanges();
}
public interface IUserRepository : IRepository<User>
{
User GetByEmail(string email);
User GetByUserName(string userName);
} |
|
Замечу, что даже на уровне интерфейса мы не упоминаем никаких деталей реализации - ни SQL, ни Entity Framework, ни MongoDB. Домен описывает только то, что должно быть сделано, но не как.
Infrastructure Layer - работа с данными
Следующий слой - инфраструктурный. Проект MyApp.Infrastructure отвечает за:
1. Реализацию репозиториев - конкретные классы для работы с базой данных.
2. Настройку ORM - конфигурацию Entity Framework или другой технологии доступа к данным.
3. Внешние сервисы - интеграцию с API, системами оплаты, отправкой email и т.д..
Именно здесь происходит вся "грязная" работа с внешними системами. Самое главное, что инфраструктурный слой зависит от доменного через интерфейсы, но доменный не зависит от инфраструктурного. Вот как может выглядеть реализация репозитория с использованием Entity Framework Core:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<User> Users { get; set; }
public DbSet<UserProfile> UserProfiles { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Конфигурация маппинга
modelBuilder.Entity<User>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.UserName).IsRequired();
entity.Property(e => e.Email).IsRequired();
entity.HasOne(e => e.Profile)
.WithOne(e => e.User)
.HasForeignKey<UserProfile>(e => e.Id);
});
// Конфигурация для других сущностей
// ...
}
} |
|
А вот реализация самого репозитория:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
| public class Repository<T> : IRepository<T> where T : BaseEntity
{
protected readonly ApplicationDbContext Context;
protected readonly DbSet<T> DbSet;
public Repository(ApplicationDbContext context)
{
Context = context;
DbSet = context.Set<T>();
}
public IEnumerable<T> GetAll()
{
return DbSet.AsNoTracking().ToList();
}
public T GetById(long id)
{
return DbSet.Find(id);
}
public void Add(T entity)
{
DbSet.Add(entity);
}
public void Update(T entity)
{
DbSet.Update(entity);
}
public void Delete(T entity)
{
DbSet.Remove(entity);
}
public void SaveChanges()
{
Context.SaveChanges();
}
}
public class UserRepository : Repository<User>, IUserRepository
{
public UserRepository(ApplicationDbContext context) : base(context)
{
}
public User GetByEmail(string email)
{
return DbSet.FirstOrDefault(u => u.Email == email);
}
public User GetByUserName(string userName)
{
return DbSet.FirstOrDefault(u => u.UserName == userName);
}
} |
|
На одном из моих проектов мы использовали Entity Framework для основных данных и Redis для кеширования. Благодаря луковой архитектуре обе эти технологии были изолированы в инфраструктурном слое, а бизнес-логика даже не подозревала об их существовании.
Application Layer - бизнес-сценарии
Следующий круг нашей луковицы - это слой приложения или Application Layer. В проекте MyApp.Application сосредоточены:
1. DTO (Data Transfer Objects) - объекты для передачи данных между слоями.
2. Интерфейсы сервисов - контракты для бизнес-операций.
3. Команды и запросы - если используется паттерн CQRS.
4. Маппинг между доменными объектами и DTO.
Application Layer служит связующим звеном между доменной моделью и внешним миром. Если домен говорит на языке бизнеса, то слой приложения переводит этот язык на понятный для интерфейса пользователя.
Я считаю хорошей практикой создавать DTO для передачи данных между слоями:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| public class UserDto
{
public long Id { get; set; }
public string UserName { get; set; }
public string Email { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Address { get; set; }
}
public class CreateUserDto
{
public string UserName { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Address { get; set; }
} |
|
Заметьте, что DTO не имеют никакой логики, это просто контейнеры для данных. Они могут содержать атрибуты валидации, которые будут использоваться на уровне представления. В этом же слое я определяю интерфейсы сервисов:
| C# | 1
2
3
4
5
6
7
8
9
| public interface IUserService
{
IEnumerable<UserDto> GetAllUsers();
UserDto GetUserById(long id);
UserDto GetUserByEmail(string email);
void CreateUser(CreateUserDto userDto);
void UpdateUser(UserDto userDto);
void DeleteUser(long id);
} |
|
Для удобства маппинга между доменными объектами и DTO я часто использую библиотеку AutoMapper:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<User, UserDto>()
.ForMember(dest => dest.FirstName, opt => opt.MapFrom(src => src.Profile.FirstName))
.ForMember(dest => dest.LastName, opt => opt.MapFrom(src => src.Profile.LastName))
.ForMember(dest => dest.Address, opt => opt.MapFrom(src => src.Profile.Address));
CreateMap<CreateUserDto, User>()
.ConstructUsing(src => new User(src.UserName, src.Email,
// В реальном проекте здесь был бы хеш пароля
src.Password));
}
} |
|
Service Layer - координация между доменом и приложением
Теперь перейдем к сервисному слою. Проект MyApp.Services содержит:
1. Реализацию сервисов - классы, которые выполняют бизнес-операции.
2. Фасады для внешних сервисов - адаптеры для интеграции с внешними API.
3. Обработку бизнес-правил и валидацию - логику, которая не принадлежит отдельным сущностям.
Именно здесь происходит координация между различными компонентами системы. Сервисы используют репозитории для доступа к данным, вызывают методы доменных объектов и выполняют транзакции. Вот пример реализации сервиса пользователей:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
| public class UserService : IUserService
{
private readonly IUserRepository _userRepository;
private readonly IMapper _mapper;
public UserService(IUserRepository userRepository, IMapper mapper)
{
_userRepository = userRepository;
_mapper = mapper;
}
public IEnumerable<UserDto> GetAllUsers()
{
var users = _userRepository.GetAll();
return _mapper.Map<IEnumerable<UserDto>>(users);
}
public UserDto GetUserById(long id)
{
var user = _userRepository.GetById(id);
return user == null ? null : _mapper.Map<UserDto>(user);
}
public UserDto GetUserByEmail(string email)
{
var user = _userRepository.GetByEmail(email);
return user == null ? null : _mapper.Map<UserDto>(user);
}
public void CreateUser(CreateUserDto userDto)
{
// Проверка, что пользователь с таким email не существует
if (_userRepository.GetByEmail(userDto.Email) != null)
throw new ApplicationException("User with this email already exists");
var user = _mapper.Map<User>(userDto);
// Создание профиля пользователя
var profile = new UserProfile
{
FirstName = userDto.FirstName,
LastName = userDto.LastName,
Address = userDto.Address,
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
};
user.SetProfile(profile);
_userRepository.Add(user);
_userRepository.SaveChanges();
}
public void UpdateUser(UserDto userDto)
{
var user = _userRepository.GetById(userDto.Id);
if (user == null)
throw new ApplicationException("User not found");
// Обновление email через метод домена для сохранения инвариантов
if (user.Email != userDto.Email)
user.ChangeEmail(userDto.Email);
// Обновление профиля
if (user.Profile != null)
{
user.Profile.FirstName = userDto.FirstName;
user.Profile.LastName = userDto.LastName;
user.Profile.Address = userDto.Address;
user.Profile.ModifiedAt = DateTime.UtcNow;
}
_userRepository.Update(user);
_userRepository.SaveChanges();
}
public void DeleteUser(long id)
{
var user = _userRepository.GetById(id);
if (user == null)
throw new ApplicationException("User not found");
_userRepository.Delete(user);
_userRepository.SaveChanges();
}
} |
|
Один из проектов, над которым я работал, требовал интеграции с внешним API для верификации электронной почты. Мы инкапсулировали всю логику взаимодействия с этим API в сервисном слое, что позволило легко заменить реального провайдера на заглушку во время разработки и тестирования.
Presentation Layer - контроллеры и представления
Наконец, доходим до внешнего слоя - презентационого. Проект MyApp.Web включает:
1. Контроллеры - обработчики HTTP-запросов.
2. Представления - Razor-страницы для генерации HTML.
3. Модели представления - специализированные DTO для представлений.
4. Фильтры и middleware - компоненты для обработки запросов.
Этот слой отвечает за взаимодействие с пользователем и преобразование запросов в вызовы соответствующих сервисов.
Вот пример контроллера пользователей:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
| public class UserController : Controller
{
private readonly IUserService _userService;
public UserController(IUserService userService)
{
_userService = userService;
}
public IActionResult Index()
{
var users = _userService.GetAllUsers();
return View(users);
}
public IActionResult Details(long id)
{
var user = _userService.GetUserById(id);
if (user == null)
return NotFound();
return View(user);
}
[HttpGet]
public IActionResult Create()
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Create(CreateUserDto model)
{
if (ModelState.IsValid)
{
try
{
_userService.CreateUser(model);
return RedirectToAction(nameof(Index));
}
catch (ApplicationException ex)
{
ModelState.AddModelError("", ex.Message);
}
}
return View(model);
}
// Другие экшены для обновления и удаления...
} |
|
Когда я только начинал использовать луковую архитектуру, я допускал ошибку, помещая бизнес-логику прямо в контроллеры. Теперь я знаю, что контроллер должен быть максимально тонким - его задача только преобразовать HTTP-запрос в вызов сервиса и вернуть соответствующий ответ.
Validation Layer - валидация данных на границах слоев
Долгое время я считал, что валидация - это просто проверка ввода пользователя на уровне контроллера. Но при работе с луковой архитектурой понял, что такой подход ошибочен. Валидация данных должна происходить на разных уровнях и с разными целями. В моей практике оправдал себя подход с тремя уровнями валидации:
1. Валидация на уровне UI - проверка ввода пользователя на соответствие простым правилам (формат email, обязательные поля и т.д.),
2. Валидация на уровне приложения - проверка бизнес-правил, которые касаются отдельных объектов,
3. Валидация на уровне домена - проверка инвариантов и бизнес-правил, которые должны соблюдаться всегда.
Для валидации на уровне UI я использую атрибуты валидации в моделях представления или DTO:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public class CreateUserDto
{
[Required(ErrorMessage = "Имя пользователя обязательно")]
[StringLength(50, MinimumLength = 3, ErrorMessage = "Имя должно быть от 3 до 50 символов")]
public string UserName { get; set; }
[Required(ErrorMessage = "Email обязателен")]
[EmailAddress(ErrorMessage = "Некорректный формат email")]
public string Email { get; set; }
[Required(ErrorMessage = "Пароль обязателен")]
[StringLength(100, MinimumLength = 6, ErrorMessage = "Пароль должен быть не менее 6 символов")]
public string Password { get; set; }
// Другие поля...
} |
|
Для валидации на уровне приложения я использую FluentValidation - эта библиотека позволяет определять более сложные правила валидации:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| public class CreateUserValidator : AbstractValidator<CreateUserDto>
{
private readonly IUserRepository _userRepository;
public CreateUserValidator(IUserRepository userRepository)
{
_userRepository = userRepository;
RuleFor(x => x.UserName)
.Must(BeUniqueUserName).WithMessage("Пользователь с таким именем уже существует");
RuleFor(x => x.Email)
.Must(BeUniqueEmail).WithMessage("Пользователь с таким email уже существует");
}
private bool BeUniqueUserName(string userName)
{
return _userRepository.GetByUserName(userName) == null;
}
private bool BeUniqueEmail(string email)
{
return _userRepository.GetByEmail(email) == null;
}
} |
|
Мой опыт показал, что валидация в домене должна быть частью самих объектов и их методов. Вспомните, как в примере выше домен-объект User выполнял проверки в своих методах и конструкторе.
Чтобы связать всю эту валидацию вместе, я регистрирую валидаторы в DI-контейнере и использую фильтр действий в ASP.NET Core:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
| // Startup.cs
public void ConfigureServices(IServiceCollection services)
{
// Другие регистрации...
services.AddTransient<IValidator<CreateUserDto>, CreateUserValidator>();
services.AddMvc(options =>
{
options.Filters.Add(typeof(ValidationActionFilter));
});
}
// ValidationActionFilter.cs
public class ValidationActionFilter : IActionFilter
{
private readonly IServiceProvider _serviceProvider;
public ValidationActionFilter(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
return;
foreach (var argument in context.ActionArguments)
{
var validatorType = typeof(IValidator<>).MakeGenericType(argument.Value.GetType());
var validator = _serviceProvider.GetService(validatorType) as IValidatorBase;
if (validator != null)
{
var validationContext = new ValidationContext(argument.Value);
var validationResult = validator.Validate(validationContext);
foreach (var error in validationResult.Errors)
{
context.ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}
}
}
}
public void OnActionExecuted(ActionExecutedContext context)
{
// Ничего не делаем
}
} |
|
Работа с зависимостями
Если домен - это сердце нашей луковой архитектуры, то система внедрения зависимостей - её кровеносная система. Без правильно настроенных зависимостей луковица превращается в запутанный клубок проводов, а об изоляции слоев можно забыть.
Dependency Injection в ASP.NET Core
Microsoft по-настоящему порадовал меня, когда встроил контейнер внедрения зависимостей прямо в фреймворк ASP.NET Core. Это избавило от необходимости подключать сторонние библиотеки вроде Ninject или Unity, хотя они до сих пор актуальны для расширенных сценариев.
Встроенный DI-контейнер поддерживает три жизненных цикла:- Transient - новый экземпляр создается каждый раз при запросе зависимости.
- Scoped - новый экземпляр создается один раз для каждого HTTP-запроса.
- Singleton - создается единственный экземпляр на все время работы приложения.
В контексте луковой архитектуры я обычно регистрирую зависимости так:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| public void ConfigureServices(IServiceCollection services)
{
// Контекст базы данных обычно регистрируется как Scoped
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
// Репозитории как Scoped, так как они используют DbContext
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddScoped<IUserRepository, UserRepository>();
// Сервисы как Scoped или Transient, в зависимости от их состояния
services.AddScoped<IUserService, UserService>();
// Вспомогательные утилиты часто как Singleton
services.AddSingleton<IEmailSender, EmailSender>();
// Автомаппер
services.AddAutoMapper(typeof(Startup));
// MVC
services.AddControllersWithViews();
} |
|
Здесь важно понимать иерархию зависимостей. Сервис с жизненым циклом Scoped может использовать сервисы Scoped и Transient, но не может использовать Singleton. Иначе можно получить известную ошибку "Cannot consume scoped service from singleton". Я один раз пытался внедрить DbContext в сервис с жизненным циклом Singleton, и это привело к целому каскаду ошибок при параллельных запросах.
Repository Pattern и Unit of Work
В луковой архитектуре репозиторий - это мост между доменым слоем и инфраструктурой. Мне нравится подход, когда репозиторий представляет собой коллекцию доменных объектов, а не просто фасад для доступа к базе данных.
Классический паттерн репозитория часто дополняется паттерном Unit of Work, который координирует работу нескольких репозиториев и управляет транзакциями:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| public interface IUnitOfWork : IDisposable
{
IUserRepository Users { get; }
IUserProfileRepository UserProfiles { get; }
// Другие репозитории...
int SaveChanges();
Task<int> SaveChangesAsync();
}
public class UnitOfWork : IUnitOfWork
{
private readonly ApplicationDbContext _context;
private IUserRepository _userRepository;
private IUserProfileRepository _userProfileRepository;
public UnitOfWork(ApplicationDbContext context)
{
_context = context;
}
public IUserRepository Users =>
_userRepository ??= new UserRepository(_context);
public IUserProfileRepository UserProfiles =>
_userProfileRepository ??= new UserProfileRepository(_context);
public int SaveChanges()
{
return _context.SaveChanges();
}
public async Task<int> SaveChangesAsync()
{
return await _context.SaveChangesAsync();
}
public void Dispose()
{
_context.Dispose();
}
} |
|
Такой подход позволяет работать с несколькими репозиториями в рамках одной транзакции, что критически важно для сохранения целостности данных. Регистрация UnitOfWork в DI-контейнере выглядит так:
| C# | 1
| services.AddScoped<IUnitOfWork, UnitOfWork>(); |
|
А использование в сервисе:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| public class UserService : IUserService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public UserService(IUnitOfWork unitOfWork, IMapper mapper)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
}
public void CreateUser(CreateUserDto userDto)
{
// Проверка уникальности email
if (_unitOfWork.Users.GetByEmail(userDto.Email) != null)
throw new ApplicationException("User with this email already exists");
var user = _mapper.Map<User>(userDto);
// Создание профиля пользователя
var profile = new UserProfile
{
FirstName = userDto.FirstName,
LastName = userDto.LastName,
Address = userDto.Address,
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
};
user.SetProfile(profile);
// Добавление и сохранение в одной транзакции
_unitOfWork.Users.Add(user);
_unitOfWork.SaveChanges();
}
// Другие методы...
} |
|
На одном из проектов я долго спорил с коллегами о необходимости Unit of Work. Они утверждали, что DbContext уже реализует этот паттерн, и дополнительная абстракция излишня. Я же настаивал, что UoW позволяет лучше контролировать границы транзакций и изолировать домен от деталей реализации. В итоге мы пришли к компромису - использовали UoW только там, где требовалась работа с несколькими репозиториями одновременно.
Service Registration и жизненный цикл объектов
Регистрация сервисов в DI-контейнере - это не просто техническая процедура. Это стратегическое решение, которое влияет на производительность, потребление памяти и поведение приложения при конкурентных запросах. Я выработал для себя следующие рекомендации:
1. Stateless-сервисы регистрирую как Transient. Они не хранят состояния и могут быть созданы заново для каждого запроса:
| C# | 1
| services.AddTransient<IEmailValidator, EmailValidator>(); |
|
2. Сервисы, работающие с базой данных, регистрирую как Scoped, чтобы они использовали один и тот же DbContext в рамках HTTP-запроса:
| C# | 1
| services.AddScoped<IUserService, UserService>(); |
|
3. Ресурсоемкие объекты или объекты с общим состоянием регистрирую как Singleton:
| C# | 1
| services.AddSingleton<ICacheManager, MemoryCacheManager>(); |
|
4. Фабрики объектов использую, когда решение о создании конкретной реализации принимается во время выполнения:
| C# | 1
2
3
4
5
6
7
8
9
| services.AddTransient<Func<PaymentType, IPaymentProcessor>>(serviceProvider => paymentType =>
{
return paymentType switch
{
PaymentType.CreditCard => serviceProvider.GetService<ICreditCardProcessor>(),
PaymentType.PayPal => serviceProvider.GetService<IPayPalProcessor>(),
_ => throw new NotSupportedException($"Payment type {paymentType} not supported")
};
}); |
|
Отдельно хочу отметить важность управления ресурсами. Например, объекты, реализующие IDisposable, должны быть правильно утилизированы. ASP.NET Core автоматически вызывает Dispose для объектов, созданных контейнером, но только для тех, которые были зарегистрированы. Если вы создаете объекты вручную с помощью new, то отвечаете за их утилизацию сами. Я несколько раз сталкивался с утечками памяти из-за неосвобожденных ресурсов, особенно при работе с файлами и сетевыми соединениями.
Настройка контейнера зависимостей для сложных сценариев
Встроенный DI-контейнер в ASP.NET Core отлично справляется с большинством задач, но иногда требуется нечто большее. Например, регистрация множественных реализаций одного интерфейса или условная регистрация зависимостей. Для таких случаев я использую более мощные контейнеры, такие как Autofac или StructureMap. Вот пример интеграции Autofac с ASP.NET Core:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| public void ConfigureServices(IServiceCollection services)
{
// Базовые сервисы ASP.NET Core
services.AddControllersWithViews();
// Не вызываем BuildServiceProvider здесь!
}
public void ConfigureContainer(ContainerBuilder builder)
{
// Регистрация репозиториев
builder.RegisterGeneric(typeof(Repository<>))
.As(typeof(IRepository<>))
.InstancePerLifetimeScope();
// Регистрация всех реализаций интерфейса
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.Where(t => t.Name.EndsWith("Repository"))
.AsImplementedInterfaces()
.InstancePerLifetimeScope();
// Регистрация сервисов
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.Where(t => t.Name.EndsWith("Service"))
.AsImplementedInterfaces()
.InstancePerLifetimeScope();
// Условная регистрация
builder.Register<IPaymentProcessor>(c =>
{
var env = c.Resolve<IHostEnvironment>();
if (env.IsDevelopment())
return new MockPaymentProcessor();
else
return new RealPaymentProcessor(c.Resolve<IConfiguration>());
}).InstancePerLifetimeScope();
// Декораторы
builder.RegisterType<UserRepository>().As<IUserRepository>();
builder.RegisterDecorator<CachingUserRepository, IUserRepository>();
} |
|
Такой подход позволяет реализовать более сложные сценарии внедрения зависимостей, например:
1. Декораторы - оборачивают существующую реализацию, добавляя новое поведение.
2. Множественные реализации - когда несколько классов реализуют один интерфейс.
3. Условная регистрация - выбор реализации в зависимости от условий.
4. Автоматическая регистрация - поиск и регистрация классов по соглашениям.
На одном из проектов мне потребовалось реализовать аудит всех операций с данными. Я не хотел засорять основной код логикой аудита, поэтому применил паттерн Декоратор:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| public class AuditingRepository<T> : IRepository<T> where T : BaseEntity
{
private readonly IRepository<T> _repository;
private readonly IAuditLogger _auditLogger;
public AuditingRepository(IRepository<T> repository, IAuditLogger auditLogger)
{
_repository = repository;
_auditLogger = auditLogger;
}
public void Add(T entity)
{
_repository.Add(entity);
_auditLogger.LogAddition(typeof(T).Name, entity.Id);
}
public void Update(T entity)
{
_repository.Update(entity);
_auditLogger.LogUpdate(typeof(T).Name, entity.Id);
}
// Остальные методы...
} |
|
Декоратор - это лишь одна из многих техник, которые можно применять в луковой архитектуре. Еще одна мощная комбинация паттернов, которую я активно использую в последние годы - это CQRS и Mediator.
Command Query Responsibility Segregation (CQRS) в связке с Mediator
CQRS - один из тех паттернов, который поначалу казался мне чрезмерно сложным. "Зачем разделять операции чтения и записи, когда можно использовать один репозиторий?" - думал я. Но после работы над высоконагруженным сервисом обработки платежей, я понял его ценность. Суть CQRS проста - мы разделяем модель приложения на две части:- Command (команды) - для изменения состояния системы,
- Query (запросы) - для чтения данных без изменений.
Такое разделение дает массу преимуществ:
1. Можно оптимизировать чтение и запись независимо друг от друга.
2. Модели чтения можно денормализовать для лучшей производительности.
3. Команды можно ставить в очередь и обрабатывать асинхронно.
4. Запросы можно кешировать, не беспокоясь о потере актуальности данных.
Я использую CQRS вместе с паттерном Mediator, который действует как посредник между компонентами системы. Мой любимый инструмент для этого - библиотека MediatR. Вот как выглядит типичная команда в моей реализации:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| public class CreateUserCommand : IRequest<long>
{
public string UserName { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Address { get; set; }
}
public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, long>
{
private readonly IUserRepository _userRepository;
private readonly IPasswordHasher _passwordHasher;
public CreateUserCommandHandler(IUserRepository userRepository, IPasswordHasher passwordHasher)
{
_userRepository = userRepository;
_passwordHasher = passwordHasher;
}
public async Task<long> Handle(CreateUserCommand request, CancellationToken cancellationToken)
{
// Проверка уникальности email
if (await _userRepository.GetByEmailAsync(request.Email) != null)
throw new ApplicationException("User with this email already exists");
// Создание пользователя
var user = new User(
request.UserName,
request.Email,
_passwordHasher.HashPassword(request.Password)
);
// Создание профиля
var profile = new UserProfile
{
FirstName = request.FirstName,
LastName = request.LastName,
Address = request.Address,
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
};
user.SetProfile(profile);
// Сохранение в БД
await _userRepository.AddAsync(user);
await _userRepository.SaveChangesAsync();
return user.Id;
}
} |
|
А вот как выглядит запрос:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| public class GetUserByIdQuery : IRequest<UserDto>
{
public long Id { get; set; }
}
public class GetUserByIdQueryHandler : IRequestHandler<GetUserByIdQuery, UserDto>
{
private readonly IUserRepository _userRepository;
private readonly IMapper _mapper;
public GetUserByIdQueryHandler(IUserRepository userRepository, IMapper mapper)
{
_userRepository = userRepository;
_mapper = mapper;
}
public async Task<UserDto> Handle(GetUserByIdQuery request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.Id);
return user == null ? null : _mapper.Map<UserDto>(user);
}
} |
|
Использование Mediator Pattern для развязки компонентов
Паттерн Mediator (Посредник) - один из ключевых инструментов в моем архитектурном арсенале. Он помогает избавиться от прямых зависимостей между компонентами, заменяя их единой точкой взаимодействия. Вместо того чтобы контроллер вызывал сервис напрямую, он отправляет команду или запрос через посредника:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| public class UserController : Controller
{
private readonly IMediator _mediator;
public UserController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("{id}")]
public async Task<ActionResult<UserDto>> GetUser(long id)
{
var query = new GetUserByIdQuery { Id = id };
var result = await _mediator.Send(query);
if (result == null)
return NotFound();
return Ok(result);
}
[HttpPost]
public async Task<ActionResult<long>> CreateUser(CreateUserCommand command)
{
try
{
var userId = await _mediator.Send(command);
return CreatedAtAction(nameof(GetUser), new { id = userId }, null);
}
catch (ApplicationException ex)
{
return BadRequest(ex.Message);
}
}
} |
|
Что дает такой подход? Несколько серьезных преимуществ:
1. Слабая связанность - компоненты не зависят друг от друга напрямую.
2. Единообразие - все взаимодействия проходят через одну точку входа.
3. Расширяемость - легко добавлять новое поведение через обработчики событий.
4. Аспектно-ориентированное программирование - можно добавлять сквозную функциональность (логирование, валидация, кеширование) с помощью Pipeline Behavior.
Последний пункт особенно ценен. Я много раз писал код, в котором повторялись одни и те же операции - валидация входных данных, логирование, обработка ошибок. С MediatR я могу вынести эту логику в отдельные компоненты поведения:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
private readonly IValidator<TRequest> _validator;
public ValidationBehavior(IValidator<TRequest> validator = null)
{
_validator = validator;
}
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
{
if (_validator == null)
return await next();
var validationResult = await _validator.ValidateAsync(request, cancellationToken);
if (validationResult.IsValid)
return await next();
throw new ValidationException(validationResult.Errors);
}
} |
|
Такое поведение можно зарегистрировать в DI-контейнере, и оно будет применяться ко всем запросам автоматически:
| C# | 1
| services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); |
|
В моей практике MediatR настолько хорошо вписался в луковую архитектуру, что я стал применять его практически во всех новых проектах. Он позволяет сделать код более модульным, а приложение - более расширяемым.
Тестирование в луковой архитектуре - моки, стабы и интеграционные тесты
Одно из главных преимуществ луковой архитектуры, которое я прочувствовал на собственной шкуре, - это невероятная простота тестирования. Когда компоненты слабо связаны и взаимодействуют через абстракции, создавать для них тесты становится почти удовольствием, а не мучением. В моей практике тестирование приложений на луковой архитектуре строится вокруг трех китов: модульные тесты с использованием моков, интеграционные тесты и, иногда, сквозные (end-to-end) тесты.
Моки и стабы - это замечательные инструменты, которые помогают изолировать тестируемый компонент от его зависимостей. В чем разница? Стаб (заглушка) просто возвращает заранее определенные результаты, а мок (имитация) еще и проверяет, как тестируемый код взаимодействует с зависимостью. Вот пример модульного теста для сервиса пользователей с использованием Moq:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| public class UserServiceTests
{
private readonly Mock<IUserRepository> _mockUserRepository;
private readonly Mock<IMapper> _mockMapper;
private readonly UserService _userService;
public UserServiceTests()
{
_mockUserRepository = new Mock<IUserRepository>();
_mockMapper = new Mock<IMapper>();
_userService = new UserService(_mockUserRepository.Object, _mockMapper.Object);
}
[Fact]
public void GetUserById_WhenUserExists_ReturnsUserDto()
{
// Arrange
var userId = 1L;
var user = new User("testuser", "test@example.com", "hash");
var userDto = new UserDto { Id = userId, UserName = "testuser", Email = "test@example.com" };
_mockUserRepository.Setup(r => r.GetById(userId)).Returns(user);
_mockMapper.Setup(m => m.Map<UserDto>(user)).Returns(userDto);
// Act
var result = _userService.GetUserById(userId);
// Assert
Assert.NotNull(result);
Assert.Equal(userDto.UserName, result.UserName);
// Verify repository was called correctly
_mockUserRepository.Verify(r => r.GetById(userId), Times.Once);
}
} |
|
Интеграционные тесты идут дальше и проверяют, как компоненты работают вместе. Я не большой фанат громоздких тестов с реальной базой данных, но иногда без них не обойтись. В ASP.NET Core есть удобные инструменты для интеграционого тестирования:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| public class UserControllerIntegrationTests : IClassFixture<WebApplicationFactory<Startup>>
{
private readonly WebApplicationFactory<Startup> _factory;
public UserControllerIntegrationTests(WebApplicationFactory<Startup> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Заменяем настоящий DbContext на тестовый с In-Memory базой
services.RemoveAll(typeof(ApplicationDbContext));
services.AddDbContext<ApplicationDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
// Инициализируем тестовые данные
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// Добавление тестовых пользователей...
});
});
}
[Fact]
public async Task GetUser_ReturnsSuccessAndCorrectContentType()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/api/users/1");
// Assert
response.EnsureSuccessStatusCode();
Assert.Equal("application/json; charset=utf-8",
response.Content.Headers.ContentType.ToString());
}
} |
|
Ценность моков и интеграционых тестов особенно заметна, когда вы начинаете рефакторить код. В одном проекте мы полностью заменили слой доступа к данных с Entity Framework на Dapper, и благодаря хорошо протестированным сервисам не сломали ни одной функции! Тесты сразу указывали, где мы что-то неправильно реализовали.
Еще один прием, который я часто использую, - это тестирование с помощью поддельных реализаций (фейков):
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public class InMemoryUserRepository : IUserRepository
{
private readonly List<User> _users = new List<User>();
private long _nextId = 1;
public User GetById(long id)
{
return _users.FirstOrDefault(u => u.Id == id);
}
public void Add(User user)
{
user.Id = _nextId++;
_users.Add(user);
}
// Другие методы...
} |
|
Проблемы и подводные камни
Луковая архитектура по-настоящему прекрасна, когда ты видишь, как чистый, модульный код начинает работать в сложной системе. Но, признаюсь честно, не все так радужно. За годы внедрения этой архитектуры я натыкался на грабли так часто, что мог бы написать целую книгу "Как не надо делать". Поделюсь самыми болезненными моментами, которые встречаются чаще всего.
Избыточная сложность для простых проектов
Помню свой первый опыт внедрения луковой архитектуры - простое приложение для управления задачами. Я разбил его на пять проектов, создал десятки интерфейсов, настроил DI-контейнер... А приложение-то было с тремя сущностями и парой отчетов. В итоге код разросся до такой степени, что новичку в команде потребовалась неделя, чтобы просто разобраться, где что лежит. Вывод, который я сделал: не используйте тяжелую артиллерию для стрельбы по воробьям. Для маленьких проектов с 3-5 сущностями и простой бизнес-логикой луковая архитектура может быть избыточной. Стоимость ее внедрения и поддержки может превысить выгоды.
Признаки того, что вы переборщили с архитектурой:- На создание нового функционала уходит в 2-3 раза больше времени из-за необходимости поддерживать все слои.
- Большая часть интерфейсов имеет только одну реализацию, и никогда не будет иметь другую.
- Разработчики путаются в структуре проекта и не понимают, куда помещать новый код.
Альтернатива? Для маленьких проектов я теперь использую упрощенный вариант - три проекта (Domain, Infrastructure, Web) вместо пяти-шести, меньше абстракций, более прямые зависимости там, где они вряд ли изменятся.
Производительность и практические ограничения
Еще одна проблема, с которой я столкнулся - накладные расходы на производительность. Каждый дополнительный слой абстракции имеет свою цену. Например, при использовании AutoMapper для преобразования между доменными объектами и DTO, при каскадных зависимостях через несколько слоев, при создании цепочек объектов через DI-контейнер. На одном из проектов мы обнаружили, что простой запрос на получение списка сущностей проходит через семь слоев преобразований и вызовов, прежде чем вернуть данные клиенту! Это привело к заметному падению производительности при больших объемах данных.
Как избежать? Я выработал несколько правил:
1. Не злоупотребляйте маппингом - иногда прямые запросы в базу данных с проекцией сразу в DTO эффективнее.
2. Используйте кеширование на уровне запросов (Query Cache).
3. В критичных по производительности местах допустимо нарушить строгое разделение слоев.
Еще одно ограничение - сложность отладки. Когда запрос проходит через множество слоев, выяснить, где именно произошла ошибка, становится нетривиальной задачей. Хорошие логи и мониторинг - ваши лучшие друзья в такой ситуации.
Альтернативы Onion Architecture
Clean Architecture от Роберта Мартина очень похожа на луковую, но с некоторыми отличиями. Она делает больший акцент на Use Cases (сценарии использования) и четче разделяет слои. Я предпочитаю Clean Architecture, когда проект имеет сложную бизнес-логику и множество сценариев использования.
Hexagonal Architecture (или Ports and Adapters) фокусируется на бизнес-логике как центральном компоненте, к которому подключаются внешние системы через порты и адаптеры. Она особенно хороша для систем с множеством интеграций с внешними сервисами.
Vertical Slice Architecture - подход, который в последнее время набирает популярность. Вместо горизонтальных слоев (контроллеры, сервисы, репозитории) код организован по функциям (регистрация пользователя, обработка заказа и т.д.). Каждая "вертикальная" функция содержит все необходимые компоненты от контроллера до доступа к данным. Это делает код более сфокусированным и уменьшает связность между функциями.
В одном из недавних проектов я использовал гибридный подход - луковая архитектура на макроуровне, но с элементами Vertical Slice для отдельных функциональных областей. Это оказалось очень удачным решением - мы получили преимущества изоляции слоев, но избежали избыточной сложности в местах, где она не нужна.
Полный пример приложения
Перейдем от теории к практике. Я собрал тут полноценный пример приложения с луковой архитектурой, который можно взять за основу для своих проектов. Никаких надуманных примеров - только рабочий код, проверенный в боевых условиях.
Для демонстрации я выбрал простое, но показательное приложение для управления пользователями. Оно позволяет создавать, редактировать, просматривать и удалять пользователей с их профилями - классический CRUD, но с применением всех принципов луковой архитектуры. Структура решения выглядит так:
| C# | 1
2
3
4
5
6
7
8
9
10
| OnionArchitecture.sln
├── src
│ ├── OnionArchitecture.Domain
│ ├── OnionArchitecture.Application
│ ├── OnionArchitecture.Infrastructure
│ └── OnionArchitecture.Web
└── tests
├── OnionArchitecture.Domain.Tests
├── OnionArchitecture.Application.Tests
└── OnionArchitecture.Infrastructure.Tests |
|
Начнем с ядра нашего приложения - доменных сущностей:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| // OnionArchitecture.Domain/Entities/User.cs
public class User : BaseEntity
{
private User() { } // Для EF Core
public User(string username, string email, string passwordHash)
{
Username = username ?? throw new ArgumentNullException(nameof(username));
Email = email ?? throw new ArgumentNullException(nameof(email));
PasswordHash = passwordHash ?? throw new ArgumentNullException(nameof(passwordHash));
CreatedAt = DateTime.UtcNow;
}
public string Username { get; private set; }
public string Email { get; private set; }
public string PasswordHash { get; private set; }
public UserProfile Profile { get; private set; }
public void UpdateEmail(string email)
{
Email = email ?? throw new ArgumentNullException(nameof(email));
ModifiedAt = DateTime.UtcNow;
}
public void SetProfile(UserProfile profile)
{
Profile = profile ?? throw new ArgumentNullException(nameof(profile));
ModifiedAt = DateTime.UtcNow;
}
} |
|
Теперь интерфейс репозитория в доменном слое:
| C# | 1
2
3
4
5
6
7
8
9
10
11
| // OnionArchitecture.Domain/Repositories/IUserRepository.cs
public interface IUserRepository
{
Task<User> GetByIdAsync(int id);
Task<User> GetByEmailAsync(string email);
Task<IEnumerable<User>> GetAllAsync();
Task AddAsync(User user);
void Update(User user);
void Remove(User user);
Task<int> SaveChangesAsync();
} |
|
В слое приложения определим модели DTO и сервисы:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // OnionArchitecture.Application/DTOs/UserDto.cs
public class UserDto
{
public int Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime CreatedAt { get; set; }
}
// OnionArchitecture.Application/Services/IUserService.cs
public interface IUserService
{
Task<UserDto> GetByIdAsync(int id);
Task<IEnumerable<UserDto>> GetAllAsync();
Task<int> CreateAsync(CreateUserDto createUserDto);
Task UpdateAsync(UpdateUserDto updateUserDto);
Task DeleteAsync(int id);
} |
|
Реализация сервиса в том же слое:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| // OnionArchitecture.Application/Services/UserService.cs
public class UserService : IUserService
{
private readonly IUserRepository _userRepository;
private readonly IMapper _mapper;
private readonly IPasswordHasher _passwordHasher;
public UserService(IUserRepository userRepository, IMapper mapper, IPasswordHasher passwordHasher)
{
_userRepository = userRepository;
_mapper = mapper;
_passwordHasher = passwordHasher;
}
public async Task<UserDto> GetByIdAsync(int id)
{
var user = await _userRepository.GetByIdAsync(id);
return user != null ? _mapper.Map<UserDto>(user) : null;
}
public async Task<int> CreateAsync(CreateUserDto createUserDto)
{
// Проверка уникальности email
if (await _userRepository.GetByEmailAsync(createUserDto.Email) != null)
throw new ApplicationException("Email already exists");
// Создаем пользователя
var passwordHash = _passwordHasher.HashPassword(createUserDto.Password);
var user = new User(createUserDto.Username, createUserDto.Email, passwordHash);
// Создаем профиль
var profile = new UserProfile(createUserDto.FirstName, createUserDto.LastName);
user.SetProfile(profile);
// Сохраняем
await _userRepository.AddAsync(user);
await _userRepository.SaveChangesAsync();
return user.Id;
}
// Остальные методы...
} |
|
В инфраструктурном слое реализуем репозитории:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| // OnionArchitecture.Infrastructure/Data/ApplicationDbContext.cs
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) {}
public DbSet<User> Users { get; set; }
public DbSet<UserProfile> UserProfiles { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Конфигурация моделей...
}
}
// OnionArchitecture.Infrastructure/Repositories/UserRepository.cs
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
public UserRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<User> GetByIdAsync(int id)
{
return await _context.Users
.Include(u => u.Profile)
.FirstOrDefaultAsync(u => u.Id == id);
}
// Остальные методы...
} |
|
Наконец, контроллер в веб-слое:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| // OnionArchitecture.Web/Controllers/UsersController.cs
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
public UsersController(IUserService userService)
{
_userService = userService;
}
[HttpGet("{id}")]
public async Task<ActionResult<UserDto>> GetUser(int id)
{
var user = await _userService.GetByIdAsync(id);
if (user == null)
return NotFound();
return user;
}
[HttpPost]
public async Task<ActionResult<int>> CreateUser(CreateUserDto createUserDto)
{
try
{
var userId = await _userService.CreateAsync(createUserDto);
return CreatedAtAction(nameof(GetUser), new { id = userId }, null);
}
catch (ApplicationException ex)
{
return BadRequest(ex.Message);
}
}
// Остальные методы...
} |
|
Не забываем настроить DI в Startup.cs:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public void ConfigureServices(IServiceCollection services)
{
// DbContext
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
// Репозитории
services.AddScoped<IUserRepository, UserRepository>();
// Сервисы
services.AddScoped<IUserService, UserService>();
services.AddScoped<IPasswordHasher, BCryptPasswordHasher>();
// AutoMapper
services.AddAutoMapper(typeof(MappingProfile));
services.AddControllers();
} |
|
Такая структура дает массу преимуществ - чистое разделение ответственности, простоту тестирования, гибкость при замене компонентов. Однажды мне пришлось срочно перевести подобное приложение с SQL Server на PostgreSQL. Благодаря луковой архитектуре изменения коснулись только инфраструктурного слоя, а бизнес-логика осталась нетронутой. Что ещё круто в таком подходе - новые разработчики быстро понимают, где что искать. Хочешь разобраться в бизнес-правилах? Смотри в Domain. Нужно добавить новый API-метод? Web-слой ждет тебя. Проблемы с базой данных? Копай в Infrastructure.
Заключение
Луковая архитектура – это не просто модный архитектурный шаблон, а полноценное решение для организации сложных приложений на ASP.NET Core. За годы работы с различными проектами я убедился, что умение правильно выбрать и применить архитектуру часто определяет успех проекта сильнее, чем конкретные технологии или фреймворки.
Когда стоит применять луковую архитектуру? Я бы выделил следующие случаи:
1. Средние и крупные корпоративные приложения с богатой бизнес-логикой.
2. Системы, которые будут развиваться и поддерживаться длительное время.
3. Проекты с чёткими доменными границами и моделями.
4. Приложения, где важна тестируемость и возможность легкой замены компонентов.
При этом не стоит забывать и об ограничениях. В простых CRUD-приложениях с минимальной бизнес-логикой луковая архитектура может оказаться избыточной. Лучше оценивать ситуацию здраво – иногда стандартная MVC-структура с сервисным слоем вполне достаточна.
Что действительно важно – это понимание принципов, лежащих в основе. Направление зависимостей всегда внутрь, к домену. Внутрение слои не должны зависеть от внешних. Слабая связанность через интерфейсы. Эти принципы можно применять даже в простых проектах, не создавая десятки проектов и сотни интерфейсов.
ASP.NET MVC или ASP.NET Core Добрый вечер, подскажите что лучшие изучать ASP.NET MVC или ASP.NET Core ? Как я понимаю ASP.NET... ASP.NET Core или ASP.NET MVC Здравствуйте
После изучение основ c# я решил выбрать направление веб разработки. Подскажите какие... Почему скрипт из ASP.NET MVC 5 не работает в ASP.NET Core? В представлении в версии ASP.NET MVC 5 был скрипт:
@model RallyeAnmeldung.Cars
... ASP.NET Core. Старт - что нужно знать, чтобы стать ASP.NET Core разработчиком? Попалось хор краткое обзорное видео 2016 года с таким названием - Что нужно знать, чтобы стать... Стоит ли изучать asp.net mvc 4 из за скорого выхода asn.net mvc vNext ? Доброго вечера!
Как я узнал, Microsoft скоро планирует выпустить новый веб-фреймворк с названием... ASP.NET MVC трехуровневая архитектура Суть заключается в следующем, мне необходимо реализовать проект ASP.NET MVC (задание на... Архитектура приложения ASP.NET MVC 5 + Angular 2 Здравствуйте. К примеру, мы делаем на стороне сервера два контроллера HomeController (клиентская... Стоит ли изучать ASP.NET MVC 4 не зная просто ASP.NET? Стоит ли сразу изучать ASP.NET MVC не зная просто ASP.NET?
И еще вопрос: мне нужно освоить MVC... Перенос с ASP.NET на ASP.NET MVC Доброго времени суток!
Вопрос в следующем: имеются файлы проекта на ASP.NET и действующий проект... ASP.NET или ASP.NET MVC Посоветуйте какую технологию лучше начать изучать ASP.NET или ASP.NET MVC. Не содной ни c другой... Чем отличается ASP.NET от ASP.NET MVC, и что лучше подходит для моего приложения Дорогие знатоки, я прочитал Шилдта C# и WPF Мак-Дональда, но до сих пор я не сильно понимаю чем... ASP.NET и ASP.NET MVC Добрый день, форумчане.
Объясните мне, пожалуйста, простым языком, чем отличаются технологии...
|