Само собой, в какой-то момент истории разработчики перешли от консольных приложений к более сложным UI-программам, где на экране появляются настоящие кнопки, текстовые поля, чекбоксы и другие элементы. И именно там, в этих «настоящих» интерфейсах, стали особенно заметны проблемы структуры кода, которые до этого легко прятались внутри консольных приложений.

В этом уроке мы сделаем небольшой шаг в ту эпоху.

Мы перейдём от консоли к маленькому примеру на WinForms — фреймворке, который когда-то был чрезвычайно популярен, а сегодня используется редко, но остаётся очень удобным именно для учебных и демонстрационных задач.

Важно: мы не собираемся изучать WinForms как технологию.

Не будем разбирать его компоненты, события, особенности или архитектуру.

Мы используем его ровно для одной цели:

Чтобы впервые увидеть «настоящий» пользовательский интерфейс, у которого есть кнопки, поля ввода и визуальные реакции на действия пользователя — и посмотреть, как выглядит логика таких приложений, если её писать прямо внутри формы.

Вместо ввода строк через Console.ReadLine мы теперь будем работать с элементами вроде TextBox, NumericUpDown, CheckBox и Button. И вместо того чтобы в консоли выводить текст, мы увидим, как программа напрямую обновляет части интерфейса при каждом действии пользователя.

Такой шаг позволит нам очень наглядно увидеть:

как устроено приложение с простым UI,

что происходит при нажатии на кнопку,

где в реальном проекте возникает смешение логики и интерфейса,

и почему, собственно, программисты со временем пришли к идее разделять эти вещи на отдельные слои.

После этого примера мы как раз и перейдём к разговору о том, как подобные приложения можно перестроить аккуратнее — и почему это так важно. В коде ниже у нас есть небольшая логика, которая полностью управляет тем, что происходит на форме. Здесь можно увидеть классический подход «до архитектуры», когда весь процесс — от обработки ввода до изменения внешнего вида интерфейса — выполняется прямо внутри одного обработчика кнопки.

Когда пользователь нажимает кнопку, запускается метод OnCalculateClicked, и внутри него происходит буквально всё сразу.

Здесь вперемешку находятся:

чтение данных из текстовых полей и других элементов,

проверка корректности этих данных,

расчёт категории клиента,

расчёт скидки,

оформление результата — цвета, тексты, заголовок формы,

вывод сообщений об ошибках.

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

Ниже — версия формы, где вся логика работы сосредоточена в одном обработчике:

namespace MVC
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private void OnCalculateClicked(object sender, EventArgs e)
        {
            string name = txtName.Text.Trim();
            int age = (int)nudAge.Value;
            bool isStudent = chkStudent.Checked;

            if (string.IsNullOrWhiteSpace(name))
            {
                MessageBox.Show("Введите имя клиента.", "Ошибка ввода", MessageBoxButtons.OK, MessageBoxIcon.Warning);
                txtName.Focus();
                return;
            }

            string category;
            if (age < 18)
            {
                category = "Несовершеннолетний";
            }
            else if (age <= 24)
            {
                category = "Молодой клиент";
            }
            else if (age <= 59)
            {
                category = "Взрослый клиент";
            }
            else
            {
                category = "Пожилой клиент";
            }

            int discount = 0;
            if (isStudent)
            {
                discount += 10;
            }

            if (age < 18)
            {
                discount += 5;
            }
            else if (age > 60)
            {
                discount += 7;
            }

            if (discount > 30)
            {
                discount = 30;
            }

            lblCategory.Text = category;
            lblDiscount.Text = $"{discount}%";

            Color discountColor;
            if (discount == 0)
            {
                discountColor = Color.Red;
            }
            else if (discount < 15)
            {
                discountColor = Color.Orange;
            }
            else
            {
                discountColor = Color.Green;
            }

            lblDiscount.ForeColor = discountColor;
            Text = $"Клиент: {name} ({age} лет)";
        }
    }
}

На самом-то деле новички очень часто, особенно на первых этапах, даже если они уже где-то слышали про архитектурные подходы или отдельные паттерны, продолжают действовать по простой инерции:

Если что-то происходит при нажатии на кнопку — значит, всё должно быть написано прямо внутри обработчика этой кнопки.

Такой подход кажется естественным. Пользователь нажал кнопку → код в кнопке сработал → логика выполнилась.

И пока у приложения всего один экран и один простой сценарий — это действительно работает. Нажали кнопку, считали поля, проверили данные, посчитали результат, отобразили его на форме. Всё выглядит логично и понятно.

В то время разработчики не мыслили чёткими принципами, как мы сейчас: никто не говорил словами вроде SRP или DIP — всего этого просто не существовало в таком виде. Но даже без формальных названий было очевидно, что такая архитектура начинает «скрипеть», стоит только приложению немного вырасти.

Однако теперь, когда эти принципы у нас есть и когда мы умеем смотреть на код уже осознанно, становится видно, насколько много нарушений здесь прячется.

Нарушение SRP (Single Responsibility Principle).

Один метод делает всё подряд: валидирует данные, считает категорию, вычисляет скидку, управляет цветами, обновляет заголовок формы. Количество причин для изменения огромно — это явный признак перегруженной ответственности.

Нарушение OCP (Open/Closed Principle).

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

Нарушение DIP (Dependency Inversion Principle).

Логика напрямую зависит от конкретных UI-элементов: txtName, nudAge, chkStudent, lblCategory, lblDiscount. Никаких абстракций, только жёсткая привязка к WinForms.

Сильное сцепление и низкая связанность.

Бизнес-логика смешана с оформлением и выводом сообщений. Метод не просто знает о деталях интерфейса — он от них полностью зависит. Такой код трудно тестировать и трудно расширять.

То есть, даже если в 70–80-е никто ещё не формулировал эти принципы явно, мы сегодня легко видим, что архитектурно такой код крайне уязвим. Именно такие проблемы и подтолкнули разработчиков к идеям разделения ролей, что в итоге и привело к появлению MVC. Ещё в далёких 70-х годах, когда начали появляться первые оконные интерфейсы, разработчики столкнулись ровно с той же проблемой. Программы становились всё более интерактивными: на экране появлялись кнопки, поля ввода, списки, элементы управления, и каждая такая деталь требовала своей логики.

Сначала всё выглядело вполне безобидно: пользователь нажимает кнопку — программа что-то считает и обновляет экран.

Но стоило интерфейсу стать чуть сложнее, как код начинал стремительно превращаться в настоящую кашу.

И вот первая неприятность: любое изменение интерфейса могло сломать логику.

Казалось бы, всего лишь добавили на форму ещё одно поле — например, «Телефон» или «Город». Маленькая доработка? На практике — нет.

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

И всё это — в уже работающий код, который никого не трогал.

Стоило перепутать одну строку или слегка поменять порядок условий — и внезапно переставал корректно работать расчёт скидки или нарушалось отображение категории.

Получается, меняем интерфейс… а ломается логика.

Вторая сторона той же проблемы: изменения логики требовали переписывать интерфейс.

Например, бизнес решает: студентам давать не 10%, а 12%; ввести новую возрастную категорию; изменить правила расчёта скидки. Это чистая бизнес-логика — но в нашей программе она впаяна прямо в форму.

Значит, снова приходится открывать обработчик кнопки, переписывать условия, менять тексты, обновлять Label и вручную проверять вывод.

Интерфейс и логика буквально ходят “пакетом”: изменили одно — обязательно придётся менять другое.

И, конечно, тестирование.

Проверить даже самое простое правило — например, что клиент старше 60 получает дополнительные 7% скидки — без запуска формы невозможно. Нужно вручную вводить данные, нажимать кнопку, следить глазами за результатом.

Логика спрятана внутри обработчика, и вызвать её отдельно, без UI, нельзя. Тестировать становится тяжело, долго и ненадёжно. В какой-то момент стало ясно, что нужен более чистый и понятный способ организовывать интерфейсные приложения. И в 1979 году норвежский программист Трюгве Реенскауг, работавший в легендарной лаборатории Xerox PARC, предложил решение, которое стало настоящим прорывом.

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

И особенно важно отметить: всё это появилось задолго до Solid. Принципы Solid будут сформулированы только в начале 2000-х годов, то есть почти через двадцать лет после MVC. Но уже тогда разработчики чувствовали необходимость в архитектурных решениях.

По сути, MVC стал одной из первых попыток сделать программы чище, надёжнее и лучше структурированными.

В своё время MVC был настоящим прорывом. Это было первое по-настоящему системное решение той архитектурной путаницы, с которой сталкивались все разработчики. Но со временем приложения становились всё сложнее, и поверх этой базовой идеи начали появляться новые улучшенные паттерны.

Тем не менее даже сегодня MVC продолжает использоваться. Где-то он стал стандартом, где-то — основой для других архитектур, а местами применяется в чистом виде. И это лучше всего показывает, насколько сильной и жизнеспособной оказалась его изначальная идея.

MVC расшифровывается как Model – View – Controller.

Это способ разделить программу на три части так, чтобы каждая из них отвечала только за своё:

Model (Модель) – данные и правила работы с ними.

View (Представление) – то, как эти данные показываются пользователю и как от пользователя принимается ввод.

Controller (Контроллер) – связующее звено, которое принимает решения и управляет потоком действий между пользователем, моделью и представлением.

Главная идея: не смешивать данные, интерфейс и логику в одном месте, а дать каждому слою свою чёткую роль.

С момента своего появления у этого паттерна появилось несколько разных форм, и в зависимости от технологии MVC может выглядеть по-разному. Например, если Вы будете работать с ASP.NET, то увидите, что там существует своя интерпретация MVC, и она применяется очень широко — особенно в веб-разработке.

Но в этом уроке мы рассмотрим более классический, каноничный вариант MVC на простом примере.

Важно помнить: каким бы ни был фреймворк и какую бы версию MVC Вы ни встретили, он всегда опирается на три ключевых элемента — Model, View и Controller.

На схеме ниже изображены три основных участника паттерна Model–View–Controller:

Прежде всего важно уточнить: это не UML-диаграмма.

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

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

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

Контроллер принимает результат от модели и подготавливает его для передачи во View.

Затем контроллер отправляет данные обратно во View, указывая, что именно нужно вывести на экране.

Так формируется полный цикл: пользователь действует → View принимает событие → Controller обрабатывает его → Model меняется → Controller получает результат → View обновляется. Давайте теперь посмотрим на то, что у нас уже есть в проекте — но посмотрим сквозь призму MVC.

А какие элементы паттерна уже присутствуют в нашем коде?

Само собой, у нас есть View (представление).

В нашем случае этим представлением является MainForm. Именно она отвечает за внешний вид приложения: содержит кнопки, поля ввода, принимает ввод пользователя, реагирует на нажатия и изменения значений. Это тот слой, с которым непосредственно взаимодействует человек.

Но беда том, что через MainForm мы сейчас управляем всем интерфейсом: говорим, что должно быть написано в текстовых полях, какие сообщения показывать, какие цвета использовать и т.д.

Но вместе с этим внутри MainForm живёт вся остальная логика вперемешку:

проверка корректности данных,

расчёт категории клиента,

вычисление скидки,

принятие решений, что и как показывать.

Наша следующая задача — выделить отдельную Model, которая будет отвечать за данные и правила работы с ними, и создать Controller — посредника, который будет принимать действия пользователя от View, обращаться к модели и говорить представлению, что именно нужно отобразить.

Начнем со слоя модели.

Модель — это сердце логики приложения. Это не кнопки, не форма и вообще не интерфейс. Модель — это данные и правила, по которым эти данные живут.

public class CustomerModel
{
    public string Name { get; private set; } = string.Empty;
    public int Age { get; private set; }
    public bool IsStudent { get; private set; }

    public string Category { get; private set; } = string.Empty;
    public int Discount { get; private set; }

    // View будет подписываться на это событие
    public event EventHandler? StateChanged;

    public CustomerModel()
    {
    }

    public void Update(string name, int age, bool isStudent)
    {
        Name = name?.Trim() ?? string.Empty;
        Age = age;
        IsStudent = isStudent;
    }

    /// <summary>
    /// Проверяет, корректны ли исходные данные.
    /// Возвращает текст ошибки или null, если всё в порядке.
    /// </summary>
    public string? Validate()
    {
        if (string.IsNullOrWhiteSpace(Name))
            return "Имя клиента не должно быть пустым.";

        if (Age < 0 || Age > 120)
            return "Возраст клиента задан некорректно.";

        return null;
    }

    /// <summary>
    /// Пересчитывает категорию и скидку на основе текущих данных.
    /// </summary>
    public void Recalculate()
    {
        Category = CalculateCategory();
        Discount = CalculateDiscount();
        OnStateChanged();
    }

    private void OnStateChanged()
        => StateChanged?.Invoke(this, EventArgs.Empty);

    private string CalculateCategory()
    {
        if (Age < 18)
            return "Несовершеннолетний";
        if (Age <= 24)
            return "Молодой клиент";
        if (Age <= 59)
            return "Взрослый клиент";
        return "Пожилой клиент";
    }

    private int CalculateDiscount()
    {
        int discount = 0;

        if (IsStudent)
            discount += 10;

        if (Age < 18)
            discount += 5;
        else if (Age > 60)
            discount += 7;

        if (discount > 30)
            discount = 30;

        return discount;
    }
}

Что важно заметить:

Класс ничего не знает о форме, текстбоксах, лейблах и цветах.

Он занимается только данными и правилами: хранит имя, возраст, признак студента, считает категорию и скидку.

Проверка Validate() возвращает строку ошибки, но не показывает MessageBox — этим будет заниматься View/Controller.

Если меняется что-то, что отображается на экране этот класс "кричит" через событие.

То есть на этом этапе, даже не возможно сказать, где данная модель будет использоваться - на сайте, в WinForms, в WPF-фреймворке или в консольном приложении.

Это и есть наше первое серьёзное достижение: модель полностью отвязана от интерфейса, она самодостаточна и не зависит от фреймворка.

Добавляем контроллер

Теперь добавляем того, кто будет переводить действия пользователя в изменения модели — контроллер.

Вот пользователь нажал на кнопку.

Раньше наша модель жила прямо внутри MainForm: форма сама читала текстбоксы, сама считала скидку, сама решала, что показать.

Теперь же мы вынесли всю эту логику в отдельный класс CustomerModel.

И возникает естественный вопрос:

Ну и что теперь, просто сохранить ссылку на модель внутри MainForm и дальше напрямую дергать у неё Validate() и Recalculate()? Тогда зачем вообще всё это MVC?

Если мы так сделаем, форма снова начнёт сама "решать" что происходит при нажатии на кнопку. Сейчас у нас да - действие "пересчитать". А завтра мы захотим, чтобы при нажатии на кнопку происходило что-то еще...и что, нам меня форму?

Если мы так сделаем, форма снова начнёт сама «решать», что происходит при нажатии на кнопку. Сегодня у нас одно действие — «пересчитать». А завтра мы захотим, чтобы при нажатии на кнопку происходило что-то ещё: логирование, сохранение истории, запрос к серверу, дополнительная проверка, выбор другого сценария.

И что тогда? Снова лезть в форму и дописывать туда новую логику? Получается, мы опять превращаем MainForm в тот самый «комбайн», от которого только что уходили.

Поэтому нам и нужен отдельный участник — контроллер, который берёт на себя решение, что должно произойти при нажатии:

public class CustomerController
{
    private readonly CustomerModel _model;
    private readonly MainForm _view;

    public CustomerController(CustomerModel model, MainForm view)
    {
        _model = model;
        _view = view;
    }

    public void OnCalculateRequested()
    {
        // 1. Забираем данные из View
        string name = _view.CustomerNameInput;
        int age = _view.CustomerAgeInput;
        bool isStudent = _view.IsStudentInput;

        // 2. Обновляем модель
        _model.Update(name, age, isStudent);

        // 3. Валидируем данные
        var error = _model.Validate();
        if (error != null)
        {
            // Если что-то не так — просим View показать ошибку
            _view.ShowValidationError(error);
            return;
        }

        // 4. Всё в порядке — пересчитываем состояние модели
        _model.Recalculate();
        // После этого модель поднимет событие StateChanged,
        // и View сама обновит интерфейс.
    }
}

Контроллер сидит между ними и решает:

«Сначала забрать данные из View, потом обновить модель, потом проверить, потом либо показать ошибку, либо пересчитать и отдать результат обратно».

То есть контроллер — это тот, кто превращает «пользователь кликнул по кнопке» в осмысленную последовательность шагов над моделью.

Обратите внимание, если модель не смогла обновится, то контроллер сам решает, что нужно делать с View.

Дорабатываем View

Ну и во View теперь ловим изменения модели, через событие, которое она нам дала. Если что-то почуяли - сразу тащим с нее данные.

public partial class MainForm : Form
{
    private readonly CustomerModel _model;
    private readonly CustomerController _controller;

    public MainForm()
    {
        InitializeComponent();

        // Создаём модель и контроллер
        _model = new CustomerModel();
        _controller = new CustomerController(_model, this);

        // Подписываемся на изменения модели
        _model.StateChanged += OnModelStateChanged;
    }

    // ---- Данные ввода для контроллера ----

    public string CustomerNameInput => txtName.Text;
    public int CustomerAgeInput => (int)nudAge.Value;
    public bool IsStudentInput => chkStudent.Checked;

    // ---- Методы, которые контроллер может вызывать ----

    public void ShowValidationError(string message)
    {
        MessageBox.Show(message, "Ошибка ввода",
            MessageBoxButtons.OK, MessageBoxIcon.Warning);
        txtName.Focus();
    }

    // Обновляем интерфейс, когда модель изменилась
    private void OnModelStateChanged(object? sender, EventArgs e)
    {
        lblCategory.Text = _model.Category;
        lblDiscount.Text = $"{_model.Discount}%";

        Color discountColor;
        if (_model.Discount == 0)
            discountColor = Color.Red;
        else if (_model.Discount < 15)
            discountColor = Color.Orange;
        else
            discountColor = Color.Green;

        lblDiscount.ForeColor = discountColor;

        Text = $"Клиент: {_model.Name} ({_model.Age} лет)";
    }

    // Обработчик кнопки — просто сигнал контроллеру
    private void btnCalculate_Click(object sender, EventArgs e)
    {
        _controller.OnCalculateRequested();
    }
}

Посмотрите, что у нас в итоге получилось. Форма больше не решает, что именно должно происходить при нажатии на её кнопку. Она лишь фиксирует факт: «кнопку нажали» — и сообщает об этом контроллеру. А уже контроллер принимает решение, что делать дальше.

Контроллер, в свою очередь, имеет полное право обратиться к форме и сказать ей, что нужно сделать в ответ. Он может, например, заставить её показать сообщение об ошибке, если модель вернула некорректные данные. Но в общем случае контроллер занимается другим: он говорит модели что-то вроде

«Ага, пользователь нажал на кнопку — давай-ка, модель, пересчитывайся».

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

Моё состояние изменилось, можешь перечитать данные и перерисоваться.

Форма, подписанная на это событие, просто берёт актуальные значения из модели и отображает их на экране.

В итоге единственная по-настоящему изолированная часть — это модель.

Она не знает ни про форму,

ни про контроллер,

ни про конкретные элементы интерфейса.

Все остальные части связаны друг с другом: View знает о контроллере и модели, контроллер знает и про View, и про модель. А вот модель можно спокойно использовать где угодно: в другом UI, в веб-приложении, в тестах, даже в консольной утилите — её код от этого не изменится. Давайте сразу сделаем важную ремарку.

Всё, что мы сейчас с Вами разобрали — это старый, классический вариант MVC. Паттерн появился давно, ещё во времена первых GUI-систем, и мы посмотрели на него почти в изначальном, «учебниковом» виде.

И, возможно, уже сейчас он выглядит для Вас немного сырым и странным. И это нормальное ощущение. Почему так?

Во-первых, у нас до сих пор остаётся небольшая путаница с тем, кто управляет View. Смотрите, что происходит:

View может обновляться по событию модели: модель пересчиталась, подняла StateChanged, форма это поймала и обновила лейблы.

В то же время View может обновляться и по прямому запросу контроллера: контроллер вызывает ShowValidationError, и форма что-то показывает по его команде.

То есть интерфейс у нас как бы дергают из двух мест одновременно: и модель (через событие), и контроллер (через методы).

Это не очень аккуратная схема: трудно однозначно ответить на вопрос «а кто вообще хозяин View?».

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

экранов становится много,

сценариев взаимодействия ещё больше,

логики «если пользователь сделал это, а данные такие, то нужно то-то» — просто море.

И контроллер постепенно превращается в огромный класс, который знает вообще обо всём: про все действия пользователя, все ветки сценариев, все особенности конкретного экрана.

Раньше, когда графические приложения были довольно простыми, это ещё можно было терпеть.

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

Именно поэтому классический MVC в своём чистом виде сегодня почти не используется в настольных приложениях.

Но при этом этот паттерн вовсе не ушёл в историю.

Он нашёл свою идеальную нишу — в WEB. Если Вы будете работать с ASP.NET MVC или, возможно, уже работаете, то обязательно увидите, что MVC там отлично устроился и прекрасно выполняет свою роль ввиду специфики данной, правда, это уже благодаря особенностям самого фреймворка и архитектуры веб-запросов, а не GUI-интерфейсов, поэтому глубоко в веб-MVC мы погружаться не будем.

Самое главное — Вы сейчас поняли идею самого паттерна, его структуру и мотивацию.

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

MVC (Model-View-Controller) — архитектурный паттерн, разделяющий приложение на три слоя: Model (данные и бизнес-логика), View (отображение) и Controller (обработка действий пользователя и координация).

Model — слой данных и бизнес-логики. Не знает о View и Controller.

View — слой отображения. Показывает данные пользователю и передаёт действия в Controller.

Controller — посредник, который принимает действия от View, обращается к Model и обновляет View.

Обсуждение урока

0
Комментарии видны всем. Чтобы участвовать в обсуждении, войдите или зарегистрируйтесь.
Модерация сообщества

Пожаловаться на комментарий

Расскажите модераторам, что именно требует внимания.