Перейти к содержанию
V1 V2

Действительно ли производственным сервисам нужен динамический граф приложения?

6 августа 2026

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

Производственный сервис, очевидно, динамичен. Запросы приходят с разными данными. Feature flags меняются, пока процесс работает. Учётные данные ротируются. Тенанты появляются и исчезают. Лимиты скорости сдвигаются. Удалённые эндпоинты переключаются на резерв. Бюджеты повторов меняются. Бизнес-правила зависят от текущего состояния. Конфигурация может перезагружаться. Трафик маршрутизируется по-разному в зависимости от заголовков, аккаунтов, регионов, экспериментов или операционной политики.

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

Эти две идеи — не одно и то же.

Динамическое поведение приложения
            ≠
Динамический граф зависимостей

Модель внедрения зависимостей Kora построена вокруг этого различия. Фреймворк определяет топологию компонентов приложения на этапе компиляции, проверяет эту топологию и генерирует для неё обычный код. При этом работающее приложение не заморожено. У экземпляров компонентов есть жизненный цикл. Конфигурация может отслеживаться и перезагружаться. Части графа могут обновляться. Долгоживущие компоненты могут ссылаться на текущую версию другого компонента через ValueOf<T>. Коллекции известных реализаций можно внедрять и выбирать во время выполнения. Теги различают реализации. Логика приложения может маршрутизировать между стратегиями на основе конфигурации, feature flags, данных тенанта, контекста запроса, состояния здоровья, задержки или любого другого сигнала выполнения.

Структурный вопрос и поведенческий вопрос просто разделены.

Для большинства бэкенд-сервисов это разделение — не ограничение. Это более точная модель того, что фактически меняется.

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

HTTP-контроллер
      ↓
сервис приложения
      ↓
репозиторий
      ↓
база данных

или:

Kafka-консьюмер
      ↓
доменный обработчик
      ↓
репозиторий
      ↓
база данных

или:

планировщик
      ↓
задача
      ↓
HTTP-клиент
      ↓
внешний сервис

Запросы, записи, SQL-строки, feature flags, учётные данные, эндпоинты и политики могут меняться постоянно, но тот факт, что OrderService зависит от OrderRepository, обычно не становится неизвестным во вторник в 14:37.

Это различие — основа подхода фреймворка Kora.

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

Поэтому вопрос не в том, поддерживает ли Kora динамику времени выполнения. Очевидно, поддерживает. Настоящий вопрос в том, где должна жить динамика.

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

Это удивительно полезная граница.


Слово «динамичный» скрывает несколько разных вещей

Полезное обсуждение должно разделять как минимум три уровня:

Топология этапа компиляции
        ≠
Состояние компонентов времени выполнения
        ≠
Поведение приложения времени выполнения

Эти слои отвечают на разные вопросы.

Топология этапа компиляции отвечает:

  • Какие компоненты могут существовать?
  • Какой компонент зависит от какого другого?
  • Какая реализация удовлетворяет интерфейс?
  • Какие теги различают несколько реализаций?
  • Какие фабрики конструируют компоненты?
  • Какие отношения жизненного цикла существуют?
  • Содержит ли граф отсутствующие или неоднозначные зависимости?
  • Содержит ли граф недопустимый цикл?

Состояние компонентов времени выполнения отвечает:

  • Какие значения конфигурации компонент сейчас использует?
  • Какой эндпоинт активен?
  • Какие учётные данные актуальны?
  • Какая версия обновляемого компонента сейчас установлена?
  • Какое содержимое кеша существует?
  • Какое соединение здорово?
  • Какие текущие параметры политики загружены?

Поведение приложения времени выполнения отвечает:

  • Какого платёжного провайдера должен использовать этот запрос?
  • Какая политика тенанта применяется?
  • Включена ли функция для этого аккаунта?
  • Какая реплика должна получить это чтение?
  • Какая политика повтора применяется к этой операции?
  • Следует ли отклонить этот запрос лимитом скорости?
  • Какая бизнес-стратегия должна выполниться?

Контейнер DI времени выполнения — один из способов реализации некоторых из этих задач, но не единственный и часто не самый естественный.

Важный архитектурный вопрос — неизвестно ли само множество возможных компонентов и их отношения зависимостей до выполнения.

Для большинства производственных сервисов — нет.


Типичный бэкенд структурно предсказуем

Рассмотрим платёжный сервис.

На этапе сборки команда уже знает, что приложение может использовать Stripe и Adyen:

PaymentController
      ↓
PaymentService
      ↓
PaymentRouter
     ↙   ↘
Stripe  Adyen

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

  • страны;
  • мерчанта;
  • валюты;
  • feature flag;
  • состояния здоровья провайдера;
  • распределения эксперимента;
  • текущей политики маршрутизации.

Это высокодинамичное поведение.

Но никакой неизвестный Java-класс не должен появляться из ниоткуда.

Множество стратегий известно. Выбор динамичен.

Это можно выразить напрямую:

public final class PaymentRouter {

    private final StripePaymentProvider stripe;
    private final AdyenPaymentProvider adyen;
    private final PaymentRoutingConfig config;

    public PaymentRouter(
        StripePaymentProvider stripe,
        AdyenPaymentProvider adyen,
        PaymentRoutingConfig config
    ) {
        this.stripe = stripe;
        this.adyen = adyen;
        this.config = config;
    }

    public PaymentProvider providerFor(Payment payment) {
        return switch (config.providerFor(payment.country())) {
            case STRIPE -> stripe;
            case ADYEN -> adyen;
        };
    }
}
class PaymentRouter(
    private val stripe: StripePaymentProvider,
    private val adyen: AdyenPaymentProvider,
    private val config: PaymentRoutingConfig
) {

    fun providerFor(payment: Payment): PaymentProvider {
        return when (config.providerFor(payment.country())) {
            Provider.STRIPE -> stripe
            Provider.ADYEN -> adyen
        }
    }
}

Граф статичен:

StripePaymentProvider
AdyenPaymentProvider
PaymentRouter

Поведение динамично:

запрос
   ↓
политика времени выполнения
   ↓
выбор провайдера

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

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


Статический граф не означает статическое поведение

Это заблуждение стоит устранить в первую очередь.

Статический граф зависимостей говорит:

Фреймворк знает множество компонентов и их отношения зависимостей.

Он не говорит:

Значения, решения и состояние внутри этих компонентов никогда не могут меняться.

Веб-сервер имеет стабильное место в графе, обслуживая миллионы разных запросов.

Репозиторий имеет стабильный тип, читая постоянно меняющиеся данные.

Клиент feature flags имеет стабильное отношение зависимости, пока значения флагов меняются непрерывно.

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

Сервис маршрутизации может оставаться тем же экземпляром, пока меняется его таблица маршрутизации.

Лимитер скорости может оставаться подключённым к тому же сервису, пока меняются квоты.

ValueOf<T> может оставаться стабильным ребром графа, пока текущий экземпляр T обновляется.

Это можно резюмировать так:

Стабильная структура
      ↓
динамическое состояние
      ↓
динамические решения
      ↓
динамическое поведение

Сервису не нужна мутируемая типовая топология, чтобы быть динамичным.


Что Kora фактически делает статичным

Модель компиляции Kora определяет структуру зависимостей до запуска приложения.

Компонент может происходить из:

  • явно аннотированного компонента;
  • фабричного метода;
  • модуля;
  • внешнего модуля, явно подключённого к приложению;
  • фабричного модуля;
  • обобщённой фабрики;
  • расширения этапа компиляции.

Затем фреймворк разрешает эти объявления в граф приложения.

Если зависимость отсутствует, компиляция падает.

Если несколько кандидатов делают внедрение неоднозначным, компиляция падает.

Если граф содержит недопустимый цикл, компиляция падает.

Если требуемый модуль не подключён, компиляция падает.

Дело не в том, что компоненты буквально инстанцируются на этапе компиляции. Они не инстанцируются. Выполнение всё ещё создаёт компоненты и управляет жизненным циклом.

Статическая часть — это валидная топология.

Концептуально:

Этап компиляции

Контроллер
   ↓
Сервис
   ↓
Репозиторий
   ↓
DataSource

✓ каждый узел известен
✓ каждое ребро известно
✓ граф проверен

Затем выполнение создаёт фактические экземпляры.

Это гораздо более узкое значение слова «статичный», чем предполагает фраза «статическое приложение».


Что Kora держит динамичным

Во время выполнения Kora всё ещё управляет несколькими формами изменений.

Контейнер создаёт и освобождает экземпляры компонентов.

Компоненты, осведомлённые о жизненном цикле, инициализируются и завершаются.

Конфигурация может меняться.

Затронутые части графа могут пересобираться.

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

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

Коллекции компонентов могут действовать как реестры стратегий.

Внешнее состояние может меняться произвольно.

Удалённые системы feature flags могут управлять решениями.

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

Это различие становится особенно ясным, когда в картину входит обновление конфигурации.


Топология этапа компиляции, обновление времени выполнения

У Kora 2 есть явный механизм обновления графа во время выполнения.

Основной контракт концептуально прост:

public interface RefreshableGraph extends Graph {
    void refresh(Node<?> fromNode);
}
interface RefreshableGraph : Graph {
    fun refresh(fromNode: Node<*>)
}

Обновление начинается с узла, чьё нижележащее значение изменилось.

Всё ниже по потоку через прямые рёбра зависимостей пересобирается, потому что те компоненты были сконструированы со старым значением.

Компоненты, соединённые через косвенную зависимость, такую как ValueOf<T>, не нужно пересобирать. Они сохраняют собственную идентичность и читают текущую зависимость, когда она нужна.

Это создаёт полезное различие между двумя отношениями графа.

Прямая зависимость говорит:

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

Косвенная зависимость говорит:

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

Это реальный динамический механизм графа времени выполнения, но он сохраняет топологию этапа компиляции.

Множество узлов и возможных рёбер было проверено заранее.

Операция времени выполнения меняет экземпляры вдоль известных рёбер.

Она не обнаруживает произвольную новую архитектуру приложения.


ValueOf<T> — ключевая абстракция

Предположим, у нас есть:

ServiceA
ServiceB
ServiceC

и ServiceC напрямую зависит и от ServiceA, и от ServiceB.

Концептуально:

ServiceC serviceC(ServiceA a, ServiceB b)
fun serviceC(a: ServiceA, b: ServiceB): ServiceC

Если ServiceB обновляется, ServiceC обычно должен быть переконструирован, потому что его конструктор получил старый ServiceB.

Теперь изменим отношение:

ServiceC serviceC(ServiceA a, ValueOf<ServiceB> b)
fun serviceC(a: ServiceA, b: ValueOf<ServiceB>): ServiceC

ServiceC больше не владеет фиксированной ссылкой на ServiceB.

Он владеет доступом к текущему ServiceB.

Во время выполнения:

начальное состояние

ServiceC
   │
   └──── ValueOf ────→ ServiceB₁

после обновления:

ServiceC
   │
   └──── ValueOf ────→ ServiceB₂

ServiceC остаётся стабильным.

Это даёт Kora контролируемую форму динамики времени выполнения без отказа от проверки графа.

Компилятор всё ещё знает, что ServiceC может зависеть от ServiceB.

Выполнение может менять, какой экземпляр ServiceB сейчас актуален.


Зачем это нужно долгоживущим серверам

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

HTTP-сервер может владеть:

  • слушающим сокетом;
  • состоянием акцептора;
  • транспортными ресурсами;
  • состоянием соединений.

Перезапуск сервера из-за изменения обработчика запросов был бы ненужным и потенциально разрушительным.

Kora может вместо этого заставить сервер косвенно зависеть от своих текущих обработчиков.

Концептуально:

экземпляр HTTP-сервера
       │
       │ остаётся живым
       ↓
ValueOf<Handlers>
       │
       ├── до обновления → Handlers v1
       │
       └── после обновления → Handlers v2

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

Это более точный вид динамики, чем выбрасывание всего контейнера.


Обновление конфигурации — конкретный пример

Наблюдатель конфигурации Kora демонстрирует, что это не просто теоретический API.

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

Процесс примерно такой:

application.conf меняется
        ↓
ConfigWatcher обнаруживает изменение
        ↓
источник конфигурации перечитывается
        ↓
RefreshableGraph.refresh(узел конфигурации)
        ↓
затронутые компоненты конфигурации пересоздаются
        ↓
прямые зависимые пересоздаются
        ↓
зависимые через ValueOf сохраняют идентичность
        ↓
приложение продолжает работу

Наблюдатель по умолчанию проверяет периодически, пока процесс работает.

Для HOCON включённые файлы тоже могут участвовать.

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

Это операционно значимая перенастройка во время выполнения.

Это не статическая конфигурация, запечённая в артефакт.


Уровни логирования показывают модель на практике

Логирование — особенно лёгкий для понимания пример.

Фреймворк знает на этапе компиляции, что инфраструктура логирования существует.

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

Фактическая политика уровня — состояние времени выполнения.

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

Это даёт:

Этап компиляции:
компоненты и зависимости логирования известны

Время выполнения:
INFO → DEBUG → WARN

Никакая топология зависимостей не изменилась.

Тем не менее поведение приложения изменилось существенно.

Это ровно то различие, вокруг которого спроектирована Kora.


Лучшая ментальная модель обновления конфигурации

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

Модель Kora можно визуализировать более узко.

Предположим:

Config
  ↓
DatabaseConfig
  ↓
DatabaseClient
  ↓
OrderRepository
  ↓
OrderService

Если DatabaseConfig меняется и каждая зависимость прямая, обновление может пересобрать затронутый путь.

Концептуально:

до

Config₁
  ↓
DatabaseConfig₁
  ↓
DatabaseClient₁
  ↓
OrderRepository₁
  ↓
OrderService₁

после обновления:

Config₂
  ↓
DatabaseConfig₂
  ↓
DatabaseClient₂
  ↓
OrderRepository₂
  ↓
OrderService₂

Топология идентична.

Экземпляры новые.

Это аналогично замене значений в проверенном графе потоков данных.


ValueOf<T> позволяет управлять границами обновления

Пересборка каждого нижестоящего компонента не всегда желательна.

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

Используй:

Config
  ↓
RoutingConfig
  ↓
RoutingPolicy
  ↓
ValueOf<RoutingPolicy>
  ↓
RequestRouter

После обновления:

RoutingPolicy₁
       ↓ обновление
RoutingPolicy₂

RequestRouter остаётся живым
и читает текущее значение.

Это делает границы обновления явными в типах.

Прямая зависимость означает связанность жизненного цикла.

ValueOf<T> означает живую ссылку без связанности жизненного цикла.

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


У динамики времени выполнения должен быть владелец

Это ведёт к более широкому архитектурному моменту.

У каждого динамического решения должен быть владелец.

Если выбор провайдера — бизнес-логика, им должен владеть PaymentRouter.

Если выбор эндпоинта — политика клиента, им должен владеть маршрутизирующий клиент или поставщик эндпоинтов.

Если оценка флагов — продуктовая политика, ею должен владеть клиент feature flags.

Если нужно поведение, специфичное для тенанта, им должен владеть реестр тенантов.

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

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

Это обычно размывает архитектуру.

Модель Kora поощряет более явный ответ:

топология зависимостей → фреймворк
политика времени выполнения → компонент приложения/домена
конфигурация времени выполнения → подсистема конфигурации
состояние времени выполнения → компонент
обновление времени выполнения → жизненный цикл графа

У каждой задачи есть место.


Feature flags не требуют динамического DI

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

Предположим, у сервиса две реализации:

interface RecommendationStrategy {
    Recommendations recommend(User user);
}
interface RecommendationStrategy {
    fun recommend(user: User): Recommendations
}

с:

ClassicRecommendationStrategy
AiRecommendationStrategy

Feature flag решает, какую использовать.

Динамическая контейнерная интерпретация могла бы пытаться регистрировать или снимать реализации по мере изменения флагов.

Это ненужно.

Используй маршрутизатор:

public final class RecommendationRouter implements RecommendationStrategy {

    private final ClassicRecommendationStrategy classic;
    private final AiRecommendationStrategy ai;
    private final FeatureFlags flags;

    public RecommendationRouter(
        ClassicRecommendationStrategy classic,
        AiRecommendationStrategy ai,
        FeatureFlags flags
    ) {
        this.classic = classic;
        this.ai = ai;
        this.flags = flags;
    }

    @Override
    public Recommendations recommend(User user) {
        if (flags.enabled("ai-recommendations", user.id())) {
            return ai.recommend(user);
        }

        return classic.recommend(user);
    }
}
class RecommendationRouter(
    private val classic: ClassicRecommendationStrategy,
    private val ai: AiRecommendationStrategy,
    private val flags: FeatureFlags
) : RecommendationStrategy {

    override fun recommend(user: User): Recommendations {
        if (flags.enabled("ai-recommendations", user.id)) {
            return ai.recommend(user)
        }

        return classic.recommend(user)
    }
}

Граф такой:

Классическая стратегия ─┐
                        ├── RecommendationRouter
стратегия с нейро-технологиями ─┤
                        │
FeatureFlags ───────────┘

Каждый запрос может выбирать по-разному.

Статическая топология вообще не снизила гибкость времени выполнения.


OpenFeature естественно вписывается в эту модель

Kora не нуждается в гигантской специфичной для фреймворка абстракции feature flags для поддержки динамических флагов.

Клиент OpenFeature или другой провайдер может быть интегрирован как любая обычная Java-библиотека:

конфигурация
      ↓
провайдер
      ↓
клиент OpenFeature
      ↓
адаптер FeatureFlags
      ↓
маршрутизация приложения

Провайдер и клиент — известные компоненты графа.

Значения флагов — данные времени выполнения.

Оценка — поведение времени выполнения.

Удалённое изменение флага может повлиять на следующий запрос без изменения графа DI.

Это ровно тот вид динамики, который принадлежит вне топологии контейнера.


Динамическая конфигурация не означает динамические классы

Удалённые системы конфигурации создают то же различие.

Рассмотрим приложение, читающее:

payment.defaultProvider = "stripe"
payment.retry.maxAttempts = 3
limits.checkout = 200
search.endpoint = "https://..."

Все эти значения могут меняться.

Но задействованные Java-типы остаются:

PaymentRouter
RetryPolicy
RateLimiter
SearchClient

Значения конфигурации — переменные времени выполнения.

Классы и рёбра зависимостей — архитектура.

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


Учётные данные — динамическое состояние

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

Предположим, облачному SDK нужны ротирующиеся учётные данные.

Неверный вывод:

учётные данные меняются
→ пересоздать определение графа зависимостей

Более чистая модель:

CredentialProvider
      ↓
текущие учётные данные

Провайдер остаётся стабильным компонентом графа.

Возвращаемый материал учётных данных меняется.

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

Опять же, топология может оставаться той же:

CredentialConfig
      ↓
CredentialProvider
      ↓
CloudClient

Меняются только экземпляры или значения.


Динамические эндпоинты — обычно состояние маршрутизации

Обнаружение сервисов даёт ещё один пример.

HTTP-клиенту может понадобиться вызывать меняющийся набор вышестоящих узлов.

Это не подразумевает меняющийся набор определений Java-зависимостей.

Стабильный компонент может владеть динамическим состоянием эндпоинтов:

ServiceDiscovery
       ↓
EndpointRegistry
       ↓
HttpClient

В 10:00:

A, B, C

В 10:05:

B, C, D

Реестр эндпоинтов меняется.

Архитектура приложения — нет.


Мультитенантность редко требует один граф DI на тенанта

Мультитенантность часто приводят как причину для динамического внедрения зависимостей.

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

Предположим, у разных тенантов есть:

  • идентификаторы баз данных;
  • учётные данные;
  • лимиты скорости;
  • feature flags;
  • платёжные политики.

Реестр времени выполнения может хранить состояние тенантов:

TenantRegistry
      ↓
TenantContext
      ↓
обработка запроса

или:

TenantConfiguration configurationFor(TenantId id)
fun configurationFor(id: TenantId): TenantConfiguration

Приложение всё ещё содержит те же абстракции контроллера, сервиса и репозитория.

Тенант — это данные.

Генерация одного графа объектов фреймворка на тенанта может быть ненужной сложностью.


Списки реализаций — встроенный механизм стратегий

Kora может внедрять коллекции известных реализаций через All<T>.

Это полезно, когда логике приложения нужен реестр.

Предположим, несколько обработчиков реализуют:

interface EventHandler {
    String type();
    void handle(Event event);
}
interface EventHandler {
    fun type(): String
    fun handle(event: Event)
}

Граф может содержать:

UserCreatedHandler
OrderPaidHandler
InvoiceFailedHandler

и реестр может получать все их:

public final class EventHandlerRegistry {

    private final Map<String, EventHandler> handlers;

    public EventHandlerRegistry(All<EventHandler> handlers) {
        this.handlers = handlers.stream()
            .collect(Collectors.toMap(EventHandler::type, Function.identity()));
    }

    public EventHandler handler(String type) {
        return handlers.get(type);
    }
}
class EventHandlerRegistry(handlers: All<EventHandler>) {

    private val handlers: Map<String, EventHandler> =
        handlers.associateBy { it.type() }

    fun handler(type: String): EventHandler? {
        return handlers[type]
    }
}

Выбор времени выполнения теперь динамичен по типу события.

Граф остаётся проверенным.

Этот паттерн покрывает:

  • сериализаторы;
  • платёжных провайдеров;
  • обработчики команд;
  • обработчики событий;
  • движки политик;
  • экспортёры;
  • протокольные адаптеры;
  • стратегии тенантов;
  • валидаторы.

Динамический реестр не требует динамической регистрации DI.


Теги делают несколько известных реализаций явными

Иногда несколько компонентов имеют один и тот же Java-тип, но разные роли.

Например:

основная база данных
база данных-реплика

или:

внутренний HTTP-клиент
внешний HTTP-клиент

или:

основное хранилище объектов
архивное хранилище объектов

Теги Kora делают эти роли явными на этапе компиляции.

Выполнение всё ещё может решать, какую использовать для конкретной операции.

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


Фабрики — статические определения с динамическими входами

Фабричный метод иногда неправильно понимают как константу этапа компиляции.

Это не так.

Определение фабрики известно на этапе компиляции.

Её аргументы — экземпляры компонентов времени выполнения.

Фабрика может конструировать компонент из конфигурации времени выполнения.

Концептуально:

default SearchClient searchClient(SearchConfig config) {
    return new SearchClient(config.endpoint(), config.timeout());
}
fun searchClient(config: SearchConfig): SearchClient {
    return SearchClient(config.endpoint(), config.timeout())
}

Этап компиляции знает:

SearchClient зависит от SearchConfig

Выполнение поставляет:

endpoint = текущий настроенный эндпоинт
timeout  = текущий настроенный таймаут

Если SearchConfig меняется и граф обновляется, SearchClient может быть пересоздан.

Статическая топология зависимостей и фабрики времени выполнения сосуществуют естественно.


@FactoryModule расширяет это до параметризованных семейств компонентов

Фабричные модули Kora позволяют параметризовать переиспользуемое поведение модулей.

Например, одна и та же структура клиентского модуля может быть инстанцирована дважды:

модуль MainDb
модуль ArchiveDb

с разными путями конфигурации и тегами.

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

Опять:

известная форма
+
значения времени выполнения

а не:

неизвестная форма

Условность тоже может быть на этапе компиляции

Kora поддерживает условные компоненты как часть конструирования графа.

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

Но политика времени выполнения не должна автоматически вталкиваться в эти условия.

Есть ценность в вопросе:

Это действительно другая архитектура приложения или просто выбор времени выполнения?

Если feature flag может меняться каждую минуту, он принадлежит поведению приложения.

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

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


Почему сканирование classpath во время выполнения обычно не бизнес-требование

Сканирование во время выполнения часто рассматривается как неотъемлемое свойство сложных фреймворков.

Но спроси, какую бизнес-проблему решает сканирование.

Для обычного сервиса продуктовое требование редко таково:

При запуске процесса ищи на classpath ранее неизвестные классы и выводи, какие из них должны стать компонентами приложения.

Продуктовое требование скорее:

Обслуживай заказы.

Обрабатывай записи Kafka.

Вызывай платёжного провайдера.

Запускай запланированные задачи.

Запрашивай базу данных.

Раскрывай gRPC-эндпоинты.

Сканирование classpath — одна техника реализации для сборки этих возможностей.

Это не сама возможность.

Kora выбирает другую технику реализации: обнаружение на этапе компиляции и сгенерированное связывание.

Если множество исходных компонентов уже известно при сборке артефакта, повторное обнаружение во время выполнения добавляет гибкость, которую многие сервисы никогда не используют.


Статический граф даёт более ранние сбои

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

Предположим, два компонента PaymentProvider удовлетворяют одну нетегированную зависимость.

Контейнер времени выполнения может обнаружить неоднозначность при запуске.

Kora может обнаружить её во время компиляции.

Предположим, зависимость репозитория отсутствует.

Конструирование графа на этапе компиляции падает.

Предположим, цикл зависимостей нельзя безопасно разрешить.

Компиляция падает.

Предположим, внешний модуль забыт.

Компиляция падает.

Это переносит структурную корректность в сборку.

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

Она меняет то, когда снимается неопределённость.


Сюрпризы связывания только в проде становятся труднее

Системы зависимостей времени выполнения могут поддерживать условия на основе:

  • свойств среды;
  • присутствия на classpath;
  • профилей;
  • имён бинов;
  • условных аннотаций;
  • внешних модулей;
  • порядка;
  • автоконфигурации.

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

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

Значения времени выполнения могут различаться.

Топология не молча заново обнаруживается из нового classpath.

Это делает развёрнутый артефакт легче для рассуждений.


Запуск становится более предсказуемым

Сканирование classpath и сборка графа во время выполнения — не только затраты производительности. Это источники изменчивости.

Работа при запуске может зависеть от:

  • размера classpath;
  • рефлексивных метаданных;
  • генерации прокси;
  • условной оценки;
  • состояния среды;
  • обнаружения компонентов;
  • обработки динамических реестров.

Kora выполняет большую часть структурного разрешения до запуска процесса.

Запуск времени выполнения всё ещё имеет реальную работу:

  • инстанцировать компоненты;
  • открыть соединения;
  • инициализировать серверы;
  • запустить хуки жизненного цикла;
  • связаться с внешними системами, где требуется.

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

Это улучшает предсказуемость запуска.


Владение становится легче увидеть

Статический граф, представленный в исходном коде, отвечает на архитектурные вопросы более прямо.

Где создаётся этот компонент?

Посмотри на конструктор или фабрику.

Почему существует этот сервис?

Проследи путь зависимостей.

Какие модули подключены?

Посмотри на @KoraApp и подмодули.

Какая реализация удовлетворяет этот интерфейс?

Проследи разрешение этапа компиляции, теги и фабрики.

Какие компоненты обновятся, если изменится эта конфигурация?

Посмотри на прямые зависимости против ValueOf<T>.

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


Тестирование становится более структурным

Проверенный граф также улучшает тестирование.

Компонентные тесты могут запрашивать известный срез графа.

Зависимости можно заменять явно.

Тест может рассуждать о том, какие компоненты нужны, потому что отношения статичны и типизированы.

Поведение времени выполнения остаётся тестируемым через обычные входы:

  • разную конфигурацию;
  • разные флаги;
  • разный контекст запроса;
  • разные данные тенанта;
  • разное состояние реестра.

Это чисто разделяет два измерения тестирования:

Структурная корректность
      ↓
компилятор + проверка графа

Поведенческая корректность
      ↓
тесты

Тестовому набору не нужно заново обнаруживать каждую ошибку связывания.


Нейро-агенты выигрывают от той же границы

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

С Kora агент может изучить:

  • конструкторы;
  • фабричные методы;
  • модули;
  • теги;
  • сгенерированный код графа;
  • отношения ValueOf<T>.

Затем он может различать:

структурную зависимость

и:

решение времени выполнения

Это различие чрезвычайно полезно во время автоматизированных правок.

Агенту, меняющему логику платёжной маршрутизации, не нужно мутировать топологию DI.

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

Агент, исследующий обновление конфигурации, может явно проследить ребро обновления.

Меньше мутируемого состояния фреймворка означает меньше невидимого контекста для вывода.


Мутируемое состояние фреймворка — это стоимость

Сильно динамический контейнер нуждается во внутреннем мутируемом состоянии, описывающем:

  • зарегистрированные определения;
  • псевдонимы;
  • активные условия;
  • области видимости;
  • текущие прокси;
  • дочерние контексты;
  • обновлённые контексты;
  • постпроцессоры;
  • колбэки жизненного цикла;
  • обнаруженные расширения.

Это состояние может быть полезно.

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

Kora намеренно держит состояние контейнера времени выполнения уже.

Фреймворк всё ещё отслеживает экземпляры компонентов и поддерживает обновление.

Чего он избегает — отношения к архитектуре приложения как к открытому реестру времени выполнения по умолчанию.

Это снижает число движущихся частей, вовлечённых в обычное выполнение приложения.


Динамический выбор принадлежит коду приложения, когда это бизнес-логика

Рассмотрим систему доставки с несколькими перевозчиками.

DHL
UPS
FedEx
LocalCourier

Выбор перевозчика зависит от:

  • пункта назначения;
  • размера посылки;
  • SLA;
  • текущего сбоя;
  • цены;
  • конфигурации мерчанта.

Этот выбор — доменная логика.

Представление его как динамической регистрации бинов спрятало бы бизнес-правило внутри инфраструктуры.

Обычный маршрутизатор стратегий яснее:

public final class ShippingRouter {

    private final Map<Carrier, ShippingProvider> providers;
    private final ShippingPolicy policy;

    public ShippingProvider choose(Shipment shipment) {
        var carrier = policy.carrierFor(shipment);
        return providers.get(carrier);
    }
}
class ShippingRouter(
    private val providers: Map<Carrier, ShippingProvider>,
    private val policy: ShippingPolicy
) {

    fun choose(shipment: Shipment): ShippingProvider? {
        val carrier = policy.carrierFor(shipment)
        return providers[carrier]
    }
}

Фреймворк DI предоставляет возможные стратегии.

Приложение владеет решением.

Это граница, которую поощряет Kora.


Маршрутизация времени выполнения выразительнее условий контейнера

Условия контейнера обычно грубы.

Они отвечают на такие вопросы:

Должно ли существовать это определение компонента?

Бизнес-маршрутизация может отвечать на более богатые вопросы:

Какая реализация для этого запроса?
Какая реализация для этого тенанта?
Какая реализация в этот момент?
Какая реализация после проверки состояния здоровья?
Какая реализация для 5% пользователей?

Как только выбор становится зависимым от запроса, мутация контейнера обычно в любом случае неверная абстракция.

Стабильный маршрутизатор с динамической политикой проще и выразительнее.


Динамические политики повтора не требуют динамических определений AOP

Повтор — ещё один поучительный случай.

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

максимум попыток
задержка
джиттер
повторяемые ошибки
бюджет

Это не значит, что фреймворку нужно генерировать новый граф перехватчиков каждый раз.

Компонент или аспект повтора может обращаться к текущей конфигурации.

Структурное отношение:

Сервис
   ↓
политика повтора
   ↓
HTTP-клиент

может оставаться стабильным.

Параметры политики — состояние времени выполнения.

Это повторяющийся паттерн:

стабильный механизм
+
динамическая политика

Лимиты скорости — это данные

Ограничение скорости ещё яснее.

У сервиса могут быть разные лимиты по:

  • тенанту;
  • эндпоинту;
  • аккаунту;
  • региону;
  • подписке;
  • времени суток.

Реализация лимитера остаётся одним компонентом.

Текущая таблица лимитов — данные.

Создание одного определения DI на лимит было бы абсурдом.

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

Топология DI — только одна часть системы.


Бизнес-правила всегда были динамичными

Стоит сделать шаг назад.

Java-приложения были динамичными задолго до появления фреймворков внедрения зависимостей.

Они использовали:

  • полиморфизм;
  • объекты стратегий;
  • карты;
  • реестры;
  • конечные автоматы;
  • конфигурацию;
  • базы данных;
  • движки правил;
  • очереди;
  • функциональные объекты;
  • таблицы маршрутизации.

DI времени выполнения не изобрёл динамику приложений.

Он ввёл удобный механизм композиции времени выполнения.

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

Но его не следует путать с самим программированием времени выполнения.


Граф может обновляться, не становясь неизвестным

Это, пожалуй, самый интересный средний путь Kora.

На первый взгляд фреймворки часто описывают либо как:

статический контейнер этапа компиляции

либо как:

динамический контейнер времени выполнения

Kora не вписывается в эту бинарность чисто.

Её описание графа проверено на этапе компиляции.

Её экземпляры графа могут меняться во время выполнения.

Более точное представление:

Этап компиляции:
проверенная топология графа

Время выполнения:
набор экземпляров v1
      ↓ обновление
набор экземпляров v2
      ↓ обновление
набор экземпляров v3

Топология ограничивает валидные переходы.

Это отличается от произвольной регистрации времени выполнения.


Распространение обновления предсказуемее, потому что рёбра известны

Если компонент меняется, Kora может пересобрать его прямых зависимых, потому что рёбра зависимостей известны.

Концептуально:

A
↓
B
↓
C

обновить A
→ пересобрать B
→ пересобрать C

Теперь добавим косвенную зависимость:

A
↓
B

ValueOf<B>
    ↓
    C

обновление B не требует пересборки C.

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

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


Жизненный цикл важен при обновлении

Обновление экземпляров — не просто присваивание.

Компоненты могут владеть ресурсами.

Замена клиента базы данных, HTTP-клиента или консьюмера требует семантики жизненного цикла:

сконструировать замену
инициализировать замену
переключить состояние зависимости
освободить старый экземпляр

Граф-рантайм Kora уже владеет жизненным циклом компонентов, поэтому обновление может оставаться осведомлённым о жизненном цикле.

Это важно, потому что «динамическую перенастройку» часто представляют так, будто изменения ссылки на объект достаточно.

Производственным компонентам нужны безопасные переходы.

Статическая топология помогает, потому что контейнер уже понимает порядок зависимостей.


Стабильные компоненты могут наблюдать новые зависимости

Механизм ValueOf<T> мощен, потому что избегает каскадного обновления дальше, чем необходимо.

Рассмотрим:

Config
  ↓
TlsConfig
  ↓
ExternalClient
  ↓
ValueOf<ExternalClient>
  ↓
RequestProcessor

Внешний клиент может быть пересоздан после ротации сертификата.

Процессор запросов может оставаться живым.

При следующей операции он обращается к текущему клиенту.

Это операционная динамика с контролируемым радиусом поражения.


Обновление — не замена каждому динамическому паттерну

Обновление графа полезно, но не должно становиться ответом на каждое меняющееся значение.

Если feature flag меняется тысячи раз в секунду, не обновляй граф приложения тысячи раз в секунду.

Если таблица маршрутизации меняется часто, держи её в реестре времени выполнения.

Если рыночные данные меняются непрерывно, это данные.

Если поиск конфигурации тенанта зависит от запроса, запрашивай сервис конфигурации тенантов.

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

Это различие не даёт обновлению стать скрытой моделью программирования времени выполнения.


Три уровня изменений

Полезная структура решений:

Уровень 1: изменения данных

Примеры:

  • заказы;
  • пользователи;
  • цены;
  • флаги;
  • записи маршрутизации;
  • квоты.

Используй обычное состояние времени выполнения.

те же компоненты
другие данные

Уровень 2: изменения конфигурации компонентов

Примеры:

  • эндпоинт;
  • учётные данные;
  • размер пула;
  • TLS-материал;
  • статическая конфигурация политики.

Используй динамическую конфигурацию, обновляемые компоненты, ValueOf<T> или поставщиков с состоянием.

та же топология
новое состояние или экземпляр компонента

Уровень 3: изменения типов/топологии компонентов

Примеры:

  • загрузить ранее неизвестный JAR плагина;
  • обнаружить новый класс реализации;
  • зарегистрировать новое определение фреймворка;
  • мутировать множество типов компонентов.

Это настоящая территория динамического контейнера.

новая топология

Большинство бэкенд-систем живут подавляюще в уровнях 1 и 2.


Реальный случай использования динамических плагинов существует

Есть приложения, где статический граф Kora действительно менее естественен.

Рассмотрим IDE.

Продукт может загружать сторонние плагины после установки.

Эти плагины могут вносить:

  • действия;
  • редакторы;
  • языковые сервисы;
  • меню;
  • типы проектов;
  • инспекции;
  • окна инструментов.

Хост не может знать каждый класс плагина при компиляции.

Граф расширений — часть продукта.

Это реальная топология времени выполнения.


Серверы приложений — ещё один пример

Универсальный сервер приложений может развёртывать произвольные приложения после установки самого сервера.

Роль сервера явно в том, чтобы размещать неизвестный код.

Обнаружение во время выполнения и изоляция загрузчиков классов — основные требования.

Статический граф этапа компиляции для хоста не может описать приложения, которых ещё не существует.

Это принципиально другая проблема, чем развёртываемый REST-сервис.


Маркетплейсы плагинов нуждаются в неизвестных расширениях

Десктопные платформы с маркетплейсами плагинов тоже нуждаются в динамическом обнаружении расширений.

Рабочий процесс может быть:

скачать плагин
      ↓
загрузить JAR
      ↓
изучить манифест/классы
      ↓
зарегистрировать точки расширения
      ↓
инстанцировать сервисы плагина

Здесь сам граф зависимостей действительно является данными времени выполнения.

Может подойти архитектура плагинов времени выполнения, система в стиле OSGi, платформа с пользовательскими загрузчиками классов или динамический контейнер DI.


Некоторые движки рабочих процессов могут нуждаться в динамическом коде

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

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

Это настоящая динамическая расширяемость.

Различие остаётся:

динамические данные рабочего процесса

не требуют топологии времени выполнения,

но:

динамически загружаемый код рабочего процесса

может требовать.


Контейнеры, чей продукт — расширяемость, другие

Центральное правило можно сформулировать так:

Гибкость DI времени выполнения ценна, когда сам граф зависимостей — часть продукта. Для большинства бэкенд-сервисов это не так.

IDE продаёт расширяемость.

Хост плагинов продаёт расширяемость.

Сервер приложений существует, чтобы загружать неизвестные приложения.

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

Это разные категории ПО.


REST-сервисы редко нуждаются в неизвестных компонентах

Возьмём обычный REST-сервис.

Маршруты могут управляться данными.

Политика аутентификации может меняться.

Feature flags могут меняться.

Тенанты могут меняться.

Содержимое базы данных, очевидно, меняется.

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

Когда архитектура меняется, команда собирает и развёртывает новый артефакт.

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

Топология этапа компиляции естественно вписывается в этот жизненный цикл.


Kafka-консьюмеры тоже структурно стабильны

Kafka-сервис может подписываться на динамические назначения партиций и обрабатывать неограниченные данные событий.

Это чрезвычайно динамично операционно.

Но его структура обычно такова:

Kafka-консьюмер
      ↓
обработчик записей
      ↓
доменный сервис
      ↓
репозиторий

Изменения назначения партиций — состояние времени выполнения.

Записи консьюмера — данные времени выполнения.

Смещения движутся непрерывно.

Никакой мутации графа зависимостей не требуется.


API-шлюзы динамичны без динамического DI

API-шлюз может маршрутизировать на основе конфигурации, загруженной из control plane.

Таблица маршрутов может меняться каждую секунду.

Тем не менее архитектура может оставаться:

HTTP-сервер
      ↓
RouteRegistry
      ↓
ProxyHandler
      ↓
HTTP-клиент

RouteRegistry — динамический компонент.

Регистрация одного бина DI на маршрут была бы ненужным представлением данных как архитектуры.


Воркеры и планировщики следуют тому же паттерну

Воркер может получать динамически определённые задачи.

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

Опять же, модель компонентов стабильна:

планировщик
   ↓
реестр задач
   ↓
исполнитель

Запланированные записи — данные.

Мутируемый граф времени выполнения не является по своей сути полезным.


Структура этапа компиляции улучшает анализ архитектуры

У статического графа есть ещё одно преимущество: его можно анализировать до выполнения.

Поскольку зависимости известны, инструменты могут рассуждать о:

  • достижимых компонентах;
  • неиспользуемых компонентах;
  • цепочках зависимостей;
  • циклах;
  • порядке жизненного цикла;
  • границах модулей;
  • диаграммах архитектуры;
  • владении компонентами.

Сгенерированный код графа также можно изучать.

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


Динамические контейнеры обменивают ранние знания на свободу выполнения

Это не по своей сути плохо.

Динамический контейнер предлагает более широкий набор возможностей времени выполнения:

обнаруживать
регистрировать
заменять
обуславливать
декорировать
перенастраивать определения

Но эти возможности имеют цену.

Некоторые отношения становятся известны только после запуска.

Некоторые ошибки переходят с этапа компиляции на выполнение.

Фреймворку могут понадобиться рефлексия, сканирование метаданных, оценка условий или мутируемые реестры.

Разработчикам может понадобиться понимать не только исходный код, но и собранное состояние контейнера.

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

Если нет, гибкость может быть в основном латентной сложностью.


У гибкости есть стоимость опциона

Инженеры часто оценивают гибкость так, будто больше автоматически лучше.

Но неиспользуемая гибкость всё равно имеет затраты:

  • большая концептуальная модель;
  • больше состояний выполнения;
  • больше режимов сбоя;
  • более позднее обнаружение ошибок;
  • больше нужной диагностики;
  • больше скрытых взаимодействий конфигурации;
  • больше мутируемого состояния фреймворка.

Граф этапа компиляции намеренно отклоняет некоторые возможности.

Взамен он превращает предположения в гарантии.

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


«Но как насчёт профилей?»

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

Это удобно, но многие различия фактически не требуют динамической топологии.

Например:

URL продакшн-базы данных
URL staging-базы данных
URL локальной базы данных

это конфигурация.

Разные учётные данные — конфигурация.

Разные таймауты — конфигурация.

Разные внешние эндпоинты — конфигурация.

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

Это не даёт конфигурации развёртывания стать неявным языком архитектуры.


Развёртывание уже механизм изменения топологии

Здесь есть более широкое операционное наблюдение.

В современных бэкенд-системах изменение структуры приложения обычно означает:

изменить код
↓
скомпилировать
↓
протестировать
↓
собрать артефакт
↓
развернуть новую версию

Kubernetes, rolling-деплои и неизменяемые образы усиливают эту модель.

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

Они не внедряют новое определение класса в работающий процесс.

Конструирование графа на этапе компиляции согласуется с этой реальностью.


Конфигурация времени выполнения тоже не должна заменять развёртывание

Существует и противоположная ошибка.

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

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

Динамическую возможность следует использовать там, где динамическое изменение действительно снижает операционную стоимость.

Та же дисциплина, которая отвергает ненужный DI времени выполнения, должна отвергать ненужное живое обновление.


Статические графы улучшают воспроизводимость

Скомпилированный граф вносит вклад в воспроизводимость, потому что два процесса, запускающие один и тот же артефакт, имеют одну и ту же структурную модель компонентов.

Данные времени выполнения могут различаться.

Конфигурация может различаться.

Но возможная топология зависимостей ограничена артефактом.

Это помогает отвечать на такие вопросы:

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

В обычной модели Kora — нет, если только этот тип не был частью скомпилированной структуры приложения.

Это снижает одно измерение дрейфа среды.


Конфигурация всё ещё может вызывать разные экземпляры

Структурная воспроизводимость не подразумевает идентичные экземпляры.

Два развёртывания могут использовать:

разные эндпоинты
разные учётные данные
разные пулы баз данных
разные настройки телеметрии
разные политики маршрутизации

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

Это желательно.

Архитектура и среда остаются раздельными задачами.


Статическая топология упрощает рассуждения о безопасности

Загрузка расширений во время выполнения имеет последствия для безопасности.

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

Статический граф означает, что исполняемые типы компонентов определяются собранным артефактом.

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

Для обычных бэкенд-сервисов это обычно упрощение.


Наблюдаемость выигрывает от явных компонентов

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

Клиент базы данных — известный компонент графа.

HTTP-сервер — известный компонент графа.

Консьюмер — известный компонент графа.

Событие обновления можно связывать с известными узлами.

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

Опять же, данные времени выполнения остаются динамичными.

Архитектура фреймворка остаётся читаемой.


Что происходит во время обновления конфигурации?

Стоит изучить механику концептуально.

Предположим:

ConfigOrigin
    ↓
Config
    ↓
SearchConfig
    ↓
SearchClient
    ↓
SearchService

Наблюдатель файлов видит изменение источника конфигурации.

Он инициирует обновление от соответствующего узла.

Обновлённая конфигурация читается.

Kora определяет нижестоящих прямых зависимых.

Эти компоненты пересобираются в соответствии со своими фабриками.

Жизненный цикл применяется, где уместно.

Если SearchService напрямую зависит от SearchClient, он может быть пересобран.

Если вместо этого он получает ValueOf<SearchClient>, он может оставаться стабильным.

Топология не переобнаруживается.

Существующая топология определяет распространение обновления.

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


Пример обновления времени выполнения

Представьте этот упрощённый модуль:

@Module
public interface SearchModule {

    default SearchConfig searchConfig(Config config) {
        return readSearchConfig(config);
    }

    default SearchClient searchClient(SearchConfig config) {
        return new SearchClient(config.endpoint());
    }

    default SearchService searchService(ValueOf<SearchClient> client) {
        return new SearchService(client);
    }
}
@Module
interface SearchModule {

    fun searchConfig(config: Config): SearchConfig {
        return readSearchConfig(config)
    }

    fun searchClient(config: SearchConfig): SearchClient {
        return SearchClient(config.endpoint())
    }

    fun searchService(client: ValueOf<SearchClient>): SearchService {
        return SearchService(client)
    }
}

При запуске:

SearchConfig(endpoint=A)
      ↓
SearchClient(A)
      ↓
ValueOf
      ↓
SearchService

После изменения конфигурации:

SearchConfig(endpoint=B)
      ↓
SearchClient(B)
      ↓
ValueOf
      ↓
тот же SearchService

SearchService может вызывать:

client.get()
client.get()

и получать текущего клиента.

Это конкретный пример:

Топология этапа компиляции не подразумевает неизменяемые экземпляры времени выполнения.


Не внедряй сырую конфигурацию повсюду

Руководство Kora по конфигурации подкрепляет эту модель.

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

Лучший подход — типизированная конфигурация:

Config
 ├── DatabaseConfig
 ├── HttpClientConfig
 ├── FeatureConfig
 └── CacheConfig

Тогда каждая подсистема зависит только от своей релевантной конфигурации.

Это даёт обновлению более точные границы.

Это также делает владение конфигурацией явным.

Динамика времени выполнения становится легче контролировать, потому что она типизирована и локализована.


ValueOf<Config> существует для действительно динамических чтений

Иногда компоненту действительно нужен обобщённый или динамический доступ к конфигурации.

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

Это ещё один пример того, что модель намеренно гибка:

известная на этапе компиляции зависимость:
ValueOf<Config>

поведение времени выполнения:
читать текущую конфигурацию динамически

Опять же, фреймворк не запрещает логику конфигурации времени выполнения.

Он делает отношение явным.


PromiseOf<T> покрывает отложенный доступ

У Kora также есть PromiseOf<T> для низкоуровневых случаев, когда компоненту нужен отложенный доступ к другой части графа.

В отличие от обычной прямой зависимости, значение может быть ещё недоступно в самой ранней точке.

Это полезно для инфраструктурных сценариев и разрыва циклов.

Существование ValueOf<T> и PromiseOf<T> иллюстрирует, что проверка графа на этапе компиляции не означает, что каждая ссылка времени выполнения должна быть жадно зафиксирована.

Граф может представлять разную семантику зависимостей.


Прямые рёбра несут смысл жизненного цикла

Самый интересный способ думать об этом — что рёбра графа не только про поиск.

Они кодируют связанность жизненного цикла.

A → B

означает больше, чем:

A может найти B

Это может означать:

A был создан с использованием B
поэтому изменение B может потребовать изменения A

ValueOf<B> ослабляет эту связанность жизненного цикла.

Это делает объявления зависимостей семантически богаче, чем локатор сервисов.


Динамические реестры часто лучше динамического DI

Предположим, система поддерживает 100 шаблонов уведомлений, определяемых тенантами.

Динамическая интерпретация DI могла бы создавать компоненты для каждого шаблона.

Более простая модель:

NotificationTemplateRegistry
         ↓
Map<TenantId, Template>

Реестр может обновляться из базы данных или удалённого control plane.

Граф остаётся маленьким.

Данные остаются данными.

Этот принцип применяется широко:

маршруты       → RouteRegistry
тенанты        → TenantRegistry
политики       → PolicyRegistry
эндпоинты      → EndpointRegistry
модели         → ModelRegistry
обработчики    → известный список обработчиков + карта времени выполнения

Контейнеру DI не нужно моделировать каждый фрагмент состояния времени выполнения как определение компонента.


Почему это легче тестировать

Реестры времени выполнения и маршрутизаторы стратегий — обычные объекты.

Их можно тестировать обычными модульными тестами.

Например:

дан feature flag OFF
ожидай классического провайдера

дан feature flag ON
ожидай провайдера нейро-технологий

или:

дан тенант A
выбери эндпоинт X

дан тенант B
выбери эндпоинт Y

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

Поэтому перенос динамики в явные компоненты улучшает тестируемость.


Почему это легче отлаживать

Предположим, запрос неожиданно ушёл в Adyen вместо Stripe.

С явной логикой маршрутизации изучи:

PaymentRouter
конфигурацию маршрутизации
feature flags
контекст запроса

С мутацией DI времени выполнения вопрос может стать:

Какой компонент провайдера был зарегистрирован?
Какое условие активировалось?
Когда контейнер обновился?
Какое определение победило?
Какой профиль был активен?

Первая модель держит бизнес-решения ближе к бизнес-коду.

Это обычно легче диагностировать.


Почему это легче для ревью кода

Ревьюер кода, видящий:

switch (config.provider()) {
    case STRIPE -> stripeProvider;
    case ADYEN -> adyenProvider;
}
when (config.provider()) {
    Provider.STRIPE -> stripeProvider
    Provider.ADYEN -> adyenProvider
}

может немедленно понять выбор времени выполнения.

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

Философия Kora предпочитает явную логику приложения, когда выбор времени выполнения действительно является логикой приложения.


Графы этапа компиляции — не «менее корпоративные»

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

Современные контейнеризованные сервисы имеют другие ограничения.

Они обычно:

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

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


Микросервисы усиливают аргумент

Монолитному серверу приложений, размещающему неизвестные приложения, выгодно обнаружение во время выполнения.

Микросервис обычно имеет противоположное свойство: каждая развёртываемая единица намеренно узкая.

Артефакт уже существует именно для определения одного сервиса.

Это делает генерацию графа на этапе компиляции особенно естественной.

Сама граница сервиса снизила потребность в открытом контейнере времени выполнения.


Но микросервисам всё ещё нужна перенастройка во время выполнения

Ничто из этого не означает «пересобирай при каждом изменении значения».

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

Примеры:

  • уровни логирования;
  • маршрутизация трафика;
  • feature flags;
  • учётные данные;
  • эндпоинты;
  • политики таймаутов;
  • параметры пулов;
  • конфигурация тенантов.

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

Вот почему упрощённое противопоставление:

этап компиляции = неизменяемо
DI времени выполнения = динамично

неточно.


Лучшее сравнение — структурная против поведенческой динамики

Более полезная матрица:

Требование Нужен динамический граф?
Оценка feature flags Обычно нет
Удалённая конфигурация Обычно нет
Отказоустойчивость эндпоинтов Обычно нет
Ротация учётных данных Обычно нет
Маршрутизация тенантов Обычно нет
Изменения лимитов скорости Обычно нет
Изменения политики повтора Обычно нет
Изменения бизнес-правил из данных Обычно нет
Обновление настроенных экземпляров клиентов Нет, если поддерживается обновление графа
Выбор среди известных стратегий Нет
Загрузка неизвестных классов из нового JAR Да, потенциально
Маркетплейс плагинов времени выполнения Да, потенциально
Размещение произвольных приложений Да, потенциально

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


Специализированную границу следует честно назвать

Модель Kora не универсально лучше.

Если требования приложения говорят:

Скачать JAR, которого не существовало при компиляции этого артефакта.

то:

граф этапа компиляции

не может магически знать его классы.

Если приложению затем нужно:

обнаружить произвольные реализации
      ↓
зарегистрировать определения компонентов
      ↓
разрешить новые зависимости
      ↓
создать области видимости/прокси
      ↓
мутировать топологию

уместна динамическая система плагинов.

Контейнер DI времени выполнения может помочь.

OSGi может помочь.

Выделенная архитектура плагинов может быть ещё лучше.

Важно то, что это специализированное архитектурное требование, а не свойство серверных приложений по умолчанию.


Границу можно провести точно

Kora хорошо справляется с этим классом систем:

Известные типы компонентов
        +
динамическая конфигурация
        +
обновление графа времени выполнения
        +
выбор стратегии времени выполнения
        +
feature flags
        +
динамические данные
        +
динамическое внешнее состояние

Другая архитектура может быть предпочтительнее для:

Скачать неизвестный JAR во время выполнения
        ↓
обнаружить неизвестные классы
        ↓
зарегистрировать новые определения компонентов
        ↓
изменить топологию зависимостей

Это действительно разные домены проблем.


Статический граф — это ограничение, и ограничения могут быть ценными

Kora намеренно убирает одну степень свободы времени выполнения.

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

Это ограничение.

Но ограничения не автоматически недостатки.

Статическая система типов Java — тоже ограничение.

Схемы баз данных — ограничения.

Схемы API — ограничения.

Границы модулей — ограничения.

Они обменивают часть гибкости на более ранние гарантии.

Правильный вопрос — была ли нужна отвергнутая гибкость.

Для большинства сервисов произвольная мутация топологии времени выполнения — нет.


Проверка на этапе компиляции превращает архитектуру в контракт

Как только граф построен заранее, он становится частью контракта скомпилированного приложения.

Компилятор может утверждать:

каждая требуемая зависимость существует
каждая выбранная реализация однозначна
циклы следуют валидным правилам
теги совпадают
фабрики валидны

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

Архитектура была проверена.

Выполнение может затем сосредоточиться на состоянии.


Это снижает число классов сбоев времени выполнения

Kora не может убрать сбои времени выполнения.

Базы данных падают.

Конфигурация может быть недействительной.

Учётные данные истекают.

Сети разделяются.

Код выбрасывает исключения.

Но некоторым сбоям больше не нужно существовать во время выполнения:

отсутствующая зависимость
неоднозначная зависимость
недопустимый структурный цикл
необъявленный компонент

Они становятся ошибками сборки.

Это конкретное преимущество надёжности статической топологии.


Динамическое поведение всё ещё падает динамически, как и должно

Если Stripe недоступен, это сбой времени выполнения.

Если сервис feature flags истекает по таймауту, это сбой времени выполнения.

Если тенант не существует, это условие времени выполнения.

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

DI этапа компиляции не должен притворяться, что может устранить неопределённость, которая действительно существует только во время выполнения.

Модель Kora сильнее всего именно потому, что ей это не нужно.

Она проверяет то, что можно знать.

Остальное оставляет динамичным.


Архитектура становится более объяснимой

Это даёт полезное свойство: приложение можно объяснять по слоям.

Архитектура:
граф этапа компиляции

Текущее развёртывание:
конфигурация времени выполнения

Текущее состояние компонентов:
экземпляры графа

Поведение текущего запроса:
бизнес-логика + состояние + данные

У каждого вопроса другой источник истины.

Об этом легче рассуждать, чем относиться ко всем четырём как к одному состоянию контейнера.


Обновление графа времени выполнения — контролируемая мутация

Было бы также неверно описывать Kora как не имеющую вообще никакого мутируемого состояния фреймворка.

Граф времени выполнения хранит экземпляры компонентов.

Обновление может их заменять.

Жизненный цикл выполняется.

Косвенные ссылки раскрывают текущие значения.

Различие в том, что мутация происходит внутри топологии, чья структура уже известна.

Это контролируемая мутация, а не открытое структурное обнаружение.

Это более точное утверждение.


Почему эта модель хорошо вписывается в Kubernetes

Kubernetes уже относится к артефактам приложений как к одноразовым единицам.

Структурные изменения обычно приходят через новые образы.

Операционное состояние времени выполнения приходит через:

  • ConfigMaps;
  • Secrets;
  • обнаружение сервисов;
  • переменные среды;
  • удалённые control plane;
  • API.

Разделение Kora естественно ложится на этот мир:

образ
→ известная архитектура

конфигурация/control plane времени выполнения
→ текущее состояние и поведение

Отслеживание файлов и осведомлённое о симлинках обновление конфигурации также могут согласовываться с обновлениями смонтированной конфигурации.

Фреймворку не нужно обнаружение classpath во время выполнения, чтобы участвовать в облачной перенастройке.


Изменение ConfigMap — не новая архитектура приложения

Это звучит очевидно, когда сформулировано прямо.

Если ConfigMap Kubernetes меняется:

timeout = 2s

на:

timeout = 5s

приложение не получило новый архитектурный компонент.

Изменилась политика таймаута.

Аналогично, ротация Secret не создала новый тип репозитория.

Изменение уровня логирования не потребовало новой реализации логгера.

Как только это различие сформулировано чётко, статический дизайн графа выглядит гораздо менее ограничительным.


Перенастройка времени выполнения должна иметь определённый радиус поражения

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

Если узел конфигурации меняется:

какие компоненты пересобираются?
какие компоненты остаются живыми?
какие ресурсы перезапускаются?

Это значимые производственные вопросы.

ValueOf<T> предоставляет инструмент для намеренной остановки распространения обновления там, где стабильный компонент должен вместо этого наблюдать новое значение.

Это более контролируемо, чем глобальное обновление непрозрачного контекста.


Дизайн обновления становится частью архитектуры

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

Она сообщает поведение обновления времени выполнения.

Сравни:

SearchService(SearchClient client)
SearchService(client: SearchClient)

с:

SearchService(ValueOf<SearchClient> client)
SearchService(client: ValueOf<SearchClient>)

Первое говорит:

Жизненный цикл SearchService следует за SearchClient.

Второе говорит:

SearchService переживает замену SearchClient и использует того клиента, который сейчас актуален.

Это явная архитектура.


Эта модель поощряет обдуманную живую перенастройку

Живая перенастройка может быть опасной, когда каждая зависимость автоматически становится горячо-перезагружаемой.

Некоторые объекты безопасно заменять.

Некоторые — нет.

Некоторые долгие операции должны дренироваться.

Некоторым клиентам нужен корректный shutdown.

Некоторые серверы должны оставаться стабильными.

Некоторые компоненты с состоянием не следует пересоздавать автоматически.

Делая отношения обновления видимыми, Kora поощряет команды обдумывать эти переходы намеренно.


Выбор времени выполнения может быть по-запросным

Ещё одна причина, почему динамический DI часто ненужен, — гранулярность.

Выбор на уровне контейнера обычно общеприложен или обще-контекстен.

Бизнес-маршрутизация часто по-запросная.

Предположим, половина трафика должна идти к провайдеру A, а половина к B.

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

Маршрутизатор может выбирать на каждый вызов:

запрос 1 → A
запрос 2 → B
запрос 3 → A
запрос 4 → B

Статический граф + выбор времени выполнения фактически более динамичны на уровне поведения.


Известные реализации часто являются функцией

Знание всех возможных стратегий на этапе компиляции — не ограничение, когда эти стратегии — код, поддерживаемый командой сервиса.

Это позволяет компилятору их проверять.

Это позволяет тестам их перечислять.

Это позволяет наблюдаемости их называть.

Это позволяет ревью кода их видеть.

Выполнение затем выбирает из проверенного множества.

Это часто ровно то, чего хотят команды.


Неизвестные данные и неизвестный код — разные риски

Бэкенд-системы регулярно принимают неизвестные данные.

В этом смысл сервера.

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

Их не следует смешивать.

неизвестный JSON запроса

нормально.

неизвестный JAR, загруженный в процесс

это система плагинов.

Большинству сервисов нужно первое.

Немногим нужно второе.

Статический граф приложения полностью совместим с первым.


Динамические графы могут быть ценны внутри фреймворков, а не приложений

Есть также момент архитектурного наслоения.

Универсальному фреймворку могут понадобиться динамические средства внутренне, потому что он поддерживает много форм приложений.

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

Kora выбирает более агрессивную специализацию под конструирование приложений.

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

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


Стоимость обобщённой сборки времени выполнения платит каждый сервис

Обобщённому контейнеру времени выполнения нужно нести возможности, которые конкретный сервис может никогда не использовать.

Это может включать:

  • сканирование;
  • метаданные;
  • условное разрешение;
  • механику прокси;
  • мутируемые реестры бинов;
  • обобщённую координацию жизненного цикла.

Система этапа компиляции может специализировать результат под сервис.

Сгенерированный граф содержит связывание, которое приложению действительно нужно.

Это аналогично частичной оценке: известные структурные решения разрешаются рано.


У выполнения всё ещё есть контейнер, просто другая работа

Было бы неточно сказать, что у Kora вообще нет механики графа времени выполнения.

Выполнение:

  • создаёт экземпляры;
  • инициализирует их;
  • освобождает их;
  • раскрывает доступ к графу;
  • поддерживает обновление;
  • управляет жизненным циклом;
  • поддерживает косвенные ссылки.

Что ему не нужно делать — заново обнаруживать определение приложения с нуля.

Работа контейнера времени выполнения — исполнение и жизненный цикл, а не архитектурный вывод.


Это разделение — ключевое решение дизайна

Самое чистое резюме:

Компилятор:
Что может существовать?
Как это соединено?
Валидна ли структура?

Выполнение:
Каковы текущие экземпляры?
Какова текущая конфигурация?
Какое поведение должно выполниться сейчас?

Две фазы сотрудничают.

Они не конкурируют.


А как насчёт A/B-экспериментов?

A/B-эксперименты иногда рассматривают как доказательство того, что приложения должны динамически менять компоненты.

Но эксперименты обычно решения уровня запроса.

Предположим:

AlgorithmA
AlgorithmB
ExperimentAllocator

Граф содержит все три.

Во время выполнения:

пользователь 123 → вариант A
пользователь 456 → вариант B

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


А как насчёт blue/green внешних систем?

Предположим, сервис мигрирует с базы данных A на базу данных B.

Оба клиента могут существовать одновременно:

DatabaseA
DatabaseB
MigrationRouter

Маршрутизатор может:

  • читать из A;
  • двойную запись;
  • сравнивать чтения;
  • сдвигать проценты;
  • в конце концов маршрутизировать в B.

Эта миграция по своей природе динамична.

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

Когда миграция завершена, убери A в следующем развёртывании.

Никакой мутации топологии времени выполнения не требовалось.


А как насчёт замены клиента во время выполнения?

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

Вместо хранения двух известных реализаций и маршрутизации между ними приложению нужен новый экземпляр того же типа компонента.

Это:

Client₁
  ↓ обновление
Client₂

Топология остаётся:

Service → Client

Это ровно то, для чего предназначено обновление экземпляров.


Выбор и обновление — разные инструменты

Полезно держать два механизма раздельными.

Выбор времени выполнения:

ProviderA ─┐
           ├─ Router → выбрать один на операцию
ProviderB ─┘

Обновление времени выполнения:

Config v1 → Client₁
              ↓
           обновление
              ↓
Config v2 → Client₂

Оба сохраняют топологию.

Они решают разные динамические требования.


Реестры добавляют третий инструмент

Реестр обрабатывает меняющийся набор определяемых данными записей:

Реестр
├── тенант A → политика
├── тенант B → политика
└── тенант C → политика

Компонент реестра статичен.

Его содержимое динамично.

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


Статическая топология особенно дружелюбна к наблюдаемости

Поскольку множество типов компонентов ограничено, метрики и трейсы можно проектировать вокруг стабильных категорий.

Метки времени выполнения могут представлять:

  • провайдера;
  • класс тенанта;
  • маршрут;
  • результат;
  • регион.

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

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


Меньшее пространство состояний выполнения легче эксплуатировать

Производственная надёжность отчасти про ограничение пространства состояний.

Система, где:

код
конфигурация
данные
определения компонентов
загруженные плагины
условные регистрации

всё может независимо меняться во время выполнения, имеет больше возможных состояний.

Иногда эта гибкость требуется.

Когда нет, ограничение одного измерения помогает.

Kora ограничивает топологию компонентов.

Данные, конфигурация и состояние приложения остаются динамичными.

Это может сделать инциденты легче воспроизводимыми.


«Статичный» — эмоционально неверное слово

Слово статичный часто звучит как:

негибкий
жёстко закодированный
нельзя перезагрузить
нужен перезапуск

Это не то, что здесь означает граф этапа компиляции.

Более точная фраза:

предварительно проверенная структурная топология

Приложение всё ещё может иметь:

  • мутируемое состояние;
  • живую конфигурацию;
  • обновляемые компоненты;
  • маршрутизацию времени выполнения;
  • внешние control plane;
  • динамические реестры;
  • feature flags.

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


Модель Kora ближе к типизированному графу потоков данных

Один из способов думать о графе приложения — как о типизированном потоке данных.

Фабрики определяют преобразования:

Config
  ↓
DatabaseConfig
  ↓
DataSource
  ↓
Repository
  ↓
Service

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

Во время выполнения значения текут через эти узлы.

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

ValueOf<T> создаёт косвенную ссылку, которая наблюдает текущий результат, не заставляя пересчитывать ниже по потоку.

Эта ментальная модель объясняет обновление времени выполнения точнее, чем фраза «статический контейнер».


Есть параллель с функциональными реактивными системами

Не утверждая, что Kora — функциональный реактивный фреймворк, семантика обновления имеет знакомую форму:

известный граф зависимостей
+
меняющееся значение выше по потоку
→ пересчитать затронутые значения ниже по потоку

Структура графа остаётся известной.

Значения меняются.

Это распространённый инженерный паттерн, потому что он позволяет эффективное, предсказуемое распространение.

Kora применяет родственную идею к жизненному циклу компонентов.


Почему это важно для сложности фреймворка

Контейнер обнаружения времени выполнения должен отвечать:

Какие компоненты существуют?

во время выполнения.

Kora отвечает на это раньше.

Выполнение может поэтому сосредоточиться на:

Какие экземпляры сейчас актуальны?

и:

Какие известные узлы затронуты этим изменением?

Это более узкие проблемы.

Более узкие обязанности выполнения часто означают меньше механики.


Нужно ли обновлять всё?

Нет.

Здесь важны типизированная конфигурация и косвенные зависимости.

Хорошо спроектированный граф должен локализовать обновления.

Например:

Config
├── LoggingConfig → Logging
├── DbConfig → DataSource → Repository
└── SearchConfig → SearchClient

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

Изменение эндпоинта поиска не должно пересоздавать несвязанные бизнес-компоненты, если только они не держат прямые зависимости времени конструирования от клиента.

Поэтому дизайн графа влияет на стоимость обновления.


Обновление времени выполнения — не «магическая горячая перезагрузка»

Также важно не переоценивать функцию.

Kora не означает:

Измени произвольный исходный код Java, и работающий процесс прозрачно станет новым приложением.

Обновление графа работает с известными фабриками и зависимостями.

Если меняется структура исходного кода, пересобирай и переразвёртывай.

Если меняется конфигурация времени выполнения, затронутые экземпляры могут обновляться.

Эта граница ясна и операционно разумна.


Изменение исходного кода и изменение конфигурации — разные вещи

Это различие стоит сделать явным:

Изменить архитектуру Java/Kotlin
→ скомпилировать
→ развернуть

Изменить поддерживаемую конфигурацию времени выполнения
→ обновить/переоценить
→ продолжить

Попытка полностью стереть эту границу обычно требует сложных систем горячей перезагрузки кода.

Большинству производственных команд они не нужны.


Это также помогает операциям при поддержке нейро-технологий

нейро-агент, отлаживающий живую систему, может рассуждать из стабильной архитектуры.

Он может спросить:

Какой узел конфигурации изменился?
Какие прямые зависимые обновляются?
Какие рёбра ValueOf останавливают распространение?
Какой экземпляр компонента сейчас актуален?

Это ограниченная проблема.

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

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


Статический граф — не отказ от внедрения зависимостей

Kora по-прежнему широко использует внедрение зависимостей.

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

Это сохраняет преимущества DI:

  • инверсию управления;
  • тестируемые конструкторные зависимости;
  • модульные фабрики;
  • замену реализаций;
  • управление жизненным циклом;
  • интеграцию конфигурации.

Оно просто переносит валидацию и конструирование графа раньше.


Главный компромисс

Компромисс можно сформулировать прямо.

Kora отказывается от:

произвольной регистрации во время выполнения неизвестных определений зависимостей

чтобы получить:

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

Хорош ли этот компромисс, зависит от продукта.

Для хоста плагинов, возможно, нет.

Для REST-сервиса обычно да.


Контрольный список решений

Прежде чем заключить, что сервису нужен динамический DI, спроси:

  1. Будет ли процесс загружать классы реализаций, которых не было при сборке артефакта?
  2. Должны ли пользователи устанавливать исполняемые плагины без пересборки сервиса?
  3. Должны ли эти плагины вносить произвольные определения зависимостей?
  4. Является ли сам граф зависимостей пользовательским состоянием продукта?
  5. Решил бы проблему более прямо реестр времени выполнения, маршрутизатор стратегий или feature flag?
  6. Является ли меняющаяся вещь на самом деле данными или конфигурацией?
  7. Нужно ли клиенту просто пересоздаваться при изменении конфигурации?
  8. Мог бы ValueOf<T> держать долгоживущие компоненты стабильными во время этого обновления?
  9. Могли бы All<T> или теги представлять известное множество стратегий?
  10. Могло бы развёртывание оставаться механизмом структурных изменений кода?

Если первые четыре ответа «нет», динамический граф приложения может не решать реальное требование.


Более точный архитектурный словарь

Команды могут улучшить обсуждения дизайна, используя более точные термины.

Вместо:

Нам нужно, чтобы приложение было динамичным.

скажи:

Нам нужна оценка функций во время выполнения.

или:

Нам нужно обновление эндпоинтов.

или:

Нам нужна ротация учётных данных.

или:

Нам нужен выбор провайдера на запрос.

или:

Нам нужно загружать неизвестный байткод плагинов.

Эти требования ведут к очень разным архитектурам.

Только последнее явно подразумевает неизвестную топологию компонентов времени выполнения.


Самый большой миф: этап компиляции означает, что всё заморожено

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

Это просто неверная модель.

Kora компилирует структурное отношение.

Выполнение всё ещё владеет жизненным циклом экземпляров.

Конфигурация может меняться.

Граф может обновлять затронутые узлы.

ValueOf<T> может раскрывать текущие экземпляры компонентам, которые сами не должны перезапускаться.

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

Реестры могут менять содержимое.

Внешнее состояние может эволюционировать произвольно.

Feature flags могут мгновенно менять поведение.

Множество возможных компонентов может быть фиксированным без фиксации мира вокруг них.


Полный пример

Рассмотрим сервис оформления заказа.

Его граф этапа компиляции содержит:

CheckoutController
        ↓
CheckoutService
        ↓
PaymentRouter
     ↙         ↘
Stripe       Adyen
        ↓
FeatureFlags

CheckoutService
        ↓
OrderRepository
        ↓
DataSource

CheckoutService
        ↓
ValueOf<FraudClient>

Во время выполнения:

  • эксперимент переключает некоторых пользователей с Stripe на Adyen;
  • feature flags меняются удалённо;
  • меняется специфичная для тенанта платёжная политика;
  • эндпоинт фрода меняется в конфигурации;
  • FraudClient пересобирается;
  • CheckoutService остаётся живым, потому что использует ValueOf<FraudClient>;
  • строки базы данных меняются непрерывно;
  • учётные данные ротируются;
  • логирование временно поднимается до DEBUG.

Топология графа не изменилась.

Тем не менее система высокодинамична.

Это реалистичная производственная модель.


Теперь сравни с действительно динамическим случаем

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

Новый плагин может содержать:

NewPaymentProvider.class
PluginFraudChecker.class
PluginSerializer.class

и работающий сервер должен:

загрузить классы
↓
обнаружить метаданные расширений
↓
зарегистрировать новые сервисы
↓
разрешить новые зависимости
↓
активировать плагин

Теперь топология зависимостей действительно является данными времени выполнения.

Граф приложения этапа компиляции Kora не спроектирован естественно для такой работы.

Это честная граница.

И она гораздо уже, чем «приложения Kora не могут быть динамичными».


Правильное финальное различие

Поэтому спор не должен быть:

статический фреймворк
против
динамический фреймворк

Он должен быть:

предварительно проверенная структура
+
динамическое состояние времени выполнения

против:

определяемая во время выполнения структура
+
динамическое состояние времени выполнения

Большинству бэкенд-сервисов нужно первое.

Специализированному классу расширяемых платформ нужно второе.


Заключение: помести динамику туда, где ей место

Производственные сервисы динамичны по своей природе.

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

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

Для большинства бэкенд-приложений структурная архитектура известна при сборке артефакта:

контроллер
   ↓
сервис
   ↓
репозиторий
   ↓
база данных

Меняется всё, что течёт через эту структуру и вокруг неё.

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

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

Во время выполнения Kora всё ещё создаёт и управляет экземплярами компонентов. Конфигурация может отслеживаться и перезагружаться. RefreshableGraph может пересобирать часть графа, затронутую изменённым узлом. ValueOf<T> позволяет стабильному компоненту наблюдать текущую версию зависимости без пересоздания самого себя. Feature flags, маршрутизаторы стратегий, коллекции компонентов, теги, реестры и обычная логика приложения могут динамически выбирать поведение на запрос или на тенанта.

Результат — не статическое приложение.

Это статически проверенная архитектура с динамическим поведением времени выполнения.

Это гораздо более полезное различие.

Этап компиляции

Компонент A
    ↓
Компонент B
    ↓
Компонент C

Топология известна и проверена

тогда как выполнение всё ещё может делать:

config v1
   ↓
Компонент B₁

        обновление

config v2
   ↓
Компонент B₂

Компонент C может либо:
- пересобраться, если напрямую зависит от B
- остаться живым и читать текущий B через ValueOf<B>

Это не отсутствие динамики. Это контролируемая динамика.

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

Для таких систем динамическое обнаружение — не случайная гибкость фреймворка. Это часть продукта.

Для REST- или gRPC-сервиса, Kafka-консьюмера, API-шлюза, воркера, планировщика или бизнес-сервиса на базе базы данных обычно нет.

Это ведёт к центральному архитектурному принципу:

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

И это объясняет дизайн Kora точнее, чем фраза «статический DI»:

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

Ту же идею можно сформулировать ещё точнее:

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

Безопасность этапа компиляции и гибкость времени выполнения — не противоположности.

Они применяются к разным вещам.

Реальный вопрос дизайна — не должно ли приложение быть динамичным.

Он в том, должен ли сам граф зависимостей быть частью этой динамики.

Для большинства производственных бэкенд-сервисов ответ нет.