$ sudo teach IT
МОДУЛЬ 11 · УРОК 4

Практика: паттерны в реальном проекте

Мы изучили три паттерна по отдельности. Теперь соберём их вместе в одну работающую систему — сервис уведомлений. Singleton хранит настройки, Factory создаёт каналы доставки, Observer связывает подписчиков и новостной сервис. Это не учебный пример — это архитектура, которую используют в production.

⏱ ~60 минут 🎓 Для новичков 🐍 Python 3 🏗 Мини-проект

🗺️ Архитектура проекта: что и зачем

Прежде чем писать код — нарисуем общую картину. В нашем мини-проекте три компонента, каждый решает свою задачу:

⚙️

Singleton: AppConfig

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

🏭

Factory: NotificationFactory

Создаёт нужный тип уведомления по строке-ключу: "email", "sms", "push". Скрывает детали создания объектов.

👁

Observer: NewsService

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

Как компоненты связаны между собой:

  1. AppConfig (Singleton) хранит глобальные настройки — например, дефолтный канал уведомлений
  2. NotificationFactory создаёт объект-уведомление по типу канала
  3. Пользователь указывает тип канала → Factory создаёт объект → объект подписывается на NewsService
  4. NewsService публикует новость → обходит всех подписчиков → каждый получает уведомление через свой канал
  5. Добавить новый канал (Telegram) — только один новый класс + одна строка в Factory. Остальное не трогаем.

💡 Почему три паттерна вместе? Каждый решает свою проблему. Singleton — «кто отвечает за конфиг». Factory — «как создавать нужные объекты». Observer — «как сообщать о событиях». Их комбинация — типичная архитектура уведомительных систем в реальных приложениях.

⚙️ Шаг 1: AppConfig — Singleton

Начнём с самого простого компонента. AppConfig — это глобальный конфиг приложения. Почему Singleton? Потому что настройки одни на всю программу. Если создать два экземпляра конфига, они могут разойтись — и тогда разные части программы будут работать с разными настройками. Singleton гарантирует: конфиг всегда один.

class AppConfig:
    """
    Синглтон: конфиг приложения.
    Один экземпляр на всю программу — настройки не расходятся.
    """
    _instance = None  # Хранит единственный экземпляр

    def __new__(cls):
        # Создаём экземпляр только если его ещё нет
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            # Инициализируем настройки один раз
            cls._instance.app_name = "NewsPortal"
            cls._instance.version = "1.0.0"
            cls._instance.default_channel = "email"  # дефолтный канал уведомлений
        return cls._instance

    def __repr__(self) -> str:
        return f"AppConfig(app={self.app_name}, v{self.version})"

🔍 Разбор: почему именно так

  • _instance = None — атрибут класса, не экземпляра. Общий для всех.
  • __new__ вызывается до __init__. Именно здесь мы решаем: создавать новый объект или вернуть существующий.
  • Настройки ставятся через cls._instance.attr прямо в __new__ — так они инициализируются ровно один раз.
  • default_channel — именно это поле мы используем в Factory позже: пользователь может не указывать канал, и тогда возьмётся дефолтный.

Убедимся, что Singleton работает:

config1 = AppConfig()
config2 = AppConfig()

print(config1 is config2)      # True — один и тот же объект
print(config1.app_name)        # NewsPortal
print(config1.default_channel) # email

🏭 Шаг 2: Notification классы и Factory

Теперь создаём классы уведомлений и Factory, которая их производит. Каждый класс уведомления — это будущий подписчик Observer. Он умеет одно: принять и обработать уведомление.

Сначала — абстрактный базовый класс и три конкретных реализации:

from abc import ABC, abstractmethod


# ─────────────────────────────────────────────
# Базовый класс: интерфейс для всех уведомлений
# ─────────────────────────────────────────────
class BaseNotification(ABC):
    """
    Абстрактный Observer — базовый класс уведомления.
    Все каналы доставки наследуются от него.
    """

    def __init__(self, recipient: str) -> None:
        self.recipient = recipient  # email, телефон, device_id — зависит от канала

    @abstractmethod
    def send(self, message: str) -> None:
        """Отправить уведомление получателю."""
        pass

    # Этот метод будет вызываться Observer-паттерном (см. Шаг 3)
    def update(self, news_service) -> None:
        """Получить уведомление от NewsService (Observer-интерфейс)."""
        self.send(news_service.latest_news)


# ─────────────────────────────────────────────
# Три конкретных канала доставки
# ─────────────────────────────────────────────
class EmailNotification(BaseNotification):
    """Email-уведомление: отправляем полный текст новости."""

    def send(self, message: str) -> None:
        print(f"  📧 [Email → {self.recipient}]: {message}")


class SmsNotification(BaseNotification):
    """SMS-уведомление: текст обрезаем до 50 символов."""

    def send(self, message: str) -> None:
        preview = message[:50] + "..." if len(message) > 50 else message
        print(f"  📱 [SMS → {self.recipient}]: {preview}")


class PushNotification(BaseNotification):
    """Push-уведомление: короткий заголовок для мобильного приложения."""

    def send(self, message: str) -> None:
        print(f"  🔔 [Push → устройство {self.recipient}]: {message}")

💡 Ключевое решение: метод update()

Мы поместили метод update() прямо в BaseNotification. Это связывает два паттерна: каждый класс уведомления одновременно является Observer-ом. Он умеет принять сигнал от NewsService (update) и доставить его по нужному каналу (send). Разделение ответственности соблюдено: update — Observer-логика, send — логика доставки.

Теперь — фабрика. Она принимает строку-тип и создаёт нужный объект. Код, который использует уведомления, не знает о конкретных классах:

class NotificationFactory:
    """
    Фабрика уведомлений.
    Создаёт нужный канал доставки по строке-ключу.
    Новый канал — одна строка в словаре _channels.
    """

    _channels = {
        "email": EmailNotification,
        "sms":   SmsNotification,
        "push":  PushNotification,
    }

    @classmethod
    def create(cls, channel: str, recipient: str) -> BaseNotification:
        """
        Создать объект уведомления.
        channel — тип канала: 'email', 'sms', 'push'
        recipient — адресат (email, телефон, device_id)
        """
        NotificationClass = cls._channels.get(channel)
        if NotificationClass is None:
            available = ", ".join(cls._channels.keys())
            raise ValueError(f"Неизвестный канал '{channel}'. Доступны: {available}")
        return NotificationClass(recipient)

🔍 Почему словарь, а не if/elif?

Словарь _channels — это реестр каналов. Добавить новый тип = добавить одну строку в словарь. Если бы мы писали if channel == "email": ... elif channel == "sms": ... — каждый новый канал требовал бы редактирования цепочки условий. Словарный подход расширяем и читаем.

Проверим Factory в изоляции:

notif = NotificationFactory.create("email", "alice@example.com")
notif.send("Тестовое сообщение")
# 📧 [Email → alice@example.com]: Тестовое сообщение

notif2 = NotificationFactory.create("sms", "+7 900 000-00-00")
notif2.send("Привет от фабрики!")
# 📱 [SMS → +7 900 000-00-00]: Привет от фабрики!

👁 Шаг 3: Observer — NewsService и подписчики

NewsService — это издатель. Он хранит список подписчиков (объектов BaseNotification) и при публикации новости обходит их всех. Подписчики уже умеют принимать уведомления через метод update(), который мы добавили в базовый класс.

from typing import List


class NewsService:
    """
    Observer-издатель: сервис новостей.
    Хранит список подписчиков (BaseNotification) и уведомляет их при публикации.
    """

    def __init__(self) -> None:
        self._subscribers: List[BaseNotification] = []
        self._latest_news: str = ""

    # ── Управление подписчиками ───────────────────

    def subscribe(self, notification: BaseNotification) -> None:
        """Добавить подписчика (объект уведомления)."""
        if notification not in self._subscribers:
            self._subscribers.append(notification)
            print(f"  [+] Подписан: {notification.__class__.__name__} → {notification.recipient}")

    def unsubscribe(self, notification: BaseNotification) -> None:
        """Убрать подписчика."""
        try:
            self._subscribers.remove(notification)
            print(f"  [-] Отписан: {notification.__class__.__name__} → {notification.recipient}")
        except ValueError:
            pass

    def _notify_all(self) -> None:
        """Уведомить всех подписчиков — вызвать update() у каждого."""
        for subscriber in self._subscribers:
            subscriber.update(self)

    # ── Свойство для подписчиков (pull-модель) ────

    @property
    def latest_news(self) -> str:
        """Подписчики читают новость через это свойство."""
        return self._latest_news

    # ── Бизнес-логика ────────────────────────────

    def publish(self, news: str) -> None:
        """Опубликовать новость и уведомить всех подписчиков."""
        print(f"\n📰 Новость: «{news}»")
        self._latest_news = news
        self._notify_all()

✅ Обратите внимание

  • NewsService работает с типом BaseNotification — он не знает, email это или SMS. Это и есть суть Observer: слабая связь.
  • Метод publish() сначала обновляет _latest_news, потом вызывает _notify_all(). Порядок важен: подписчики должны видеть уже новые данные.
  • Свойство latest_news — pull-модель: подписчики сами вытягивают данные через ссылку на издателя.

🔗 Шаг 4: Связываем всё вместе

Все три компонента готовы. Теперь напишем сценарий использования — именно так архитектура «оживает»:

# ── Шаг 1: Получаем конфиг (Singleton) ───────
config = AppConfig()
print(f"Запуск: {config.app_name} v{config.version}")
print(f"Дефолтный канал: {config.default_channel}")

# ── Шаг 2: Создаём NewsService (Observer) ────
news_service = NewsService()

# ── Шаг 3: Создаём уведомления через Factory ─
# Пользователь Alice хочет email
alice_notif = NotificationFactory.create("email", "alice@example.com")

# Пользователь Bob хочет SMS
bob_notif = NotificationFactory.create("sms", "+7 900 123-45-67")

# Пользователь Carol хочет push
carol_notif = NotificationFactory.create("push", "device-xyz-999")

# Пользователь Dave не указал канал — берём дефолтный из AppConfig
dave_notif = NotificationFactory.create(config.default_channel, "dave@example.com")

# ── Шаг 4: Подписываем всех на NewsService ───
news_service.subscribe(alice_notif)
news_service.subscribe(bob_notif)
news_service.subscribe(carol_notif)
news_service.subscribe(dave_notif)

# ── Шаг 5: Публикуем новость ─────────────────
news_service.publish("Python 4.0 официально анонсирован!")

# ── Шаг 6: Bob отписывается ──────────────────
news_service.unsubscribe(bob_notif)

# ── Шаг 7: Публикуем ещё одну новость ────────
news_service.publish("Конференция PyCon 2026: даты объявлены")

Вывод программы:

Запуск: NewsPortal v1.0.0
Дефолтный канал: email

  [+] Подписан: EmailNotification → alice@example.com
  [+] Подписан: SmsNotification → +7 900 123-45-67
  [+] Подписан: PushNotification → device-xyz-999
  [+] Подписан: EmailNotification → dave@example.com

📰 Новость: «Python 4.0 официально анонсирован!»
  📧 [Email → alice@example.com]: Python 4.0 официально анонсирован!
  📱 [SMS → +7 900 123-45-67]: Python 4.0 официально анонсирован!
  🔔 [Push → устройство device-xyz-999]: Python 4.0 официально анонсирован!
  📧 [Email → dave@example.com]: Python 4.0 официально анонсирован!

  [-] Отписан: SmsNotification → +7 900 123-45-67

📰 Новость: «Конференция PyCon 2026: даты объявлены»
  📧 [Email → alice@example.com]: Конференция PyCon 2026: даты объявлены
  🔔 [Push → устройство device-xyz-999]: Конференция PyCon 2026: даты объявлены
  📧 [Email → dave@example.com]: Конференция PyCon 2026: даты объявлены

🏆 Что здесь происходит на самом деле

  • AppConfig отдал дефолтный канал — и Factory использовала его для Dave без лишних условий в коде.
  • Factory скрыла детали создания объектов — код подписки одинаков для всех типов уведомлений.
  • Observer позаботился о доставке — NewsService не знает о типах уведомлений вообще. Он просто вызывает update().
  • После отписки Bob перестал получать новости — и мы не трогали код NewsService.

✈️ Шаг 5: Добавляем Telegram — минимальные изменения

Это самая важная демонстрация урока. Смотрите, насколько легко расширить систему — добавляем поддержку Telegram. Никакой рефакторинг, никаких изменений в существующем коде.

💡 Принцип открытости-закрытости (Open/Closed Principle): система открыта для расширения (можно добавлять новый функционал) и закрыта для изменения (существующий код не трогаем). Именно это мы сейчас покажем на практике.

Шаг 1 из 2: создаём новый класс — наследуем от BaseNotification:

# ── НОВЫЙ ФАЙЛ или ДОБАВЛЯЕМ В КОНЕЦ ФАЙЛА ───

class TelegramNotification(BaseNotification):
    """
    Telegram-уведомление.
    Новый канал = новый класс. Существующий код не трогаем.
    """

    def send(self, message: str) -> None:
        print(f"  ✈️ [Telegram → @{self.recipient}]: {message}")

Шаг 2 из 2: регистрируем в фабрике — одна строка:

# В классе NotificationFactory в словаре _channels добавляем:
_channels = {
    "email":    EmailNotification,
    "sms":      SmsNotification,
    "push":     PushNotification,
    "telegram": TelegramNotification,  # ← ДОБАВИЛИ ТОЛЬКО ЭТО
}

Всё. Telegram готов к работе:

# Используем как обычно — Factory сама разберётся
tg_notif = NotificationFactory.create("telegram", "python_dev")
news_service.subscribe(tg_notif)

news_service.publish("Новый урок по Python уже в эфире!")
# ...
# ✈️ [Telegram → @python_dev]: Новый урок по Python уже в эфире!

✅ Что изменилось

  • Добавлен 1 новый класс
  • Добавлена 1 строка в словарь
  • Итого: 2 строки изменений

🔒 Что не трогали

  • NewsService — не менялся
  • AppConfig — не менялся
  • BaseNotification — не менялся
  • Все существующие тесты проходят

💻 Финальный код: всё вместе

Собираем полный рабочий файл — скопируйте и запустите прямо сейчас:

from abc import ABC, abstractmethod
from typing import List


# ══════════════════════════════════════════════
# SINGLETON: AppConfig
# ══════════════════════════════════════════════

class AppConfig:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.app_name = "NewsPortal"
            cls._instance.version = "1.0.0"
            cls._instance.default_channel = "email"
        return cls._instance


# ══════════════════════════════════════════════
# FACTORY: уведомления
# ══════════════════════════════════════════════

class BaseNotification(ABC):
    def __init__(self, recipient: str) -> None:
        self.recipient = recipient

    @abstractmethod
    def send(self, message: str) -> None:
        pass

    def update(self, news_service) -> None:
        self.send(news_service.latest_news)


class EmailNotification(BaseNotification):
    def send(self, message: str) -> None:
        print(f"  📧 [Email → {self.recipient}]: {message}")


class SmsNotification(BaseNotification):
    def send(self, message: str) -> None:
        preview = message[:50] + "..." if len(message) > 50 else message
        print(f"  📱 [SMS → {self.recipient}]: {preview}")


class PushNotification(BaseNotification):
    def send(self, message: str) -> None:
        print(f"  🔔 [Push → {self.recipient}]: {message}")


class TelegramNotification(BaseNotification):
    def send(self, message: str) -> None:
        print(f"  ✈️ [Telegram → @{self.recipient}]: {message}")


class NotificationFactory:
    _channels = {
        "email":    EmailNotification,
        "sms":      SmsNotification,
        "push":     PushNotification,
        "telegram": TelegramNotification,
    }

    @classmethod
    def create(cls, channel: str, recipient: str) -> BaseNotification:
        NotificationClass = cls._channels.get(channel)
        if NotificationClass is None:
            available = ", ".join(cls._channels.keys())
            raise ValueError(f"Неизвестный канал '{channel}'. Доступны: {available}")
        return NotificationClass(recipient)


# ══════════════════════════════════════════════
# OBSERVER: NewsService
# ══════════════════════════════════════════════

class NewsService:
    def __init__(self) -> None:
        self._subscribers: List[BaseNotification] = []
        self._latest_news: str = ""

    def subscribe(self, notification: BaseNotification) -> None:
        if notification not in self._subscribers:
            self._subscribers.append(notification)
            print(f"  [+] {notification.__class__.__name__} → {notification.recipient}")

    def unsubscribe(self, notification: BaseNotification) -> None:
        try:
            self._subscribers.remove(notification)
            print(f"  [-] {notification.__class__.__name__} → {notification.recipient}")
        except ValueError:
            pass

    def _notify_all(self) -> None:
        for subscriber in self._subscribers:
            subscriber.update(self)

    @property
    def latest_news(self) -> str:
        return self._latest_news

    def publish(self, news: str) -> None:
        print(f"\n📰 «{news}»")
        self._latest_news = news
        self._notify_all()


# ══════════════════════════════════════════════
# ДЕМОНСТРАЦИЯ
# ══════════════════════════════════════════════

if __name__ == "__main__":
    # Singleton: один конфиг
    config = AppConfig()
    print(f"=== {config.app_name} v{config.version} ===\n")

    # Observer: сервис новостей
    news = NewsService()

    # Factory: создаём уведомления
    alice = NotificationFactory.create("email", "alice@example.com")
    bob   = NotificationFactory.create("sms", "+7 900 123-45-67")
    carol = NotificationFactory.create("push", "device-xyz")
    dave  = NotificationFactory.create(config.default_channel, "dave@example.com")
    eva   = NotificationFactory.create("telegram", "eva_dev")

    # Подписываем всех
    for user in [alice, bob, carol, dave, eva]:
        news.subscribe(user)

    # Первая новость — получат все пятеро
    news.publish("Python 4.0 официально анонсирован!")

    # Bob отписывается
    news.unsubscribe(bob)

    # Вторая новость — Bob уже не получит
    news.publish("PyCon 2026: программа конференции опубликована")

🎯 Итоги урока

⚙️ Singleton в проекте

AppConfig — один экземпляр на всю программу. Хранит дефолтный канал, который Factory использует при создании уведомлений.

🏭 Factory в проекте

NotificationFactory скрывает детали создания. Добавить новый канал = 1 класс + 1 строка в словаре. Остальное не трогаем.

👁 Observer в проекте

NewsService не знает о типах уведомлений. Подписчики добавляются/убираются динамически. Издатель не меняется.

🔗 Связь паттернов

Singleton → конфиг для Factory. Factory → объекты для Observer. Observer → система событий. Каждый паттерн делает свою работу.

✨ Open/Closed принцип

Добавили Telegram: 1 новый класс + 1 строка. Не менялись NewsService, AppConfig, BaseNotification. Система расширяема без правки существующего кода.

🌍 В реальных проектах

Эта архитектура лежит в основе Celery/RabbitMQ задач, Django Signals, систем уведомлений в мобильных приложениях и микросервисов.