📄

Построение модульных проектов на C#: от монолита к гибкой архитектуре

14.02.2026 CSharp

Введение: Почему модульность — не роскошь, а необходимость

Модульная архитектура в 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

Формулировка

  1. «Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций»
  2. «Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций»

Суть

Зависимости должны строиться на абстракциях (интерфейсах), а не на конкретных реализациях. Это позволяет легко заменять реализации без изменения высокоуровневого кода.

// ❌ ПЛОХО: Высокоуровневый модуль зависит от низкоуровневых
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-уведомления — снова правки в том же классе.

Почему нам нужна модульность?

Изначально приложение может быть небольшим, но как только появляются новые требования — разные способы хранения, разные каналы уведомлений, необходимость покрытия тестами — монолит начинает тормозить разработку. Каждое изменение требует проверки всего приложения, возрастает риск сломать существующую функциональность.

Модульная архитектура позволит нам:

  1. Изолировать ответственности — каждый модуль занимается только своим делом.
  2. Легко заменять реализации — переключение с SQLite на PostgreSQL будет требовать изменений только в одном месте (в Composition Root).
  3. Тестировать логику изолированно — мы сможем подменять внешние зависимости моками.
  4. Параллельно разрабатывать — разные команды могут работать над модулями хранения и уведомлений, не мешая друг другу.

План действий

Мы спроектируем TaskManager CLI, следуя принципам модульности с самого начала. В результате получим структуру, где:

  • Core — содержит бизнес-сущности, интерфейсы и сервисы приложения (независим от всего).
  • Infrastructure — реализует эти интерфейсы для конкретных технологий (SQLite, Smtp, Telegram).
  • CLI — точка входа, которая собирает всё вместе.
  • Shared — вспомогательные утилиты, общие для всех модулей.

Далее на шаге 1 мы начнём с проектирования границ модулей и создания абстракций. Но прежде, чем перейти к коду, зафиксируем требования к приложению в виде пользовательских сценариев. Это поможет нам понять, какие модули понадобятся.

Почему нам нужна модульность?

Изначально приложение может быть небольшим, но как только появляются новые требования — разные способы хранения, разные каналы уведомлений, необходимость покрытия тестами — монолит начинает тормозить разработку. Каждое изменение требует проверки всего приложения, возрастает риск сломать существующую функциональность.

Модульная архитектура позволит нам:

  1. Изолировать ответственности — каждый модуль занимается только своим делом.
  2. Легко заменять реализации — переключение с SQLite на PostgreSQL будет требовать изменений только в одном месте (в Composition Root).
  3. Тестировать логику изолированно — мы сможем подменять внешние зависимости моками.
  4. Параллельно разрабатывать — разные команды могут работать над модулями хранения и уведомлений, не мешая друг другу.

План действий

Мы спроектируем TaskManager CLI, следуя принципам модульности с самого начала. В результате получим структуру, где:

  • Core — содержит бизнес-сущности, интерфейсы и сервисы приложения (независим от всего).
  • Infrastructure — реализует эти интерфейсы для конкретных технологий (SQLite, Smtp, Telegram).
  • CLI — точка входа, которая собирает всё вместе.
  • Shared — вспомогательные утилиты, общие для всех модулей.

Функциональные требования (первые версии)

  • Управление задачами: создание, просмотр, изменение статуса, назначение исполнителя.
  • Управление пользователями: регистрация, просмотр списка.
  • Уведомления: при создании задачи и изменении её статуса отправлять уведомление исполнителю по email.
  • Хранение данных: задачи и пользователи хранятся в SQLite.
  • Отчёты: выводить в консоль количество задач по статусам за последнюю неделю.

Нефункциональные требования

  • Легко переключиться на другое хранилище (например, JSON-файлы или PostgreSQL).
  • Легко добавить новый канал уведомлений (Telegram).
  • Код должен быть покрыт юнит-тестами (логика сервисов, без реального доступа к БД и внешним API).
  • Приложение должно запускаться из командной строки с простыми командами: task create "Fix bug" --assignee johntask listtask 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>();

Вывод: Модульность как дисциплина

Модульность — это не набор инструментов, а система ограничений, которые разработчик добровольно на себя принимает:

  1. Никаких new() для зависимостей — только через конструктор
  2. Никаких static для сервисов — только интерфейсы и внедрение
  3. Никаких ссылок на инфраструктурные проекты в Core — только абстракции
  4. Никакой бизнес-логики в конструкторах — только присваивание полей

Для CLI-приложений на C# это окупается с первых 2000 строк кода:

  • Можно переключаться между SQLite, PostgreSQL и JSON-файлами без изменения логики
  • Команды пишутся и тестируются изолированно
  • Приложение растёт горизонтально — новые модули добавляются, а старые не ломаются

Модульность в C# — это архитектурная гигиена. Она не требует фреймворков или дорогих инструментов. Только discipline и правильное разделение ответственности.