Введение: Почему модульность — не роскошь, а необходимость
Модульная архитектура в C# — это не просто модный тренд, а практический подход к созданию масштабируемых, тестируемых и поддерживаемых приложений. Когда ваш проект вырастает за пределы 1000 строк кода, структура становится важнее реализации отдельных функций.
Рассмотрим эволюцию типичного C# проекта:
- Фаза 1: Один файл
Program.csс 200 строками - Фаза 2: Несколько классов в одном проекте
- Фаза 3: Разделение на логические слои
- Фаза 4: Полноценная модульная архитектура с четкими границами
Основные принципы модульного проектирования
1. Принцип единственной ответственности (SRP)
Single Responsibility Principle
Формулировка
«У класса должна быть только одна причина для изменения»
Более точно: «Модуль (класс) должен отвечать за одного и только одного актора» (где «актор» — заинтересованная сторона: пользователь, администратор, бухгалтерия и т.д.)
Суть
Класс должен выполнять строго одну бизнес-задачу или отвечать за одну зону ответственности. Если класс делает слишком много — он становится «божественным» (God object), хрупким и сложным для изменения.
// ❌ ПЛОХО: Класс делает слишком много
public class DataManager
{
public void ReadFromFile(string path) { /* ... */ }
public void ProcessData(string data) { /* ... */ }
public void SaveToDatabase(string data) { /* ... */ }
public void SendEmailReport() { /* ... */ }
}
// ✅ ХОРОШО: Разделение ответственности
public class FileReader { /* ... */ }
public class DataProcessor { /* ... */ }
public class DatabaseRepository { /* ... */ }
public class ReportNotifier { /* ... */ }
2. Инверсия зависимостей (DIP)
Dependency Inversion Principle
Формулировка
- «Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций»
- «Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций»
Суть
Зависимости должны строиться на абстракциях (интерфейсах), а не на конкретных реализациях. Это позволяет легко заменять реализации без изменения высокоуровневого кода.
// ❌ ПЛОХО: Высокоуровневый модуль зависит от низкоуровневых
class OrderService { // ВЫСОКОУРОВНЕВЫЙ
private MySQLDatabase db; // ЗАВИСИМОСТЬ ОТ КОНКРЕТНОЙ РЕАЛИЗАЦИИ
public OrderService() {
this.db = new MySQLDatabase(); // ЖЕСТКАЯ СВЯЗЬ
}
public void saveOrder(Order order) {
db.save(order); // ПРЯМАЯ ЗАВИСИМОСТЬ
}
}
class MySQLDatabase { // НИЗКОУРОВНЕВЫЙ МОДУЛЬ
public void save(Object obj) {
// SQL запросы к MySQL
}
}
// Что если нужно перейти на PostgreSQL? Придется изменять OrderService!
Также важны и остальные принципы SOLID. Но эти два являются основными, ради которых существуют остальные. С остальными принципами SOLID предлагается ознакомиться самостоятельно, эта тема уже разобрана не один десяток лет назад.
Ключевая мысль: Модульность в C# — это не просто разделение на проекты, а проектирование системы, где каждый компонент изолирован, тестируем и заменяем. Для CLI-приложений это особенно важно, так как позволяет постепенно наращивать функциональность и легко поддерживать код.
Практическая реализация модульности в C#: От теории к коду
Уровни модульности в .NET
Модульность в C# реализуется на трёх уровнях, и каждый имеет своё назначение:
1. Уровень решения (Solution) — разделение на отдельные проекты (.csproj)
2. Уровень сборки (Assembly) — публичные и внутренние типы
3. Уровень пространств имён (Namespace) — логическая группировка
Для большинства CLI-приложений оптимален первый уровень: один модуль = один проект.
MyApp.sln
├── MyApp.CLI/ # Точка входа, UI
├── MyApp.Core/ # Бизнес-логика, интерфейсы
├── MyApp.Infrastructure/ # Работа с БД, внешние сервисы
└── MyApp.Shared/ # Общие модели, утилиты
Знакомство с проектом: TaskManager CLI
Прежде чем погрузиться в технические детали, давайте создадим конкретный контекст — приложение, на примере которого мы будем изучать модульность. Это поможет увидеть, как абстрактные принципы превращаются в работающий код.
Представьте, что мы разрабатываем консольное приложение для управления задачами — TaskManager CLI. Утилита должна позволять:
- Создавать задачи с заголовком, описанием, сроком и исполнителем
- Назначать задачи на пользователей
- Менять статус задачи (новая, в работе, выполнена)
- Отправлять уведомления при изменении статуса (email, Telegram)
- Формировать отчёты по задачам за период
- Работать с разными хранилищами данных: файлы JSON, база данных SQLite, а в будущем — PostgreSQL
На первый взгляд, ничего сложного. Но давайте представим, как выглядел бы типичный монолитный код, если бы мы не задумывались о модульности.
Монолитная версия (чего мы хотим избежать)
// ❌ Классический монолит — всё в одном проекте, всё жёстко связано
public class TaskService
{
private readonly string _connectionString = "Data Source=tasks.db";
public void CreateTask(string title, string assignee)
{
// 1. Сохраняем задачу в SQLite
using var connection = new SQLiteConnection(_connectionString);
connection.Execute("INSERT INTO Tasks...");
// 2. Отправляем email уведомление
var smtp = new SmtpClient("smtp.gmail.com");
smtp.Send(new MailMessage("admin@taskmanager", assignee, "New task", title));
// 3. Пишем лог в файл
File.AppendAllText("log.txt", $"Task created: {title}\n");
}
public List<Task> GetTasksForReport(DateTime from, DateTime to)
{
// Прямой запрос к БД — логика отчёта смешана с доступом к данным
using var connection = new SQLiteConnection(_connectionString);
return connection.Query<Task>("SELECT * FROM Tasks WHERE CreatedAt BETWEEN @from AND @to", new { from, to }).ToList();
}
}
Этот класс нарушает всё, что только можно:
- SRP: он отвечает и за работу с БД, и за отправку писем, и за логирование, и за формирование отчётов.
- DIP: высокоуровневая логика создания задачи жёстко зависит от конкретных реализаций низкого уровня (SQLite, SmtpClient, File).
- Тестирование: невозможно протестировать
CreateTaskбез реальной базы данных, почтового сервера и файловой системы. - Расширяемость: хотим заменить SQLite на PostgreSQL — придётся переписывать весь класс. Добавить Telegram-уведомления — снова правки в том же классе.
Почему нам нужна модульность?
Изначально приложение может быть небольшим, но как только появляются новые требования — разные способы хранения, разные каналы уведомлений, необходимость покрытия тестами — монолит начинает тормозить разработку. Каждое изменение требует проверки всего приложения, возрастает риск сломать существующую функциональность.
Модульная архитектура позволит нам:
- Изолировать ответственности — каждый модуль занимается только своим делом.
- Легко заменять реализации — переключение с SQLite на PostgreSQL будет требовать изменений только в одном месте (в Composition Root).
- Тестировать логику изолированно — мы сможем подменять внешние зависимости моками.
- Параллельно разрабатывать — разные команды могут работать над модулями хранения и уведомлений, не мешая друг другу.
План действий
Мы спроектируем TaskManager CLI, следуя принципам модульности с самого начала. В результате получим структуру, где:
- Core — содержит бизнес-сущности, интерфейсы и сервисы приложения (независим от всего).
- Infrastructure — реализует эти интерфейсы для конкретных технологий (SQLite, Smtp, Telegram).
- CLI — точка входа, которая собирает всё вместе.
- Shared — вспомогательные утилиты, общие для всех модулей.
Далее на шаге 1 мы начнём с проектирования границ модулей и создания абстракций. Но прежде, чем перейти к коду, зафиксируем требования к приложению в виде пользовательских сценариев. Это поможет нам понять, какие модули понадобятся.
Почему нам нужна модульность?
Изначально приложение может быть небольшим, но как только появляются новые требования — разные способы хранения, разные каналы уведомлений, необходимость покрытия тестами — монолит начинает тормозить разработку. Каждое изменение требует проверки всего приложения, возрастает риск сломать существующую функциональность.
Модульная архитектура позволит нам:
- Изолировать ответственности — каждый модуль занимается только своим делом.
- Легко заменять реализации — переключение с SQLite на PostgreSQL будет требовать изменений только в одном месте (в Composition Root).
- Тестировать логику изолированно — мы сможем подменять внешние зависимости моками.
- Параллельно разрабатывать — разные команды могут работать над модулями хранения и уведомлений, не мешая друг другу.
План действий
Мы спроектируем TaskManager CLI, следуя принципам модульности с самого начала. В результате получим структуру, где:
- Core — содержит бизнес-сущности, интерфейсы и сервисы приложения (независим от всего).
- Infrastructure — реализует эти интерфейсы для конкретных технологий (SQLite, Smtp, Telegram).
- CLI — точка входа, которая собирает всё вместе.
- Shared — вспомогательные утилиты, общие для всех модулей.
Функциональные требования (первые версии)
- Управление задачами: создание, просмотр, изменение статуса, назначение исполнителя.
- Управление пользователями: регистрация, просмотр списка.
- Уведомления: при создании задачи и изменении её статуса отправлять уведомление исполнителю по email.
- Хранение данных: задачи и пользователи хранятся в SQLite.
- Отчёты: выводить в консоль количество задач по статусам за последнюю неделю.
Нефункциональные требования
- Легко переключиться на другое хранилище (например, JSON-файлы или PostgreSQL).
- Легко добавить новый канал уведомлений (Telegram).
- Код должен быть покрыт юнит-тестами (логика сервисов, без реального доступа к БД и внешним API).
- Приложение должно запускаться из командной строки с простыми командами:
task create "Fix bug" --assignee john,task list,task complete 42.
Теперь, когда контекст понятен, переходим к первому шагу — проектированию границ через абстракции. Именно здесь мы заложим фундамент, который позволит нашему приложению оставаться гибким и тестируемым по мере роста.
Шаг 1. Проектируем границы через абстракции
Критическое правило: модули должны зависеть только от интерфейсов, а не от конкретных классов соседей.
Создаём Core-модуль
// MyApp.Core/Interfaces/IUserRepository.cs
namespace MyApp.Core.Interfaces;
public interface IUserRepository
{
Task<User?> GetByIdAsync(Guid id);
Task AddAsync(User user);
}
// MyApp.Core/Entities/User.cs
namespace MyApp.Core.Entities;
public class User
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public string Email { get; private set; }
// Бизнес-логика внутри сущности
public void UpdateEmail(string newEmail)
{
if (string.IsNullOrWhiteSpace(newEmail))
throw new ArgumentException("Email cannot be empty");
Email = newEmail;
}
}
// MyApp.Core/Services/UserService.cs
namespace MyApp.Core.Services;
public class UserService
{
private readonly IUserRepository _userRepository;
// ❌ НЕПРАВИЛЬНО: Зависимость от конкретной реализации
// public UserService() => _userRepository = new SqlUserRepository();
// ✅ ПРАВИЛЬНО: Зависимость от абстракции
public UserService(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task RegisterUserAsync(string name, string email)
{
var user = new User(name, email);
await _userRepository.AddAsync(user);
// ... логика регистрации
}
}
Шаг 2. Реализуем инфраструктурные модули
Модуль Infrastructure ничего не знает о CLI-проекте, но знает Core.
// MyApp.Infrastructure/Repositories/SqlUserRepository.cs
using MyApp.Core.Entities;
using MyApp.Core.Interfaces;
namespace MyApp.Infrastructure.Repositories;
public class SqlUserRepository : IUserRepository
{
private readonly AppDbContext _context;
public SqlUserRepository(AppDbContext context)
{
_context = context;
}
public async Task<User?> GetByIdAsync(Guid id) =>
await _context.Users.FindAsync(id);
public async Task AddAsync(User user)
{
await _context.Users.AddAsync(user);
await _context.SaveChangesAsync();
}
}
Обратите внимание: SqlUserRepository находится в Infrastructure, но реализует интерфейс из Core.
Направление зависимости → внутрь Core, а не наружу.
[CLI] → [Core] ← [Infrastructure]
↓
[Shared] (нейтральная территория)
Шаг 3. Композиция корня (Composition Root)
Главный секрет модульности: ни один класс не создаёт свои зависимости. Этим занимается только Composition Root — обычно это точка входа приложения.
// MyApp.CLI/Program.cs
using MyApp.Core.Interfaces;
using MyApp.Core.Services;
using MyApp.Infrastructure.Data;
using MyApp.Infrastructure.Repositories;
// Композиция корня — единственное место, где мы связываем конкретные реализации
var connectionString = "Server=...";
var context = new AppDbContext(connectionString);
IUserRepository userRepository = new SqlUserRepository(context);
var userService = new UserService(userRepository);
// Используем
await userService.RegisterUserAsync("Alice", "alice@example.com");
Важно: CLI-проект (точка входа) имеет ссылки на Core и Infrastructure.
Core не имеет ссылки на Infrastructure. Это позволяет Infrastructure быть заменяемой.
Шаг 4. Используем DI-контейнер
Для больших CLI-приложений ручное связывание утомительно. Используем Microsoft.Extensions.DependencyInjection:
// MyApp.CLI/Program.cs
using Microsoft.Extensions.DependencyInjection;
using MyApp.Core.Interfaces;
using MyApp.Core.Services;
using MyApp.Infrastructure.Repositories;
var services = new ServiceCollection();
// Регистрируем зависимости
services.AddDbContext<AppDbContext>();
services.AddScoped<IUserRepository, SqlUserRepository>();
services.AddScoped<UserService>();
var serviceProvider = services.BuildServiceProvider();
// Получаем готовый объект с внедрёнными зависимостями
var userService = serviceProvider.GetRequiredService<UserService>();
Вывод: Модульность как дисциплина
Модульность — это не набор инструментов, а система ограничений, которые разработчик добровольно на себя принимает:
- Никаких
new()для зависимостей — только через конструктор - Никаких
staticдля сервисов — только интерфейсы и внедрение - Никаких ссылок на инфраструктурные проекты в Core — только абстракции
- Никакой бизнес-логики в конструкторах — только присваивание полей
Для CLI-приложений на C# это окупается с первых 2000 строк кода:
- Можно переключаться между SQLite, PostgreSQL и JSON-файлами без изменения логики
- Команды пишутся и тестируются изолированно
- Приложение растёт горизонтально — новые модули добавляются, а старые не ломаются
Модульность в C# — это архитектурная гигиена. Она не требует фреймворков или дорогих инструментов. Только discipline и правильное разделение ответственности.