Принципы программной инженерии: от SOLID до AI-эры
Примеры на современной Java, Rust, Go и Python (3.12+). Принципы сгруппированы по осям; для код-уровневых даны примеры на всех четырёх языках, для архитектурных — представительный пример. Версия рантайма указана на карточке там, где API от неё зависит.
У каждого принципа проставлен тип утверждения — теорема, модель, эмпирический закон, эвристика, паттерн, практика или редакционная позиция — и уровень уверенности. Это главное, что отличает справочник от списка «лучших практик»: закон Амдала (89) выводится из арифметики, USL (38) подгоняется по вашим измерениям, а «выбирай скучные технологии» — ничем не подкреплённый совет, и страница не делает вид, что это одно и то же. Теоремы, модели и эмпирические законы обязаны нести ссылку на первоисточник — это проверяет CI, а не добрая воля автора.
Раздел XIII перечисляет пары принципов, которые противоречат друг другу, и способ разрешения. Номера принципов — стабильные идентификаторы: новые дописываются в конец нумерации и встают в тематический раздел, поэтому внутри раздела последовательность не сплошная.
Скоуп — технические и инженерно-экономические принципы, включая организационные практики, которые непосредственно определяют архитектуру, delivery model, операционный риск или интерфейс внутренней платформы (15, 76, 84, 85). Вне скоупа — практики, не оставляющие следа в системе: постмортемы, найм, перформанс-ревью.
Все примеры — фрагменты: реальный синтаксис, но не самостоятельные программы, они опираются на типы и импорты за кадром. Статус помечен на каждой карточке; CI проверяет то, что проверяемо статически.
Область применимости. Оценки в таблице — редакционная позиция этой страницы, а не консенсус. ISP и DIP тоже дают проверяемые failure modes; «единственный заслуживающий примеров» — утверждение автора, а не свойство SOLID.
Конфликтует с YAGNI при преждевременном применении
LSP
Подтип подставим без нарушения контракта
Единственный проверяемый; нарушения = реальные баги
ISP
Клиент не зависит от неиспользуемых методов
Переносится куда угодно, полезен
DIP
Зависимость направлена к абстракции; интерфейс принадлежит потребителю
Полезен, но вырождается в интерфейсы с одной реализацией
«Причина для изменения» — это люди, а не требования. Поздняя формулировка Мартина: модуль отвечает перед одним и только одним актором — группой людей, которая выдвигает требования. Класс, который хранит список покупок и считает его стоимость, нарушает SRP не потому, что «делает две вещи», а потому что за состав списка и за правила ценообразования отвечают разные люди и их требования меняются независимо. Актор при этом — критерий разреза, а не драйвер дизайна: классы не заводят по одному на стейкхолдера, проверяют обратное — что один артефакт не служит двум хозяевам с несовпадающими графиками изменений.
Неоперационализируемость от этого никуда не девается: гранулярность произвольна, критерия остановки декомпозиции принцип не даёт — нарушение опознаётся постфактум, а дизайн из него не выводится. Практический тест — история файла: коммиты из несвязанных задач разных людей, систематически сходящиеся в один класс. Ограничитель против обратной крайности, дробления ради дробления, — cohesion (13): что меняется вместе, живёт вместе. Операционализируемая замена — information hiding (12): вопрос не «сколько здесь ответственностей», а «что сломается снаружи, если это внутреннее решение пересмотрят».
У контракта два слоя. Сигнатуры — какие методы, какие типы — проверяет компилятор. Поведение — что метод обещает делать — не проверяет никто, кроме ревью и тестов. LSP требует держать оба: подтип, совпавший по сигнатурам и нарушивший обещание, ломает каждого, кто писал код против супертипа. Канонический пример: у Rectangle есть setWidth и setHeight; Square, наследуя его, обязан держать стороны равными — его setWidth(5) тайком меняет и высоту, и код, полагавшийся на «поставил ширину — высота не тронута», ломается именно на квадрате. Компилятор доволен, контракт нарушен. Лечение в примерах ниже — не чинить наследование, а убрать его: закрытый набор вариантов без мутирующего setWidth (3) и контрактные тесты, прогоняемые по всем реализациям.
LSP-нарушение и исправление — единственный SOLID-принцип, заслуживающий примеров.
Пример кода (фрагмент)
фрагмент
Java
// sealed вместо наследования — контракт закрыт и проверяем компилятором
sealed interface Shape permits Rect, Square {}
record Rect(double w, double h) implements Shape {}
record Square(double side) implements Shape {}
// Square extends Rectangle невозможен по построению — нет мутирующего setWidth
Rust
// LSP выражается через trait-контракты; нарушение — паника вместо Err
trait Storage {
fn get(&self, k: &str) -> Result<Option<Vec<u8>>, StorageErr>; // контракт: не паникует
}
// impl, делающий unwrap() внутри get — нарушение LSP,
// ловится в code review + proptest
Go
// io.Reader — контракт из документации; реализация, паникующая вместо
// возврата error, нарушает LSP, хотя компилятор доволен
type SafeReader struct{ inner io.Reader }
func (r SafeReader) Read(p []byte) (int, error) {
n, err := r.inner.Read(p)
if err != nil && err != io.EOF {
return n, fmt.Errorf("read: %w", err) // не panic
}
return n, err
}
Python
# Protocol + контракт-тест, прогоняемый по всем реализациям
from typing import Protocol
class Storage(Protocol):
def get(self, key: str) -> bytes | None: ...
def storage_contract_suite(impl: Storage): # один набор тестов на все реализации
assert impl.get("missing") is None # не KeyError — это контракт
Функция возвращает тип, который конструктивно не может быть невалидным. Валидация выбрасывает знание, парсинг сохраняет его в типе.
Бытовой эквивалент — разъёмы. Штекер питания не входит в USB не потому, что кто-то проверяет его на входе, а потому что форма другая. Валидация — наклейка «сюда не вставлять», парсинг — другая форма разъёма; ошибка первого рода ловится в рантайме и только если проверку не забыли, второго — не совершается вовсе. Та же аналогия тянется на соседние карточки: 3 — разъёмов ровно столько, сколько осмысленных вариантов, лишний нельзя даже изготовить; LSP (1) — если штекер подошёл, на контактах обязано быть напряжение, обещанное на этикетке.
Пример кода (фрагмент)
фрагмент
Java
// record + компактный конструктор = smart constructor
public record Email(String value) {
public Email {
if (!value.matches(".+@.+\\..+")) throw new IllegalArgumentException(value);
}
public static Optional<Email> parse(String raw) {
try { return Optional.of(new Email(raw)); }
catch (IllegalArgumentException e) { return Optional.empty(); }
}
}
// дальше по коду Email всегда валиден — повторных проверок нет
Rust
// newtype с приватным полем — канонический вариант
pub struct Email(String); // поле приватно, снаружи не сконструировать
impl Email {
pub fn parse(raw: &str) -> Result<Self, EmailError> {
raw.contains('@').then(|| Email(raw.to_owned())).ok_or(EmailError::Invalid)
}
pub fn as_str(&self) -> &str { &self.0 }
}
Go
// приватное поле + конструктор — единственная точка входа
type Email struct{ value string } // zero value бесполезен — намеренно
func ParseEmail(raw string) (Email, error) {
if !strings.Contains(raw, "@") {
return Email{}, fmt.Errorf("invalid email: %q", raw)
}
return Email{value: raw}, nil
}
func (e Email) String() string { return e.value }
Python
# frozen dataclass + фабрика; NewType для дешёвых случаев
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Email:
value: str
def __post_init__(self):
if "@" not in self.value:
raise ValueError(f"invalid email: {self.value!r}")
# или pydantic: class Email(BaseModel): value: EmailStr — то же самое, на стероидах
Версии. Java-пример: record — финально с Java 16, sealed — с 17, record patterns и проверка полноты switch — с 21. Отсюда пометка «Java 21» на панели.
Sum types вместо комбинаций флагов. {isLoading, data, error} имеет 4 невозможных состояния из 8; sum type — ноль.
Откуда восемь: три независимых поля, каждое либо есть, либо нет — 2³. Осмысленных из них четыре: ничего не начиналось, идёт загрузка, пришли данные, пришла ошибка. Остальные четыре — «загрузка и данные разом», «данные и ошибка», «загрузка и ошибка», «всё сразу» — представимы в типе, недостижимы по смыслу, и каждая ветка кода обязана решать, что с ними делать: обычно она этого не делает, и невозможное состояние доезжает до пользователя. Sum type снимает сам вопрос — состояний ровно четыре, потому что пятое не выражается.
Пример кода (фрагмент)
фрагмент
Java 21
// sealed + record + pattern matching
sealed interface FetchState<T> permits Loading, Loaded, Failed {}
record Loading<T>() implements FetchState<T> {}
record Loaded<T>(T data) implements FetchState<T> {}
record Failed<T>(Exception cause) implements FetchState<T> {}
String render(FetchState<String> s) {
return switch (s) { // компилятор требует полноты
case Loading<String> l -> "spinner";
case Loaded<String>(var data) -> data;
case Failed<String>(var e) -> "error: " + e.getMessage();
};
}
Rust
// enum — родная конструкция, exhaustiveness бесплатно
enum FetchState<T> {
Loading,
Loaded(T),
Failed(anyhow::Error),
}
// match без wildcard: добавили вариант — не скомпилируется, пока не обработаете
Go
// sum types нет; ближайшая замена — закрытый интерфейс + type switch.
// Честная оценка: exhaustiveness не проверяется компилятором, только линтером
// (go-check-sumtype / exhaustive)
type fetchState interface{ isFetchState() }
type Loading struct{}
type Loaded struct{ Data []byte }
type Failed struct{ Err error }
func (Loading) isFetchState() {}
func (Loaded) isFetchState() {}
func (Failed) isFetchState() {}
Python
# union + match, полнота проверяется mypy/pyright через assert_never
from dataclasses import dataclass
from typing import assert_never
@dataclass(frozen=True)
class Loading: pass
@dataclass(frozen=True)
class Loaded: data: bytes
@dataclass(frozen=True)
class Failed: cause: Exception
type FetchState = Loading | Loaded | Failed
def render(s: FetchState) -> str:
match s:
case Loading(): return "spinner"
case Loaded(data): return data.decode()
case Failed(cause): return f"error: {cause}"
case _: assert_never(s) # статическая полнота
Ошибка — часть сигнатуры, а не побочный канал управления. Исключения ломают локальность: любой вызов может прыгнуть куда угодно.
Пример кода (фрагмент)
фрагмент
Java
// чекед-исключения — исторически провалившаяся попытка того же;
// современный вариант — Result-подобный sealed тип для доменных ошибок
sealed interface ChargeResult permits Charged, Declined, GatewayDown {}
record Charged(String txId) implements ChargeResult {}
record Declined(String reason) implements ChargeResult {}
record GatewayDown(Duration retryAfter) implements ChargeResult {}
// исключения остаются для программных ошибок (NPE, IllegalState)
// — это корректное разделение
Rust
// Result + оператор ? — эталон подхода
fn charge(card: &Card, amount: Cents) -> Result<TxId, ChargeError> {
let token = tokenize(card)?; // ранний возврат, тип честен
gateway::charge(token, amount) // ChargeError — enum со всеми исходами
}
// thiserror для библиотек, anyhow для приложений — устоявшаяся граница
Go
// (T, error) — идиома языка; ключ — оборачивание с контекстом и errors.Is/As
func (s *Svc) Charge(ctx context.Context, c Card, amt Cents) (TxID, error) {
tok, err := s.tokenize(ctx, c)
if err != nil {
return "", fmt.Errorf("tokenize card ****%s: %w", c.Last4, err)
}
id, err := s.gw.Charge(ctx, tok, amt)
if err != nil {
if errors.Is(err, gateway.ErrDeclined) { return "", ErrDeclined }
return "", fmt.Errorf("gateway charge: %w", err)
}
return id, nil
}
Python
# исключения — идиома языка, бороться с ней дороже, чем принять.
# Компромисс: доменные исходы — значениями, инфраструктурные сбои — исключениями
from dataclasses import dataclass
@dataclass(frozen=True)
class Declined: reason: str
@dataclass(frozen=True)
class Charged: tx_id: str
def charge(card: Card, amount: int) -> Charged | Declined:
... # Declined — не исключение: это ожидаемый бизнес-исход
# ConnectionError остаётся исключением — это сбой, а не исход
Логика — чистые функции без I/O; тонкая оболочка исполняет эффекты. Убивает мок-ад: если в тесте есть when(repo.findById(...)), логика перемешана с I/O.
Пример кода (фрагмент)
фрагмент
Java
// core: чистая функция, тестируется таблично без единого мока
static List<LedgerEntry> settle(List<Trade> trades, FxRates rates, LocalDate day) { ... }
// shell: весь I/O здесь, логики нет
void settleDaily() {
var trades = repo.tradesFor(today); // I/O
var rates = fxClient.ratesFor(today); // I/O
var entries = Settlement.settle(trades, rates, today); // pure
repo.saveAll(entries); // I/O
}
Rust
// Rust подталкивает к этому сам: чистое ядро не требует async и Send/Sync
fn settle(trades: &[Trade], rates: &FxRates, day: NaiveDate) -> Vec<LedgerEntry> { ... }
async fn settle_daily(db: &Db, fx: &FxClient) -> Result<()> {
let (trades, rates) = tokio::try_join!(db.trades(today), fx.rates(today))?;
db.save(settle(&trades, &rates, today)).await
}
Go
// ядро — пакет без единого импорта I/O; это проверяется депс-линтером
package settlement // импортирует только time и domain-типы
func Settle(trades []Trade, rates FxRates, day time.Time) []LedgerEntry { ... }
Python
# ядро без I/O тестируется hypothesis'ом; оболочка — 5 строк
def settle(trades: list[Trade], rates: FxRates, day: date) -> list[LedgerEntry]:
... # ни одного await, open(), requests — по конструкции
async def settle_daily(db: Db, fx: FxClient) -> None:
trades, rates = await asyncio.gather(db.trades(TODAY), fx.rates(TODAY))
await db.save(settle(trades, rates, TODAY))
Haskell превращает это разделение из дисциплины в закон: любой побочный эффект помечен типом IO, и компилятор не даст вызвать его из чистой функции — functional core / imperative shell там не стиль, а следствие системы типов. (Точность попутно: монады — не «только для I/O»; Maybe, List, State — тоже монады, IO лишь самая известная.) В Java/Go/Python граница держится конвенцией и линтером — что и показывают примеры.
Значение, которое нельзя изменить, нельзя изменить и у вас за спиной: пропадает целый класс вопросов «кто ещё держит эту ссылку», а разделение между потоками перестаёт требовать блокировок. Цена — копирование при изменении, и платится она на границе: принятая конструктором коллекция копируется, иначе вызывающий сохранил рычаг к вашему состоянию.
Пример кода (фрагмент)
фрагмент
Java
// record + List.copyOf; коллекции — через неизменяемые фабрики
public record Portfolio(String owner, List<Position> positions) {
public Portfolio { positions = List.copyOf(positions); } // защитная копия
public Portfolio withPosition(Position p) {
var next = new ArrayList<>(positions); next.add(p);
return new Portfolio(owner, next);
}
}
Rust
// иммутабельность — дефолт языка; mut — явное исключение
let portfolio = Portfolio::new(owner, positions); // менять нельзя без mut
// разделяемое состояние: Arc<Portfolio> дёшев и безопасен именно потому,
// что содержимое неизменяемо
Go
// иммутабельности в языке нет; конвенция — маленькие value-типы копируются,
// слайсы в конструкторах копируются явно
func NewPortfolio(owner string, ps []Position) Portfolio {
return Portfolio{owner: owner, positions: slices.Clone(ps)}
}
Python
# frozen dataclass + tuple вместо list
@dataclass(frozen=True, slots=True)
class Portfolio:
owner: str
positions: tuple[Position, ...]
def with_position(self, p: Position) -> "Portfolio":
return replace(self, positions=(*self.positions, p))
Область применимости. «Интерфейс только при двух реализациях» механистично. Единственная реализация оправданна, когда есть настоящий consumer-owned port, plugin boundary, security capability или стабильный внешний API. Критерий — доказанная ось изменения, а не число классов.
Метрика: сколько файлов надо открыть, чтобы понять фрагмент. Абстракция появляется на третьем повторении, не на первом. Интерфейс с одной реализацией — минус к навигации без плюса к гибкости.
Пример кода (фрагмент)
фрагмент
Go
// Go-сообщество формулирует это явно: "accept interfaces, return structs" —
// интерфейс объявляется у потребителя и только когда реализаций >= 2
// АНТИПАТТЕРН: интерфейс с единственной реализацией — две точки чтения
type UserService interface {
QueryUser(ctx context.Context, id UserID) (User, error)
}
type userServiceImpl struct{ db *sql.DB }
// НОРМА: прямой вызов, одна точка чтения
func (h *Handler) route(ctx context.Context, id UserID) (User, error) {
return h.db.QueryUser(ctx, id)
}
Java
// АНТИПАТТЕРН: интерфейс с ровно одной реализацией и одним вызывающим —
// +1 файл, +1 прыжок при чтении, ноль подменяемости на практике
public interface UserService { User find(UserId id); }
public final class UserServiceImpl implements UserService { ... }
// НОРМА: класс вызывается напрямую. Интерфейс появится, когда появится
// вторая реализация — или на границе I/O, где его требует тест (5)
public final class UserService { public User find(UserId id) { ... } }
Rust
// АНТИПАТТЕРН: dyn ради единственной реализации — косвенный вызов
// в рантайме и прыжок по файлам при чтении
struct Handler { store: Box<dyn Store> }
// НОРМА: конкретный тип, пока реализация одна. Появится вторая —
// generic: мономорфизация даёт гибкость, не беря за неё в рантайме
struct Handler { store: PostgresStore }
Python
# АНТИПАТТЕРН: иерархия ABC ради одного наследника
class Store(ABC):
@abstractmethod
def get(self, key: str) -> bytes | None: ...
class PostgresStore(Store): ... # единственная реализация
# НОРМА: класс напрямую. Станет две — Protocol: он не требует
# наследования и объявляется у потребителя, а не у реализации (12)
class PostgresStore:
def get(self, key: str) -> bytes | None: ...
Предусловие, постусловие, инвариант — и главное, что из них следует: кто виноват. Нарушенное предусловие — ошибка вызывающего: она возможна в корректной программе, проверяется всегда и отвечает исключением. Нарушенный инвариант — ваша собственная ошибка и признак уже испорченного состояния: продолжать работу нельзя, отсюда assert или panic, а не аккуратный возврат ошибки (25).
Пример кода (фрагмент)
фрагмент
Java
// Objects.requireNonNull + явные инварианты в конструкторе; assert для внутренних
public record Transfer(Account from, Account to, Cents amount) {
public Transfer {
Objects.requireNonNull(from); Objects.requireNonNull(to);
if (amount.value() <= 0) throw new IllegalArgumentException("amount must be > 0");
if (from.equals(to)) throw new IllegalArgumentException("self-transfer");
}
}
Rust
// debug_assert! на внутренних инвариантах — бесплатно в release
fn apply(&mut self, entry: LedgerEntry) {
debug_assert!(self.is_balanced(), "ledger invariant broken before apply");
// ...
debug_assert!(self.is_balanced(), "apply broke double-entry invariant");
}
Go
// TigerBeetle-стиль — дешёвые проверки инвариантов остаются в production
func (l *Ledger) Apply(e Entry) error {
if l.debits != l.credits { // инвариант двойной записи, всегда включён
panic("ledger corrupted: unbalanced before apply") // порча состояния = crash-only
}
...
}
Область применимости. Property-based testing и type-driven design закрывают разные части задачи: первое ищет контрпример в пространстве входов, второе сокращает само пространство, делая часть входов непредставимой (3). Ни то, ни другое не отвечает на вопрос, каким должен быть правильный результат: оракул задаётся отдельно — свойством, эталонной реализацией или метаморфным отношением, — и его отсутствие не лечится количеством прогонов.
Пример-тест проверяет случаи, которые вы придумали; свойство-тест проверяет утверждение, обязанное держаться на любом входе, а контрпример ищет генератор — и, найдя, ужимает его до минимального. Свойства, окупающиеся почти везде: roundtrip (разобрать записанное даёт исходное), сохранение (сумма не меняется), согласие с наивной эталонной реализацией. Типы — та же идея на шаг раньше: то, что гарантирует компилятор (3), тестировать уже не нужно.
Пример кода (фрагмент)
фрагмент
jqwik
// свойство вместо примеров
@Property
void settlementConservesMoney(@ForAll("trades") List<Trade> trades) {
var entries = Settlement.settle(trades, RATES, DAY);
assertThat(sumDebits(entries)).isEqualTo(sumCredits(entries));
}
proptest
// roundtrip — самое дешёвое и самое отлавливающее свойство
proptest! {
#[test]
fn serde_roundtrip(cfg in any::<Config>()) {
let json = serde_json::to_string(&cfg).unwrap();
prop_assert_eq!(serde_json::from_str::<Config>(&json).unwrap(), cfg);
}
}
Go fuzzing
// testing/quick устарел; идиома — rapid или встроенный fuzzing
func FuzzParseEmail(f *testing.F) {
f.Fuzz(func(t *testing.T, raw string) {
e, err := ParseEmail(raw)
if err == nil && !strings.Contains(e.String(), "@") {
t.Fatalf("parsed invalid email: %q", raw) // парсер принял мусор
}
})
}
hypothesis
from hypothesis import given, strategies as st
@given(st.lists(trade_strategy()))
def test_settlement_conserves_money(trades):
entries = settle(trades, RATES, DAY)
assert sum_debits(entries) == sum_credits(entries)
Дизайн от layout данных: SoA вместо AoS, cache lines, батчи вместо поэлементной обработки. Противоречит инкапсуляции — это осознанный трейдофф для hot path.
AoS (array of structs) — массив записей, как их пишет человек; SoA (struct of arrays) — по массиву на каждое поле. Разница проявляется, когда цикл читает одно поле у миллиона записей: процессор тащит память строками по 64 байта, поэтому AoS втягивает в кэш всю запись целиком и выбрасывает почти всю строку, а SoA кладёт рядом ровно то, что читается. Отсюда и конфликт с инкапсуляцией (12): SoA разбирает объект на части и лишает его права прятать своё представление — поэтому и применяется точечно, на измеренном (37) горячем пути, а не по всей кодовой базе.
Пример кода (фрагмент)
фрагмент
Java
// до Valhalla объекты — это указатели и промахи кэша.
// SoA вручную + примитивные массивы, либо MemorySegment (FFM API)
final class Particles { // вместо List<Particle>
final float[] x, y, vx, vy; // 4 плотных массива, SIMD-дружелюбно
void step(float dt) {
for (int i = 0; i < x.length; i++) { x[i] += vx[i] * dt; y[i] += vy[i] * dt; }
}
}
Rust
// SoA + итераторы компилируются в SIMD; #[repr(C)] контролирует layout
struct Particles { x: Vec<f32>, y: Vec<f32>, vx: Vec<f32>, vy: Vec<f32> }
impl Particles {
fn step(&mut self, dt: f32) {
for (x, vx) in self.x.iter_mut().zip(&self.vx) { *x += vx * dt; }
for (y, vy) in self.y.iter_mut().zip(&self.vy) { *y += vy * dt; }
}
}
Go
// то же — слайсы примитивов вместо []*Particle
// (убирает pointer chasing и нагрузку на GC)
type Particles struct{ X, Y, VX, VY []float32 }
Python
# DOD = "выйди из интерпретатора" — numpy/arrow, векторизация вместо циклов
import numpy as np
class Particles:
def __init__(self, n): self.x, self.vx = np.zeros(n), np.zeros(n)
def step(self, dt): self.x += self.vx * dt # одна C-операция вместо n итераций
# polars/duckdb для табличных данных — тот же принцип уровнем выше
У каждого ресурса ровно один владелец, отвечающий за освобождение, и момент освобождения привязан к области видимости, а не к дисциплине программиста. Языки различаются только тем, кто за этим следит: Rust проверяет владение компилятором, остальные дают конструкцию (try-with-resources, defer, with) и ловят забытое линтером. Сборщик мусора эту роль не исполняет: он отвечает за память, а не за дескрипторы, соединения и блокировки, и момент финализации не определён.
Пример кода (фрагмент)
фрагмент
Rust
// эталон — владение в типе, компилятор доказывает отсутствие use-after-free
fn process(conn: Connection) -> Report { ... }
// забрал владение: вызвавший больше не может использовать conn
Java
// try-with-resources + AutoCloseable; Cleaner для нативных ресурсов
try (var conn = pool.acquire(); var tx = conn.begin()) {
...
} // порядок закрытия обратный, гарантирован
Go
// defer сразу после acquire — идиома; забытый defer ловится линтером
conn, err := pool.Acquire(ctx)
if err != nil { return err }
defer conn.Release()
Python
# context manager; для нескольких ресурсов — ExitStack
with pool.acquire() as conn, conn.begin() as tx:
...
Версии. Модульная система — JDK 9+, и для модулей на module path строгая инкапсуляция работает с самого начала. Послабление --illegal-access касалось только внутренних пакетов самого JDK: deny по умолчанию с JDK 16 (JEP 396), опция игнорируется с JDK 17 (JEP 403).
Модуль скрывает решение, которое вероятно изменится. Критерий проверяем: «что сломается, если это решение пересмотрят». SRP (1) — размытый пересказ этого принципа.
Секрет — это конкретное решение: формат хранения, кодировка, порядок полей, алгоритм. Интерфейс, отдающий «список ASCII-строк», уже проболтался о кодировке — переход на UTF-8 ломает всех потребителей. Тот же интерфейс, описанный как «названия покупок», пережил бы этот переход незаметно. Разница не в коде, а в том, что объявлено наружу.
Зачем Java понадобился отдельный этаж видимости. До модульной системы public означало «виден всему classpath»: спрятать решение между пакетами одной библиотеки язык не позволял, и вспомогательный класс, сделанный public ради соседнего пакета, по закону Хайрама (16) становился чужим контрактом. exports добавляет второй этаж: public и экспортирован — это API; public в неэкспортированном пакете снаружи не существует. Внутри модуля не меняется ничего — пакеты по-прежнему видят public-типы друг друга, экспортированные и нет, а private/protected/public работают как раньше; модуль лишь фильтрует, что из public-поверхности предъявлено миру. Остальные директивы того же файла к hiding отношения не имеют: requires задаёт граф зависимостей, opens точечно разрешает рефлексию, uses/provides связывают ServiceLoader.
Три слова рядом с module path. Classpath — список адресов на диске (каталоги и JAR'ы), где JVM разрешено искать классы: конфигурация запуска, не механизм, и он плоский — отсюда вся история выше. Module path — тот же список, но с прочитанными module-info; только там появляется второй этаж видимости. ClassLoader — тот, кто по имени класса находит байткод и грузит его в JVM, лениво, при первом использовании; собственный загрузчик — единица изоляции (Tomcat выдаёт его каждому приложению, Flink — каждому джобу, child-first, чтобы джоб привозил свои версии библиотек), и один класс, загруженный двумя лоадерами, — для JVM два разных типа: отсюда ClassCastException «между одинаковыми классами». ServiceLoader — «найди все реализации интерфейса, имён которых я не знаю на этапе компиляции»: так JDBC находит драйверы; uses/provides (88) — его контракт в модульной системе, до модулей то же делал текстовый файл в META-INF/services.
Пример кода (фрагмент)
фрагмент
Java
// модульная система (module-info.java) — hiding на уровне артефакта
module billing.core {
requires transitive billing.model; // видно потребителям: часть API
exports com.acme.billing.api; // только API
// com.acme.billing.internal не экспортирован и не открыт:
// обычный доступ запрещён, deep reflection (setAccessible — исторический
// обход любых модификаторов) тоже. Доступ всё ещё выдаётся явно —
// opens, Module.addOpens(), --add-opens: это граница по умолчанию,
// а не физическая невозможность.
}
Rust
// pub(crate) / приватность по умолчанию — hiding встроен в язык
pub struct RateLimiter { /* поля приватны */ }
pub(crate) mod internal; // виден внутри крейта, невиден потребителям
Единственная пара метрик дизайна, поддающаяся измерению (afferent/efferent coupling, instability). Практика: направленные зависимости проверяются в CI, а не в head-каноне ревьюера.
Что именно считается: Ca (afferent) — сколько модулей зависят от вас, Ce (efferent) — от скольких зависите вы, instability I = Ce / (Ca + Ce). I = 0 — от вас зависят все, вы ни от кого: менять дорого, зато никто не ломает вас. I = 1 — наоборот. Ценность не в самих числах, а в направлении: устойчивое не должно зависеть от неустойчивого, иначе изменение в самом изменчивом модуле поднимается вверх по графу ко всем его читателям. Cohesion той же линейкой не меряется — это вопрос «меняется ли содержимое модуля по одной причине» (1), и он остаётся суждением.
Пример кода (фрагмент)
Java · ArchUnitфрагмент
// правило зависимостей как тест; definedBy матчит по ИМЕНАМ ПАКЕТОВ —
// ArchUnit читает байткод, поэтому конвенция имён и есть контракт этого теста
@ArchTest
static final ArchRule layering = layeredArchitecture().consideringAllDependencies()
.layer("domain").definedBy("..domain..")
.layer("infra").definedBy("..infra..")
.whereLayer("domain").mayNotAccessAnyLayer(); // домен не знает про инфраструктуру
Go — depguard / go list -deps в CI. Rust — крейты в workspace (граф зависимостей = Cargo.toml, циклы невозможны). Python — import-linter с контрактом слоёв.
Одна модель не бывает универсальной. «Customer» биллинга и «Customer» маркетинга — разные типы; слияние порождает god-таблицу на 60 nullable-колонок.
Откуда берётся проблема. Слово «заказ» в интернет-магазине произносят четыре отдела, и каждый имеет в виду своё: склад — позиции и место на полке, бухгалтерия — сумму, налог и статус оплаты, доставка — адрес и габариты коробки, поддержка — переписку с клиентом. Пока это один класс Order, он обязан носить поля всех четырёх сразу, и у каждого потребителя пустует три четверти из них — так и получается таблица на шестьдесят nullable-колонок. Хуже колонок то, что склеено: у отделов независимые причины меняться (1), а правка формата отгрузки теперь проходит через тип, от которого зависит расчёт налога.
Что такое граница. Bounded context — участок системы, внутри которого каждое слово значит ровно одно, а на выходе из него смысл приходится переводить. «Customer» биллинга и «Customer» маркетинга — не две версии одного понятия, а два разных понятия, случайно названных одним словом: гаечный ключ и ключ от квартиры не становятся одним предметом от совпадения названия. Совпадение полей ничего не доказывает — id, email, name есть у обоих, но меняться они будут по разным поводам и в разные дни. Критерий тот же, что у Парнаса (12): граница проходит по решению, которое пересматривают отдельно, — и по той же причине DRY (43) через границу не действует.
Пример кода (фрагмент)
Rustфрагмент
// Разные крейты — разные типы; трансляция на границе явная
// crates/billing/src/lib.rs
pub struct Customer { pub id: CustomerId, pub payment_methods: Vec<PaymentMethod> }
// crates/crm/src/lib.rs
pub struct Customer { pub id: CustomerId, pub segments: Vec<Segment> }
// Anti-corruption layer: From<crm::Customer> нет намеренно — маппинг только через
// явную функцию с бизнес-решениями, а не механический
Перевод — бизнес-решение, а не перекладывание полей. Прослойка, которая его делает, называется anti-corruption layer: она существует, чтобы понятия чужого контекста не протекали внутрь. Отсутствие From<crm::Customer> в примере намеренное — автоматическое преобразование делает вид, что типы одинаковы, а первый же вопрос «клиента, заблокированного за долг, маркетинг считает активным?» показывает, что это не так. Отвечает на такой вопрос не маппер, а тот, кто владеет контекстом.
Три ошибки на входе. Bounded context — не микросервис: границы смысла проводятся в коде, и четыре контекста прекрасно живут в одном процессе и одной кодовой базе; разъезд по сервисам — отдельное решение с отдельной ценой (13). Одинаковый набор полей — не доказательство одного типа, а сегодняшнее совпадение, и ось изменения тут ещё не доказана (42). Удобный Customer.from(...) — самая дорогая из трёх: он не запрещён, он создаёт впечатление, будто перевод механический, и тем самым прячет место, где надо было принять решение.
Область применимости. Это устойчивая корреляция с несколькими механизмами, а не доказанный закон: вклад дают границы владения, каналы согласования, релизные границы и когнитивная нагрузка команды, и по отдельности они управляются по-разному. Inverse Conway maneuver работает там, где орг-структуру действительно можно менять; чаще она сама следствие бюджета, найма и истории поглощений.
Архитектура сойдётся к структуре коммуникаций организации. Не совет, а наблюдение: хотите другую архитектуру — сначала перестройте команды (inverse Conway maneuver).
Область применимости. Не всякий чужой тип в сигнатуре — ошибка. Типы стандартной библиотеки и де-факто общие типы экосистемы (context.Context, java.time, UUID) дешевле пропускать, чем оборачивать — обёртка над ними сама становится тем, что потребитель обязан выучить. Принцип про зависимости, чью версию вы меняете, а не про фундамент, который меняете не вы.
Тип из чужой библиотеки, попавший в вашу публичную сигнатуру, становится вашим контрактом: потребитель компилируется против него, и ваш апгрейд этой библиотеки — его breaking change. Утечка бесшумна, потому что ни один компилятор не спрашивает «вы правда хотите предъявить это наружу» — по закону Хайрама (16) ответ выясняется, когда ломать уже поздно.
Проверка механическая: выпишите все типы, упомянутые в публичной поверхности модуля. Всё, чего нет в вашем коде, — чужой контракт, который вы молча взяли на поддержку. Дальше выбор из трёх, и он должен быть осознанным: обернуть чужой тип в свой (тогда зависимость остаётся секретом, 12), объявить транзитивность явно и признать её частью API, либо убрать тип из сигнатуры. Худший вариант — четвёртый, по умолчанию: не выбрать ничего.
Пример кода (фрагмент)
фрагмент
Java
module billing.api {
// нужна мне, невидима потребителю: деталь реализации
requires com.fasterxml.jackson.databind;
// тип из billing.model встречается в моих публичных сигнатурах,
// поэтому потребитель обязан его видеть. transitive — это признание:
// «совместимость billing.model теперь моя забота»
requires transitive billing.model;
exports com.acme.billing.api;
exports com.acme.billing.spi to billing.plugins; // адресный экспорт
// рефлексия выдаётся точечно и поимённо, а не всем желающим
opens com.acme.billing.dto to com.fasterxml.jackson.databind;
// точка расширения объявлена, а не найдена сканированием classpath
uses com.acme.billing.spi.TaxProvider;
provides com.acme.billing.spi.TaxProvider with com.acme.billing.tax.DeTaxProvider;
}
Rust
// pub use — тот же приём и та же цена: чужой тип становится вашим API,
// и major-бамп chrono превращается в major-бамп вашего крейта
pub use chrono::NaiveDate;
// граница вместо утечки: newtype оставляет chrono деталью реализации
pub struct SettlementDate(NaiveDate);
// pub-поле pub-структуры — тоже контракт; non_exhaustive сохраняет
// право добавлять поля, не ломая потребителей
#[non_exhaustive]
pub struct Settlement { pub date: SettlementDate }
Go
// в Go нет ни requires transitive, ни re-export: транзитивность утекает
// через подпись — единственное место, где её вообще видно. Потребитель
// обязан импортировать lib/pq, чтобы разобрать эту ошибку
func Charge(ctx context.Context, id string) (*pq.Error, error)
// граница: чужая ошибка заворачивается в свою, контракт — errors.Is
var ErrDuplicate = errors.New("billing: duplicate charge")
func Charge(ctx context.Context, id string) error {
if err := insert(ctx, id); isUniqueViolation(err) {
return fmt.Errorf("charge %s: %w", id, ErrDuplicate) // lib/pq внутри
}
return nil
}
Python
# то же обязательство, только объявляется соглашением, а не компилятором
from ._settlement import Settlement
from ._vendor_client import Client as Client # избыточный алиас = «это API» (PEP 484)
# второй, более явный слой того же заявления: Client обещан наружу,
# значит апгрейд вендора — наш breaking change, а не его
__all__ = ["Settlement", "Client"]
Обратная сторона того же принципа — точки расширения. uses/provides объявляют услугу в контракте модуля вместо сканирования classpath на старте: реализация подставляется поздно, но сам факт расширяемости объявлен рано и виден в артефакте. Тот же выбор возникает везде, где есть плагины: набор точек расширения — часть публичной поверхности, и добавить их дешевле, чем убрать.
Область применимости. Закон масштабируется числом потребителей, а не выполняется для любого API: «при достаточном количестве» намеренно не квантифицировано. Зависимостью становится не всякое наблюдаемое свойство, а то, которое дёшево заметить и дорого проверить по спецификации. Практический вывод — не «всё сломается», а «что не зафиксировано и не рандомизировано, зафиксируют за вас».
При достаточном числе потребителей все наблюдаемые свойства API — контракт: порядок ключей JSON, латентность, текст сообщений об ошибках.
Пример кода (фрагмент)
фрагмент
Go
// proverb-уровень: не давайте наблюдать то, что не обещаете
// map намеренно рандомизирует порядок итерации — язык защищается от закона Хайрама
for k := range m { ... } // порядок разный при каждом проходе — опереться не выйдет
Java
// то же — Set.of()/Map.of() рандомизируют порядок итерации между запусками JVM
// именно чтобы никто не начал зависеть от него
Точность. Спецификация Go говорит, что порядок итерации по map не определён и не гарантирован между итерациями, а не что он «случайный и всегда разный». Практический вывод тот же: зависеть от него нельзя — но формулировка «рандомизирует» описывает реализацию, а не контракт языка.
Механика к тому же разная по силе. Рантайм Go берёт случайные смещения при каждой инициализации итератора: две итерации по одной и той же неизменённой map в одном процессе дают разный порядок. Java считает SALT один раз при старте JVM (ImmutableCollections, комментарий в исходнике прямо называет цель — «iteration order will vary between JVM runs»): внутри запуска порядок стабилен, между запусками разный. Контракт у обоих одинаков — порядок не определён; различается настойчивость, с которой реализация мешает на него опереться, и она сама может измениться в следующем релизе, что и есть главный довод не опираться.
Единственный корректный протокол изменения схемы или API при непрерывном деплое. Никогда «rename column» — всегда последовательность, в которой на каждом шаге старый и новый код работают одновременно.
Полный цикл: 1) expand — nullable-колонка и совместимый код; 2) двойная запись; 3) батчевый бэкфилл с чекпойнтом; 4) сверка старого и нового значений; 5) переключение чтения; 6) период наблюдения; 7) NOT NULL и constraint; 8) contract — снос старого.
Пример кода (фрагмент)
фрагмент
SQL
-- 1. EXPAND: колонка nullable и без default — быстрая операция метаданных.
-- NOT NULL и backfill на этом шаге взяли бы ACCESS EXCLUSIVE на всю таблицу.
ALTER TABLE users ADD COLUMN email_normalized TEXT;
-- 2. DUAL-WRITE делает приложение: оба поля пишутся в одной транзакции.
-- 3. BACKFILL батчами с чекпойнтом, а не одним UPDATE:
-- один большой UPDATE = долгие блокировки, раздувание WAL, bloat,
-- лаг реплик и невозможность прервать работу.
WITH batch AS (
SELECT id FROM users
WHERE email_normalized IS NULL AND id > :checkpoint
ORDER BY id LIMIT 5000 FOR UPDATE SKIP LOCKED
)
UPDATE users u SET email_normalized = lower(u.email)
FROM batch b WHERE u.id = b.id
RETURNING u.id; -- максимум возвращённого id — новый :checkpoint
-- 4. VERIFY: расхождений быть не должно; это условие перехода к шагу 5
SELECT count(*) FROM users
WHERE email_normalized IS DISTINCT FROM lower(email);
-- 5-6. Чтение переключается флагом (72), двойная запись живёт весь период наблюдения.
-- 7. Ограничение добавляется в два шага, чтобы не держать долгую блокировку
ALTER TABLE users ADD CONSTRAINT users_email_norm_not_null
CHECK (email_normalized IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_email_norm_not_null;
-- 8. CONTRACT: только после того, как ни один живой деплой не читает старое поле
ALTER TABLE users DROP COLUMN email;
Python · pydantic v2
# То же для API-полей: deprecated-поле живёт параллельно, отдаём оба.
# Пометка ставится на само поле — она попадает в JSON Schema и в OpenAPI
class UserOut(BaseModel):
email: str = Field(deprecated="Используйте email_normalized")
email_normalized: str
Откат на каждом шаге разный: до шага 5 достаточно перестать писать в новое поле, после — вернуть чтение флагом. После шага 8 отката нет, это one-way door (20). Поэтому между 5 и 8 нужен период наблюдения, измеряемый в неделях деплоев, а не в часах.
Область применимости. additive-only — безопасная политика по умолчанию, но не полное описание совместимости: она зависит от формата, defaults, compatibility mode и сочетания версий reader/writer. Отдельные удаления с reserved и часть изменений типов wire-compatible, оставаясь semantic-breaking.
Protobuf/Avro-правила: additive-only, никогда не переиспользовать номера полей, reserved для удалённых.
Все три правила выводятся из одного факта: в wire-формате поле опознаётся номером, а не именем — имя живёт только в .proto и в сгенерированном коде. Отсюда переименование поля безопасно (номер тот же), смена номера равносильна подмене поля, а незнакомый номер старый читатель пропускает как unknown field — поэтому добавление совместимо. И отсюда же худший случай: номер удалённого поля, отданный новому полю другого типа, старый читатель разберёт по старой схеме и молча получит чужие данные — не ошибку, а правдоподобный мусор. reserved существует ровно затем, чтобы компилятор не дал это сделать; это тот же сожжённый идентификатор, что и retired-номер принципа в этом справочнике.
Пример кода (фрагмент)
фрагмент
Protobuf
message Order {
reserved 3, 7; // удалённые поля: номера сожжены навсегда
reserved "legacy_status";
string id = 1;
int64 amount_cents = 2;
OrderStatus status = 4; // добавление — единственная безопасная операция
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)] // на ГРАНИЦЕ ДОВЕРИЯ — строгость;
struct AdminCommand { ... } // на межсервисном контракте — наоборот,
// терпимость к новым полям
«Будь либерален к входу» породила XSS, request smuggling и невозможность эволюции протоколов. Современная позиция: строгая валидация на границе, отказ вместо угадывания.
Честно про заголовок. RFC 9413 называется «Maintaining Robust Protocols» и формально ничего не отменяет: он показывает, что либеральный приём сам по себе становится источником окостенения (ossification) и уязвимостей, и рекомендует активное управление допусками, а не их запрет. «Отменена» — редакционная позиция этой страницы, а не текст RFC.
Пример кода (фрагмент)
фрагмент
Python · pydantic v2
# strict mode на внешней границе
class PaymentIn(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid") # "123" не станет 123
amount_cents: int
currency: Literal["EUR", "USD"]
Go
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
// неизвестное поле от внешнего клиента = 400, не молчаливый дроп
Скорость решения обратно пропорциональна стоимости отката. Выбор библиотеки логирования — two-way door, решается за час. Выбор формата хранения / партиционирования / ID-схемы — one-way door, требует ADR.
Пример кода (конфигурация)
ADRконфигурация
# ADR-014: Ключ партиционирования topic'а orders
Status: accepted Date: 2026-03-02
Context: repartition Kafka-топика на проде = даунтайм консьюмеров
+ пересборка state stores Flink
Decision: партиционируем по customer_id, не по order_id
Consequences: горячие ключи для крупных B2B-клиентов;
митигируем salted keys при >5% skew
Выкладка кода и включение поведения — два разных события, разделённых флагом. Canary / percentage rollout превращает релиз из бинарного события в управляемый эксперимент с метрикой отката. Kill switch — отдельный вид флага: не эксперимент, а заранее построенный рубильник для деградации (см. 55), живёт вечно и тестируется регулярно.
Пример кода (фрагмент)
Javaфрагмент
// флаг — типизированный, с владельцем и сроком жизни прямо в декларации
@Flag(owner = "team-payments", removeBy = "2026-10-01", kind = RELEASE)
static final BoolFlag NEW_SETTLEMENT = flags.bool("settlement.v2", false);
if (NEW_SETTLEMENT.on(ctx.customerId())) { ... } // percentage по стабильному hash id,
// не random() — иначе пользователь
// мигает между версиями
Честная оценка. Flag debt реален: каждый флаг — это 2n конфигураций, из которых тестируются две. Removal date — часть definition of done; флаг старше двух кварталов без решения — инцидент процесса. Следствие для схем: canary означает, что две версии кода живут одновременно всегда, поэтому expand — migrate — contract (17) из «хорошей практики» становится единственно возможной.
73
Strangler Fig: легаси душат по маршрутам, не переписывают
Big-bang rewrite нарушает Gall's Law (41) в промышленном масштабе. Strangler fig: фасад перед легаси, маршруты переводятся на новую систему по одному, легаси умирает вытеснением. Каждый шаг обратим (роутинг назад), прогресс измерим (% трафика), система работает всё время.
Пример кода (конфигурация)
Маршрутизацияконфигурация
# маршрутизация — и есть механизм миграции
routes:
- match: { path: /invoices/* } # мигрировано
backend: billing-v2
- match: { path: /* } # всё остальное — пока легаси
backend: legacy-monolith
Самая дорогая часть — не роутинг, а расщепление данных: пока обе системы пишут в одну базу, легаси не мёртв, а спрятан. Shared DB — промежуточная станция с датой выезда, иначе получили distributed monolith.
74
Contract testing: интеграция проверяется без интеграционного стенда
E2E-стенд со всеми сервисами — самый дорогой и самый нестабильный способ проверить совместимость. Consumer-driven contracts: потребитель публикует свои ожидания (какие поля читает, какие статусы обрабатывает), провайдер проверяет их в своём CI. Ломающее изменение находится до деплоя, у провайдера, с именем пострадавшего потребителя. Это операционализация Hyrum's Law (16): контракт — то, что потребители реально используют, записанное машинно.
Пример кода (фрагмент)
Python · pact-styleфрагмент
# ожидание потребителя
pact.given("invoice 42 exists") \
.upon_receiving("get invoice") \
.with_request("GET", "/invoices/42") \
.will_respond_with(200, body=Like({"number": "42", "total_cents": 12300}))
# провайдер в своём CI прогоняет ВСЕ опубликованные контракты потребителей;
# удаление поля, которое кто-то читает, — красный билд у провайдера
Контракты проверяют структуру, не поведение — они дополняют, а не заменяют тесты провайдера. Схема-реестр (protobuf + compatibility checks, 18) — то же самое для событий; contract testing — для границ, где реестра нет (REST/JSON).
86
IaC: желаемое состояние + plan как ревью; state — БД с one-way doors
IaC — не «скрипты создания», а декларация желаемого состояния с обнаружимым дрифтом: ручное изменение в консоли — это расхождение кода и реальности, которое plan обязан показать, а процесс — запретить создавать. terraform plan — артефакт ревью наравне с диффом кода: ревьюится не HCL, а план изменений («destroy and recreate» на базе данных — вот что ищет ревьюер). State-файл — база данных со всеми последствиями: locking, бэкапы; переименование ресурса без moved {} = destroy/create — one-way door (20), замаскированная под рефакторинг.
Пример кода (конфигурация)
Terraform · HCLконфигурация
# рефакторинг без разрушения — moved-блок, а не «terraform понял по имени»
moved {
from = aws_msk_cluster.kafka
to = module.streaming.aws_msk_cluster.kafka
}
# инвариант в коде, а не в голове ревьюера
lifecycle {
prevent_destroy = true # на stateful-ресурсах; plan с их destroy падает в CI
}
Чего prevent_destroy не делает. Он защищает ресурс, пока тот описан в конфигурации: удалите сам resource block — и защита уедет вместе с ним, а ресурс будет уничтожен. Значит защита не может жить только в HCL. Нужны: policy-as-code, заваливающая plan с destroy на stateful-ресурсах; CI, применяющий сохранённый plan, а не пересчитанный на apply; проверка на удаление resource block в диффе; проверенные бэкапы и учения по восстановлению; и явная break-glass процедура. Отдельно: drift виден только в пределах того, что провайдер умеет прочитать.
«Cattle, not pets» — правило про способ изменения, а не про сорт ресурса: заменять целиком можно то, что восстановимо из кода и реплик, а всё, что держит единственный экземпляр состояния, требует явных протоколов миграции, восстановления и совместимости версий. Реплицированное состояние с кворумом или объектное хранилище управляются декларативно наравне с compute; уникальная машина с локальным диском — нет, как бы она ни называлась в теге. Путаница этих двух — стандартный сценарий потери стейта. Drift detection — не аудит раз в квартал, а continuous (plan по крону с алертом на непустой дифф): дрифт, найденный через месяц, уже оброс зависимостями — Hyrum (16) для инфраструктуры.
87
Миграция — продукт с метриками, а не задача со сроком
Zero-downtime миграция (ID-схема, формат хранения, gitflow → trunk, платформа) — не «переключение», а конвейер: dual-run (обе системы работают) → shadow/compare (новая получает копию трафика, расхождения меряются) → постепенный cutover с обратимым роутингом → период соседства → contract (снос старого). Метрика прогресса публична (% трафика / сервисов / записей на новом пути), и — самое игнорируемое — у миграции есть владелец до конца: миграция, законченная на 90%, хуже неначатой, потому что теперь систем две навсегда.
Пример кода (фрагмент)
Go · shadow-compareфрагмент
// АНТИПАТТЕРН: горутина на каждый запрос — неограниченная конкурентность,
// контекст отменён сразу после ответа клиенту, ни таймаута, ни recover
go func() {
nu, nerr := m.next.Get(ctx, id) // ctx уже отменён
m.compare.Record(id, old, err, nu, nerr) // паника здесь снимает процесс
}()
// НОРМА: новая реализация получает трафик, но не право голоса —
// через ограниченную очередь, со своим контекстом и таймаутом
func (m *Migrator) Get(ctx context.Context, id UserID) (User, error) {
old, err := m.legacy.Get(ctx, id)
m.shadow.Offer(job{id, old, err}) // неблокирующе: очередь полна —
return old, err // сэмпл выбрасывается, клиент не ждёт
} // источник правды пока старый; cutover — когда diff-rate < порога
// N дней подряд, и это число записано ДО начала
func (m *Migrator) shadowWorker() {
for j := range m.shadow.Jobs() {
func() {
defer func() { _ = recover() }() // паника воркера не роняет процесс
ctx, cancel := context.WithTimeout(m.shadowBase, 200*time.Millisecond)
defer cancel()
nu, nerr := m.next.Get(ctx, j.id)
m.compare.Record(j.id, redact(j.old), j.err, redact(nu), nerr)
}()
}
}
Честная оценка. Организационные миграции (gitflow → trunk, monorepo, платформа) подчиняются той же механике, что и технические: dual-run = обе модели разрешены, cutover = новые сервисы только на новой, contract = старая выключена датой, объявленной заранее. «Добровольно и бессрочно» — это не миграция, а вечное удвоение поддержки. Критерий отмены тоже пишется до старта: миграция без условия «когда мы откатываемся» — ловушка sunk cost в производственном масштабе.
Чего не видно в коде: shadowBase — не производный от запроса контекст, а собственный, несущий только разрешённые correlation-атрибуты и ни одного пользовательского; на теневой путь нужен circuit breaker, иначе деградация новой системы начинает съедать ресурсы старой; а очередь обязана иметь метрику отброшенных сэмплов — молча теряемый теневой трафик превращает diff-rate в цифру, измеряющую саму себя.
Корректность обеспечивается только на концах; промежуточные слои дают оптимизацию, не гарантию. Следствие: exactly-once брокера кончается на границе брокера — дальше дедупликация обязана жить у потребителя.
Точная формулировка. Kafka-транзакции дают настоящий EOS в контуре read-process-write, где и вход, и выход, и офсеты — внутри Kafka: коммит офсета и запись результата атомарны. Гарантия исчезает ровно там, где появляется внешний sink (БД, платёжный шлюз, письмо): у него нет общей транзакции с брокером. Поэтому «exactly-once» — не маркетинг, а область применимости, за пределами которой работает только E2E-дедупликация.
Пример кода (фрагмент)
фрагмент
SQL · transactional inbox
-- Маркер дедупликации и изменение бизнес-состояния — в ОДНОЙ транзакции.
-- Иначе два consumer'а проходят проверку одновременно, либо процесс падает
-- между эффектом и отметкой, и событие применяется дважды.
BEGIN;
INSERT INTO consumed_events (event_id) VALUES (:event_id)
ON CONFLICT DO NOTHING;
-- Ноль затронутых строк = событие уже применено: коммитим и выходим.
UPDATE accounts SET balance = balance + :amount WHERE id = :account_id;
COMMIT;
Java · Kafka consumer
void handle(ConsumerRecord<String, OrderEvent> rec) {
// Дедупликация и эффект — внутри одной транзакции хранилища, а не двумя
// вызовами вокруг process(): между ними процесс может умереть.
tx.run(session -> {
if (!session.claimEvent(rec.value().eventId())) return; // INSERT .. ON CONFLICT
applyBusinessChange(session, rec.value());
});
// offset коммитится ПОСЛЕ транзакции: повтор безопасен, потеря — нет
consumer.commitSync();
}
Где это перестаёт работать. Схема выше требует, чтобы эффект и маркер лежали в одном транзакционном ресурсе. Если побочный эффект снаружи — платёжный шлюз, письмо, вызов чужого API — общей транзакции нет, и остаются три варианта: idempotency key на стороне внешнего сервиса, outbox (68) с последующей сверкой, либо явная reconciliation-процедура. Дедупликация в отдельном сторе с TTL — это оптимизация «не делать работу дважды», а не гарантия корректности.
Идемпотентный ключ — в контракте API, а не retry-логика в клиенте.
Пример кода (фрагмент)
фрагмент
Go
// Ключ идемпотентности от клиента, результат кэшируется атомарно с операцией
func (s *Svc) CreatePayment(ctx context.Context, key IdempotencyKey, req PaymentReq) (Payment, error) {
return s.store.WithIdempotency(ctx, key, func(tx Tx) (Payment, error) {
return s.doCreate(tx, req) // при повторе с тем же key вернётся сохранённый результат
})
}
Rust
// Тип делает требование ненарушаемым: без ключа запрос не сконструировать
pub struct CreatePayment { pub idempotency_key: Uuid, pub amount: Cents }
23
Очередь ограничена: backpressure или сброс нагрузки
Неограниченная очередь превращает отказ в latency-коллапс (Little's law: L = λW; растёт очередь — растёт время ответа при том же λ).
Буфер и backpressure — не альтернативы на одной шкале. Защита от перегрузки собирается из четырёх разных вещей: ограниченный буфер (поглощает всплеск, но не тренд), лимит конкурентности и admission control (решают, сколько работы вообще принять), backpressure (передаёт давление туда, откуда пришла работа) и сброс нагрузки (быстрый отказ там, где давление передать некуда). Выбор между двумя последними определяется одним вопросом: способен ли продьюсер притормозить. Внутренний сервис на gRPC-стриме — да; браузер случайного пользователя — нет, и для него честный ответ — 429 с Retry-After, а не место в очереди на тридцать секунд. Сквозной дедлайн и ретрай-бюджет (24) — часть той же конструкции: без них сброшенная нагрузка возвращается ретраями и добивает то, что защищали.
Пример кода (фрагмент)
фрагмент
Go
// bounded channel + отказ вместо роста очереди
select {
case jobs <- j:
case <-time.After(50 * time.Millisecond):
return ErrOverloaded // load shedding: быстрый отказ дешевле медленного успеха
}
Java
// Reactive Streams — backpressure в контракте; либо bounded executor
var pool = new ThreadPoolExecutor(8, 8, 0, SECONDS,
new ArrayBlockingQueue<>(1_000), // bounded!
new ThreadPoolExecutor.CallerRunsPolicy()); // давление назад на продьюсера
Rust · tokio
// bounded mpsc; send().await сам является backpressure
let (tx, rx) = tokio::sync::mpsc::channel::<Job>(1_000);
if tx.try_send(job).is_err() { return Err(Error::Overloaded) }
Python · asyncio
# bounded queue; put() приостанавливает продьюсера — это фича
queue: asyncio.Queue[Job] = asyncio.Queue(maxsize=1_000)
await queue.put(job) # блокируется при заполнении — давление течёт вверх по конвейеру
24
Timeout + retry с jitter, circuit breaker, bulkhead
Область применимости. Таймаут, ретрай с jitter, circuit breaker и bulkhead не взаимозаменяемы — они закрывают разные отказы: слишком медленный ответ, разовую ошибку, устойчивую деградацию зависимости и растекание отказа между пулами. Ни один из них не заменяет ограничение очереди и сброс нагрузки (23); ретрай без бюджета и сквозного дедлайна усиливает перегрузку, а не смягчает её.
async fn retry<T, E>(mut f: impl FnMut() -> BoxFuture<'static, Result<T, E>>) -> Result<T, E> {
let base = Duration::from_millis(100);
for attempt in 0..5 {
match f().await {
Ok(v) => return Ok(v),
Err(_) if attempt < 4 => {
let cap = base * 2u32.pow(attempt);
sleep(Duration::from_millis(rand::random_range(0..cap.as_millis() as u64))).await;
}
Err(e) => return Err(e),
}
}
unreachable!()
}
Go · сквозной дедлайн
// context с дедлайном — сквозной; таймаут задаёт вызывающий, а не библиотека
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
resp, err := s.gw.Charge(ctx, req) // весь стек ниже уважает дедлайн
Чего не хватает в примере выше. Он повторяет любую ошибку. Рабочая политика различает: retryable (таймаут, 5xx, перегрузка) и non-retryable (валидация, аутентификация, 4xx) — второе повторять бессмысленно и вредно; уважает Retry-After вместо собственного backoff; ограничена сквозным дедлайном, а не суммой отдельных таймаутов; отменяется вместе с контекстом; имеет retry budget на клиента, чтобы ретраи не составили большую часть трафика; и никогда не повторяет неидемпотентную операцию без ключа идемпотентности (22). Число попыток и исчерпание бюджета — метрики, иначе деградация невидима.
Единственный путь остановки — падение; recovery тестируется каждым деплоем. Отдельный shutdown-код не исполняется годами и потому не работает.
Пример кода (фрагмент)
Goфрагмент
// Оркестратор (k8s) убивает POD'ы постоянно — значит старт обязан быть recovery
func main() {
state := mustRecoverFromLog(walPath) // старт == восстановление, всегда
// graceful shutdown минимален: только отпустить лидерство/дренировать LB,
// корректность от него НЕ зависит
}
Уточнение. Тезис в том, что корректность не должна зависеть от graceful shutdown. Само по себе аккуратное завершение остаётся полезной оптимизацией: дренирование соединений и балансировщика, передача лидерства, сокращение повторной работы после рестарта. Отказ от него — не следствие crash-only, а отдельное решение.
Версии. StructuredTaskScope — по-прежнему preview: JDK 26 превьюит его в шестой раз (JEP 525), нужен --enable-preview. В примере учтено переименование JDK 26: anySuccessfulResultOrThrow() из JEP 505 стал anySuccessfulOrThrow(), а allSuccessfulOrThrow() теперь отдаёт список результатов, а не поток Subtask. Седьмое превью (JEP 533, JDK 27) эти имена сохраняет — фрагмент компилируется и там, — но финальным API считать по-прежнему рано.
p99, а не среднее: при fan-out 100 ваш p99 — это медиана пользователя. Hedged requests: отправить дубль после p95-задержки, взять первый ответ.
Пример кода (фрагмент)
Java · JDK 26 (structured concurrency, JEP 525)фрагмент
// hedged request на виртуальных потоках
try (var scope = StructuredTaskScope.open(Joiner.<Reply>anySuccessfulOrThrow())) {
scope.fork(() -> replica1.get(key));
scope.fork(() -> { Thread.sleep(p95Latency); return replica2.get(key); }); // hedge
return scope.join(); // первый успешный, второй отменяется
}
// На JDK 25 (JEP 505) тот же joiner назывался anySuccessfulResultOrThrow().
// На JDK 21 (JEP 453) форма другая — фабрик ещё не было:
// try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Reply>()) {
// scope.fork(() -> replica1.get(key));
// scope.fork(() -> { Thread.sleep(p95Latency); return replica2.get(key); });
// scope.join();
// return scope.result();
// }
Арифметика и цена. При fan-out в 100 независимых обращений вероятность задеть хотя бы один хвост компонента ≈ 1 − 0.99¹⁰⁰ ≈ 63 %: p99 компонента становится обычным опытом пользователя. Hedging применим только к идемпотентным и отменяемым операциям (22) и покупает латентность за дополнительный трафик — у него должен быть бюджет и доля hedged-запросов в метриках.
Единица отказа проектируется заранее: клиенты шардируются по ячейкам, инцидент задевает одну ячейку, а не всех. Shuffle sharding — вариант с комбинаторным снижением пересечения.
Выражается топологией деплоя, не кодом.
77
Chaos engineering: гипотезы об отказах проверяются экспериментом
«Мы переживём падение реплики» — гипотеза; пока её не проверили инъекцией отказа, это мнение. Метод: steady-state метрика → гипотеза («убийство пода не двигает p99») → минимальный blast radius (27) → инъекция → сравнение. Деталь, которую пропускают: конечная цель — прод, потому что стейджинг не воспроизводит ни трафик, ни данные, ни конфигурацию.
Пример кода (конфигурация)
Эксперимент как артефактконфигурация
# эксперимент — версионируемый артефакт, как тест
experiment: kafka-broker-loss
steady_state: { metric: consumer_lag_p99, threshold: "< 30s" }
method: [{ action: kill-pod, selector: "app=kafka, broker=2" }]
rollback: automatic # abort при выходе за threshold
blast_radius: cell-eu-1 # одна ячейка, не флот
Честная оценка. Chaos без наблюдаемости — вандализм: если не можете ответить «что сломалось», нечего и ломать. Порядок строгий: сначала SLO и трейсинг, потом инъекции. Game days на стейджинге — легитимная первая ступень, но не конечная.
Область применимости. Это таксономия неверных допущений, а не наблюдаемая регулярность: список Дойча ничего не предсказывает, он перечисляет предпосылки, которые дизайн обязан опровергнуть явно. Проверяется чек-листом на каждой сетевой границе, а не измерением, и потому живёт в ревью, а не в мониторинге.
Восемь заблуждений (Deutsch / Gosling): сеть надёжна, латентность нулевая, полоса бесконечна, сеть безопасна, топология неизменна, администратор один, транспорт бесплатен, сеть однородна. Каждое «очевидно ложно» — и каждое регулярно закладывается в дизайн неявно.
Проверяемая форма. Любой межсервисный вызов в коде обязан отвечать на три вопроса: таймаут? ретрай-политика? что при недоступности? Отсутствие ответа = заложенное заблуждение №1. Кодом не выражается; выражается чек-листом ревью для каждой сетевой границы.
64
CAP недостаточен; PACELC добавляет trade-off нормального режима
Область применимости. CAP и PACELC — утверждения разного рода, и карточка объединяет их сознательно. CAP доказан Гилбертом и Линчем для конкретной модели: асинхронная сеть, разделяемый регистр, требование, чтобы каждый живой узел отвечал. PACELC — таксономия дизайна, расширяющая разговор на нормальный режим, а не теорема; «CP/AP-база» — маркетинговый ярлык. Собственное утверждение страницы — что уровень консистентности выбирается на операцию, а не на систему — редакционное.
CAP в популярной форме вырожден: P не выбирается — partition случается, выбор только между C и A во время partition. PACELC честнее: даже без partition (E) выбор между latency (L) и consistency (C) существует всегда — синхронная репликация стоит RTT на каждую запись.
Честная оценка. «CP или AP» как ярлык базы данных — маркетинговая таксономия; реальные системы настраиваются per-operation (quorum vs one в Cassandra, synchronous_commit в Postgres). Принцип: уровень консистентности — свойство операции, не системы, и выбирается по инварианту, который операция защищает.
PACELC не отменяет CAP, а расширяет его: CAP описывает поведение во время partition, PACELC добавляет выбор в нормальном режиме. Заголовок про «мёртв» относится к популярному использованию CAP как ярлыка для баз данных. И synchronous_commit в PostgreSQL — параметр подтверждения коммита и durability/репликации, а не переключатель модели консистентности на операцию.
Область применимости. Консистентность — вектор гарантий, а не одна шкала: causal consistency и session guarantees покрывают разные оси и не образуют тотального порядка. Единственное, что упорядочено строго, — цена реального времени: linearizability требует его, sequential и causal — нет.
«Прочитал то, что записал» — не свойство базы, а гарантия, которую сервис либо даёт, либо нет, и это должно быть написано в контракте. Консистентность — не одна шкала «сильнее/слабее», а набор наблюдаемых гарантий: порядок операций, видимость чужих записей, свежесть, поведение внутри сессии, привязка к реальному времени. Linearizability дороже всех, потому что требует последнего: операция обязана выглядеть выполненной в один момент между вызовом и ответом. Большинству фич хватает session guarantees (read-your-writes, monotonic reads) — платить за linearizability по умолчанию значит платить за то, чем не пользуешься.
Пример кода (фрагмент)
фрагмент
Java
// read-your-writes через версионный токен, без sticky sessions
record WriteResult(OrderId id, long lsn) {} // LSN/HLC записи возвращается клиенту
Order read(OrderId id, long minLsn) {
var replica = pool.any();
replica.waitForLsn(minLsn, TIMEOUT); // реплика догоняет или отдаём в primary
return replica.get(id);
}
// клиент носит lsn в cookie/header → его собственные записи видимы,
// чужие — eventually; это и есть session guarantee, задекларированная явно
Go
// то же на уровне контракта: тип ответа содержит гарантию
type ReadConsistency int
const (
Eventual ReadConsistency = iota
ReadYourWrites
Linearizable // документированно дорого: идёт через лидера
)
func (s *Store) Get(ctx context.Context, id OrderID, c ReadConsistency) (Order, error)
Тест-вопрос на дизайн-ревью: «какой аномалией пользователь заплатит за эту оптимизацию — и знает ли он об этом из контракта?»
Возвращать наружу long lsn — это утечка PostgreSQL в контракт. Наружу отдают непрозрачный consistency token с описанной семантикой, таймаутом ожидания и поведением при его истечении (деградация в чтение с лидера или явная ошибка).
66
Распределённая блокировка без fencing token сломана
Любой lock с TTL (Redis, ZooKeeper lease, k8s lease) не защищает сам по себе: держатель может зависнуть (GC-пауза, сетевой delay), TTL истечёт, лок получит другой — и «мертвец» проснётся и запишет поверх. Единственная корректная схема: лок выдаёт монотонный token, а хранилище отвергает записи со stale token. Защита живёт на стороне ресурса, не на стороне лока — это E2E argument (21), применённый к блокировкам.
Пример кода (фрагмент)
фрагмент
Go
token, err := locker.Acquire(ctx, "settlement-2026-08-04") // token = 34
if err != nil { return err }
defer locker.Release(token)
// хранилище проверяет: принятый max token = 33 → 34 проходит;
// если зомби придёт с token = 33 после того, как видели 34 — отказ
if err := store.Write(ctx, key, val, WithFenceToken(token)); err != nil { ... }
Rust
// токен в типе — запись без него не сконструировать
pub struct Fenced<T> { token: FenceToken, payload: T }
impl Store {
pub fn write(&self, w: Fenced<Write>) -> Result<(), Stale> { ... }
}
Честная оценка. Требование к ресурсу точнее формулируется как монотонная проверка: fencing token — частный случай, версия и conditional write / compare-and-swap выражают ту же защиту. Если же ресурс не умеет ничего из этого (S3 без precondition, внешний API), лок даёт efficiency — не делать работу дважды, — но не correctness; путаница этих двух применений и есть вторая половина инцидентов с «distributed lock», включая известный спор Kleppmann vs antirez про Redlock. И отдельно: consensus (Raft/Paxos) — алгоритм, а не эвристика; своей реализации ему не пишут, берут готовую.
Область применимости. Логические часы решают одну задачу — отношение «произошло раньше». Длительности и дедлайны считаются монотонными часами, доменное и человеческое время остаётся за wall clock, а владение ресурсом подтверждается номером поколения (66), а не временем вовсе. Утверждение карточки — про упорядочивание между узлами, а не про то, что wall clock не нужен.
NTP-дрейф, leap smearing, VM-паузы: System.currentTimeMillis() двух машин несравним, и «last write wins по timestamp» молча теряет записи. Упорядочивание — Lamport clock / HLC / версионные векторы; wall clock — только для людей (логи, метрики) и для TTL с запасом.
Трое часов, которых путают.Wall clock (currentTimeMillis, CLOCK_REALTIME) отвечает на вопрос «когда по календарю»: логи, доменные даты, TTL с запасом. Он прыгает — вперёд и назад — при NTP-коррекции. Monotonic clock (nanoTime, CLOCK_MONOTONIC) не прыгает и потому единственный годится для длительностей, таймаутов и дедлайнов, — но несравним между машинами и обнуляется при перезапуске, поэтому хранить его бессмысленно. Логические часы (Lamport, векторные, HLC) — единственный источник отношения «произошло раньше» между узлами. Типовые баги ровно на подмене: длительность, посчитанная по wall clock, во время коррекции становится отрицательной, а «порядок» по monotonic не имеет смысла за пределами процесса. Отдельный слой — fencing token (66): там нужен не порядок событий, а номер поколения владельца.
Пример кода (фрагмент)
фрагмент
Python · HLC
# физическое время + логический счётчик, монотонность гарантирована
@dataclass(frozen=True, order=True)
class HLC:
wall_ms: int
logical: int
def tick(self, now_ms: int) -> "HLC":
if now_ms > self.wall_ms:
return HLC(now_ms, 0)
return HLC(self.wall_ms, self.logical + 1) # часы ушли назад — растим счётчик
def recv(self, other: "HLC", now_ms: int) -> "HLC":
m = max(self.wall_ms, other.wall_ms, now_ms)
l = max(self.logical if m == self.wall_ms else -1,
other.logical if m == other.wall_ms else -1) + 1
return HLC(m, l)
Java
// симптом для ревью: сравнение времён, записанных разными нодами
if (eventA.timestamp().isBefore(eventB.timestamp())) { ... } // ← флаг, если A и B
// писались разными нодами
Google TrueTime — исключение, подтверждающее правило: Spanner покупает сравнимость часов атомными часами и явным интервалом неопределённости (commit-wait). Если у вас нет атомных часов — у вас нет сравнимых timestamp'ов.
Три разных часов не смешивать: wall clock — для календарного времени и отображения; monotonic clock — для длительностей, таймаутов и дедлайнов; HLC или векторы версий — для причинного порядка. И last-write-wins не всегда ошибка: это допустимая политика сходимости, если потеря конкурирующего обновления — осознанное решение, а не сюрприз.
«Записали в базу и опубликовали в Kafka» — это две системы без общей транзакции: упасть между ними можно всегда, и одна из сторон соврёт. Корректных схем две: outbox (событие пишется в ту же БД-транзакцию, relay / CDC доставляет) или CDC напрямую с WAL. «Сначала publish, потом commit» и наоборот — обе сломаны, только фантомы разного знака.
Пример кода (фрагмент)
фрагмент
SQL
-- одна транзакция: бизнес-факт и событие атомарны
BEGIN;
INSERT INTO payments (id, amount_cents, status) VALUES ($1, $2, 'captured');
INSERT INTO outbox (event_id, aggregate_id, type, payload)
VALUES (gen_random_uuid(), $1, 'PaymentCaptured', $3);
COMMIT;
-- relay (Debezium / поллер) читает outbox → Kafka; доставка at-least-once,
-- поэтому дедупликация у потребителя по event_id обязательна (см. 21)
Go · relay
// at-least-once петля: пометка — после подтверждённой публикации
rows := tx.Query(`SELECT event_id, payload FROM outbox
WHERE published_at IS NULL ORDER BY seq LIMIT 100 FOR UPDATE SKIP LOCKED`)
for _, e := range rows {
if err := producer.Send(ctx, e); err != nil { break } // не помечаем — придём снова
tx.Exec(`UPDATE outbox SET published_at = now() WHERE event_id = $1`, e.ID)
}
Outbox не даёт exactly-once — он даёт атомарность записи и at-least-once доставки; дедупликация всё равно на конце (21). Listen-to-yourself и «просто ретраить publish» — не эквиваленты, а известные способы получить рассинхрон.
Цикл relay не идемпотентен сам по себе — он at-least-once: пометка ставится только после подтверждённой публикации, поэтому падение между send и update даёт повторную доставку, а не потерю. Отсюда обязательные детали: захват строк через FOR UPDATE SKIP LOCKED, ограниченный батч, таймаут на публикацию и дедупликация у потребителя (21).
Распределённые ACID-транзакции существуют (XA, 2PC) и дают более сильный контракт — атомарность на всех участниках сразу, — но платят координацией: участники держат блокировки до конца протокола, а между prepare и решением не вправе решить сами. Реплицированный координатор (журнал решений под Raft) убирает единую точку отказа, но ни блокировки, ни лишних round-trip не убирает. Для продолжительных межсервисных процессов практичнее saga — цепочка локальных транзакций с компенсирующим действием на каждую. Главная цена, которую забывают: пропала изоляция — промежуточные состояния видимы другим участникам, и компенсация не «откатывает», а пишет новый факт (refund ≠ отмена charge).
Пример кода (фрагмент)
Javaфрагмент
// шаги и компенсации объявлены парой; состояние — явный persisted тип
sealed interface SagaStep permits ReserveInventory, ChargeCard, BookShipment {}
record ChargeCard(Cents amount) implements SagaStep {
Compensation compensation() { return new RefundCard(amount); } // новый факт, не rollback
}
// изоляция руками: семантический лок — заказ в статусе PENDING_PAYMENT
// невидим для повторной продажи, пока saga не завершится или не компенсируется
Честная оценка. По мере роста числа шагов явный оркестратор (state machine) обычно легче наблюдать и восстанавливать, чем неявная хореография: вопрос «кто сейчас владеет процессом» перестаёт иметь однозначный ответ ровно тогда, когда он нужен в три часа ночи. Это эвристика, а не порог: для двух участников с ясным контрактом хореография дешевле. Долгие saga = workflow engine (Temporal и т. п.) — не новая идея, а persisted saga-оркестратор.
Кэширование
70
Кэш — это реплика с задокументированным бюджетом staleness
Кэш — не «ускорение», а осознанное ослабление консистентности: TTL — это SLA на устаревание, и он должен быть выведен из бизнес-требования («цена может врать 60 секунд»), а не из «ну пусть 5 минут». Инвалидация по событию и TTL как страховочный пол — хороший дефолт именно вместе: инвалидация теряется не от семантики доставки, а от операционных отказов — упавшего релея, переполнения, ошибки маршрутизации, рассинхрона после rebalance, — и TTL ограничивает ущерб такой потери. Это дефолт, а не обязанность: durable write-through с явно заданными гарантиями тоже корректен.
Пример кода (фрагмент)
Goфрагмент
// TTL как контракт, а не константа в глубине кода
type CachePolicy struct {
TTL time.Duration // = задокументированный staleness budget
StaleWhileRevalidate time.Duration // отдаём протухшее, обновляем фоном
}
var pricePolicy = CachePolicy{TTL: 60 * time.Second, StaleWhileRevalidate: 10 * time.Second}
// ревью-вопрос: "откуда 60?" должен иметь ответ со ссылкой на требование
Cache-aside / read-through / write-through / write-behind — не вкусовщина: write-behind теряет данные при падении кэша и допустим только для восстановимого (счётчики, метрики). Ключ с двумя писателями (сервис и инвалидатор) — гонка по построению; версионируйте значение или пишите через одну точку.
Протухание горячего ключа = синхронный залп всех подписчиков в origin, self-DDoS по расписанию. Три обязательных механизма: коллапс одновременных промахов в один запрос (singleflight), рандомизация TTL (протухают не хором), кэширование отсутствия (иначе каждый запрос несуществующего id — удар в базу).
Пример кода (фрагмент)
фрагмент
Go · singleflight
var g singleflight.Group
func (s *Svc) Product(ctx context.Context, id string) (Product, error) {
if p, ok := s.cache.Get(id); ok { return p, nil }
v, err, _ := g.Do(id, func() (any, error) { // N промахов → 1 запрос в origin
p, err := s.db.Product(ctx, id)
if errors.Is(err, ErrNotFound) {
s.cache.SetNegative(id, 30*time.Second) // negative cache, короткий TTL
return Product{}, err
}
s.cache.Set(id, p, jitterTTL(5*time.Minute)) // ±20% — рассинхрон протухания
return p, err
})
...
}
Python · XFetch
# вероятностное раннее обновление: горячие ключи обновляются ДО протухания,
# вероятность растёт по мере приближения к TTL
import math, random, time
def should_refresh(expires_at: float, delta: float, beta: float = 1.0) -> bool:
return time.time() - delta * beta * math.log(random.random()) >= expires_at
Negative caching без короткого TTL — способ закэшировать транзиентную ошибку навсегда; кэшируйте отсутствие, не ошибку (5xx origin'а в кэш не пишется никогда).
Границы применимости. Negative caching нужен там, где промахи реально дороги, иначе это лишний слой. Ключ кэша обязан включать тенанта и права доступа — иначе singleflight объединит запросы пользователей с разной видимостью и отдаст чужие данные. И отмена контекста первого вызывающего не должна отменять работу для всех, кто к нему присоединился.
VI
Стриминг и пайплайны данных
79
Event time ≠ processing time; watermark — компромисс, а не техническая деталь
Версии. Flink 1.20 / 2.x: в 2.x перегрузки с org.apache.flink.streaming.api.windowing.time.Time удалены в пользу java.time.Duration — сверяйтесь с версией кластера.
Время события и время обработки расходятся всегда: сеть, ретраи, офлайн-клиенты, replay. Watermark — это оценка прогресса event-time: заявление системы «событий старше T мы больше не ждём», из которого следует, когда окно можно считать достаточно полным. Насколько это оценка, а насколько гарантия, задаёт источник: у партиции с монотонным временем — почти гарантия, у мобильных клиентов с офлайном — эвристика с хвостом. Выбор T — компромисс между полнотой и латентностью, и делает его бизнес, а не фреймворк. Late data — нормальный режим, не ошибка: у каждого окна должна быть политика, выбранная осознанно.
Дальше watermark распадается на четыре разных решения, которые в API выглядят как одна настройка: сколько ждать (сам watermark), сколько ещё принимать после закрытия окна (allowed lateness), что делать с тем, что пришло позже (сброс в боковой поток или потеря) и что делать с уже отданным результатом (повторный firing, retraction, ничего). Отдельным пунктом — idle-источник: партиция, в которую перестали писать, замораживает общий watermark, потому что он берётся как минимум по всем партициям, и конвейер встаёт при полностью исправных данных.
Пример кода (фрагмент)
Java · Flinkфрагмент
// каждая константа здесь — бизнес-решение, не конфиг «по умолчанию»
WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(30))
.withIdleness(Duration.ofMinutes(1));
stream.keyBy(Event::customerId)
.window(TumblingEventTimeWindows.of(Duration.ofMinutes(5)))
.allowedLateness(Duration.ofMinutes(10))
// Состояние окна живёт ещё 10 минут после watermark'а. Событие, пришедшее
// в этот интервал, ПОПАДАЕТ в окно и вызывает повторный firing — то есть
// обновлённую версию агрегата. Именно это, а не side output, обязан
// уметь принять даунстрим.
.sideOutputLateData(lateTag)
// Сюда попадает то, что пришло позже window end + allowedLateness:
// в окно оно уже не войдёт и было бы просто отброшено.
.aggregate(new Revenue());
// Рост доли событий в side output — сдвиг реальности, а не «шум»:
// чинить источник или пересматривать 30s.
Честная оценка. «Exactly-once event-time aggregation» не отменяет проблему: окно, закрытое watermark'ом, закрыто по заявленному компромиссу, и опоздавшее событие либо теряется, либо порождает исправление. Даунстрим, не умеющий принимать повторный firing (append-only отчёт), молча фиксирует ложь — политика late data сквозная, до последнего потребителя.
80
Design for replay: репроцессинг — штатная операция, не аврал
Баг в трансформации, новая колонка, испорченный интервал — история будет проигрываться заново, вопрос только в том, спроектировано это или будет изобретаться в 3 часа ночи. Требования по построению: трансформации детерминированы (никаких now(), random(), lookup'ов в мутабельные справочники без версии), sinks идемпотентны или транзакционны, позиция (offset / checkpoint) — first-class управляемый артефакт, retention источника ≥ горизонта replay.
Пример кода (фрагмент)
фрагмент
Java
// враг replay — недетерминизм, спрятанный в трансформации
// ПЛОХО: enrich(event, dict.current()) // replay через месяц даст
// // другой результат
// ХОРОШО: enrich(event, dict.asOf(event.ts())) // temporal join по event time:
// // результат воспроизводим
// // в пределах контракта данных
Go
// replay-запрос как явный тип: границы, скорость, назначение
type ReplayJob struct {
Source TopicRange // {topic, partitions, fromOffset/fromTs, toTs}
Transform VersionedRef // sha трансформации — какой код проигрывает
Sink SinkRef // тот же прод-топик ИЛИ теневой — решение явное
RateLimit TokenBucket // replay не должен вытеснить live-трафик (bulkhead, 24)
}
Replay в тот же даунстрим требует, чтобы вся цепочка ниже была идемпотентна (22, дедуп по event_id — 21); иначе replay в теневой sink, сравнение, cutover. Kappa-архитектура — это принцип replay, возведённый в архитектуру: batch = replay стрима. Следствие для retention: срок хранения топика — не стоимость диска, а страховой горизонт репроцессинга; резать его — решение уровня one-way door (20).
Побайтовое совпадение достижимо только если версионирована вся транзитивная зависимость: код трансформации, схемы и парсеры, справочники, библиотеки, окружение с плавающей точкой. Поэтому «воспроизводимость» проверяется сравнением, а не декларируется, и объявляется в границах контракта данных. Сюда же: стратегия сверки, reconciliation, ограничения retention по закону и приватности, и разделение ресурсов между replay и живым трафиком.
81
Poison pill не останавливает партицию; DLQ — обязательство, не мусорка
Один непарсящийся message в партиции при наивном consumer'е останавливает всю партицию навсегда (crash-loop на одном offset'е). Политика по построению: N попыток → dead letter с полным контекстом (исходный message, ошибка, offset, стектрейс, версия схемы) → партиция едет дальше. Но DLQ — очередь необработанных обязательств: у неё есть владелец, алерт на рост и процедура re-drive; DLQ без потребителя — это /dev/null с чистой совестью.
Пример кода (фрагмент)
Goфрагмент
if err := process(msg); err != nil {
if !errors.Is(err, ErrTransient) || attempts(msg) >= 3 {
dlq.Send(ctx, DeadLetter{
Original: msg.Raw, Topic: msg.Topic, Offset: msg.Offset,
Error: err.Error(), SchemaVer: msg.Header("schema"), FirstSeen: now,
})
consumer.Commit(msg) // партиция разблокирована — это и была цель
return nil
}
return err // transient → retry с backoff (24), offset не двигаем
}
Честная оценка. DLQ ломает per-key ordering: если message N ушёл в DLQ, а N+1 того же ключа обработан, re-drive применит N после N+1. Для ordering-чувствительных ключей (балансы, статусы) корректная альтернатива — парковка всего ключа (pause key, не партицию) либо re-drive через полный replay ключа (80). Выбор — по инварианту, и он должен быть записан.
«Поставить ключ на паузу» — не встроенная операция Kafka, а отдельная архитектура: буферизация или маршрутизация по ключу, pause() партиции либо retry-топики с восстановлением порядка. Плюс детали, без которых DLQ вредна: счётчик попыток вне оперативной памяти, редакция PII в payload, метаданные схемы и версии, идемпотентный re-drive, карантин для ядовитых сообщений и обрезка стектрейсов.
82
State — самая дорогая часть стриминга; его схема планируется как схема БД
Stateless-оператор перезапускается бесплатно; stateful тащит за собой чекпойнты, savepoint-совместимость и миграции. State — это база данных, размазанная по джобу: у каждого оператора стабильный uid (иначе savepoint не восстановится после рефакторинга), у каждого state-типа — эволюционируемая сериализация (POJO/Avro, не Kryo по умолчанию), у каждого ключа — TTL или явное решение «живёт вечно» (иначе state растёт монотонно до OOM — Lehman, 45, в одном джобе).
Пример кода (фрагмент)
Java · Flinkфрагмент
// три решения, которые нельзя принять «потом»
stream.keyBy(...)
.process(new Dedupe())
.uid("dedupe-v1"); // 1. стабильный uid: переименование класса
// не должно терять state
StateTtlConfig ttl = StateTtlConfig // 2. TTL — из бизнес-горизонта
.newBuilder(Duration.ofDays(7)) // (дедуп-окно), не «пока влезает»
.cleanupInRocksdbCompactFilter(1000).build();
// 3. тип state'а — POJO/Avro с явной эволюцией; Kryo generic = state,
// который нельзя изменить без полной пересборки из источника (= 80)
Проверка перед каждым релизом джоба: «восстановится ли этот код из прошлого savepoint'а?» — это contract test (74) между версиями самого себя. Если ответ «нет» — это миграция state'а со своим expand — migrate — contract, а не деплой.
Стоимость state — не только heap: RocksDB упирается в локальный диск, компакции, усиление чекпойнтов и время восстановления. К трём решениям добавляются снапшоты сериализаторов, maxParallelism и key groups (задаётся один раз и ограничивает будущее перешардирование) и семантика очистки TTL — ленивая уборка не освобождает диск в момент истечения.
Область применимости. Wide events не заменяют метрики. Рабочая позиция — комплементарность: метрики для дешёвого агрегатного детектирования, трейсы для причинной цепочки, события для деталей, профили для CPU/памяти. Высокая кардинальность требует контроля стоимости и приватности.
Мониторинг проверяет заранее сформулированные условия; observability — способность разобраться в состоянии, которое никто не предвидел, не выкатывая ради этого новый код. Wide structured events с высокой кардинальностью — сильный механизм такой способности, а не её определение: метрики, трейсы и профили отвечают на другие вопросы и не заменяются событиями.
Пример кода (фрагмент)
фрагмент
Go
// Один широкий event на запрос вместо десятка разрозненных лог-строк
logger.Info("request_completed",
"trace_id", traceID, "customer_id", custID, "endpoint", "/charge",
"duration_ms", ms, "retry_count", retries, "gateway", gw, "outcome", outcome,
"amount_cents", amt, "deploy_sha", buildSHA)
// потом: "покажи p99 по gateway для клиентов из EU на версии abc123" — отвечается
Python · structlog
# контекст накапливается по пути запроса
log = structlog.get_logger().bind(trace_id=tid, customer_id=cid)
...
log.info("request_completed", duration_ms=ms, outcome="declined", reason=r)
Логи десяти сервисов без общего идентификатора — это десять несвязанных историй об одном запросе. Trace ID полезен ровно настолько, насколько переживает каждый переход: один сервис, потерявший контекст, разрывает цепочку целиком — поэтому пропагация это обязательство на каждой границе, а не настройка библиотеки в одном месте.
Пример кода (фрагмент)
фрагмент
Java · OpenTelemetry
// контекст течёт через HTTP → Kafka → Flink автоматически,
// если пропагация настроена; вручную — inject/extract на каждой границе
var headers = new RecordHeaders();
GlobalOpenTelemetry.getPropagators().getTextMapPropagator()
.inject(Context.current(), headers, (h, k, v) -> h.add(k, v.getBytes()));
producer.send(new ProducerRecord<>(topic, null, key, value, headers));
Rust-пример показывает только локальный span. Через сетевую границу контекст сам не переезжает: нужен явный inject на продьюсере и extract на консьюмере через TextMapPropagator. Для Kafka — заголовки с фиксированной кодировкой и без дублирования уже существующих trace-заголовков.
Область применимости. Исчерпание бюджета не обязано автоматически замораживать любой релиз — это policy decision. Иногда reliability-фикс нужно выпустить именно при исчерпанном бюджете.
Переводят надёжность из религии в бюджет: «99.9% за 30 дней = 43 минуты даунтайма; бюджет исчерпан → фичи стоп, релизы заморожены» — формализованный компромисс.
Детерминированный replay, dry-run режимы, инспектируемое промежуточное состояние. Система без этого имеет неограниченный MTTR.
Пример кода (фрагмент)
фрагмент
Rust · deterministic simulation testing
// FoundationDB/TigerBeetle-стиль: весь I/O за трейтом,
// в тестах — детерминированный симулятор с seed
trait Clock { fn now(&self) -> Instant; }
trait Net { fn send(&self, to: NodeId, msg: Msg); }
// прод: реальные; тест: SimClock + SimNet с контролируемыми отказами
// упал тест с seed=42 → воспроизводится побайтово, всегда
Python · dry-run
# Dry-run как режим, а не как флаг в трёх if-ах
class Executor(Protocol):
def apply(self, change: Change) -> None: ...
class RealExecutor: ...
class DryRunExecutor: # печатает план; логика ВЫШЕ не знает разницы
def apply(self, change: Change) -> None: print(f"WOULD apply: {change}")
Dry-run обязан строить тот же неизменяемый план, который потом применяется. Две отдельные ветки dryRun() и apply() расходятся — и расходятся молча, ровно в тот момент, когда на dry-run полагаются.
VIII
Безопасность и доверие
32
Trust boundaries явно; всё пересекающее границу — парсится
Граница доверия — место, где данные приходят от того, кем вы не управляете: HTTP-запрос, очередь, файл, ответ чужого API, выход модели (48). Правило одно: на границе вход разбирается в тип, а не проверяется по дороге — проверка выбрасывает знание о том, что она прошла (2), и следующий по коду обязан проверять заново. Тип это знание хранит, и компилятор не даёт о нём забыть.
Пример кода (фрагмент)
Rustфрагмент
// Тип фиксирует происхождение данных
pub struct Untrusted<T>(T); // сырое, пришло снаружи
pub struct Sanitized<T>(T); // прошло парсер
impl Untrusted<String> {
pub fn parse_html_fragment(self) -> Result<Sanitized<String>, Rejected> { ... }
}
// функция render(s: Sanitized<String>) физически не примет сырой ввод
Санитизация контекстна. Один тип Sanitized<String> создаёт ложное ощущение универсальной безопасности: безопасное для HTML-текста опасно в атрибуте, в URL, в SQL-идентификаторе и в аргументе shell. Типы должны называть приёмник — SafeHtmlText, SqlIdentifier, ShellArg, SafeUrl — а экранирование выполняться на границе конкретного sink.
Компонент получает ровно те права, которых требует его задача, потому что цена ошибки и цена компрометации определяются не намерениями компонента, а его возможностями. Это не только про IAM и securityContext: в коде тот же принцип — узкий интерфейс. Сервису отчётов, которому передали OrderReader вместо всего репозитория, нечем испортить заказ — ни по злому умыслу, ни по опечатке.
Пример кода (фрагмент)
фрагмент
Go
// Каждый компонент — минимум прав; в коде это узкие интерфейсы возможностей
type OrderReader interface{ GetOrder(ctx context.Context, id OrderID) (Order, error) }
type OrderWriter interface{ PutOrder(ctx context.Context, o Order) error }
// репорт-сервис получает OrderReader; физически не может писать
Инъекция — это стирание границы между программой и данными: склеенная строка уходит интерпретатору (SQL, shell, LDAP, HTML) целиком, и он не может знать, какая её часть была вводом пользователя. Параметризация не «экранирует опасные символы» — она передаёт запрос и значения по разным каналам, поэтому значение в принципе не становится кодом. Ручное экранирование не эквивалент: оно снова полагается на то, что вы угадали грамматику приёмника.
Пример кода (фрагмент)
фрагмент
Python
# Единственный корректный способ во всех языках
cur.execute("SELECT * FROM users WHERE email = %s", (email,)) # не f-string. никогда.
Java
var ps = conn.prepareStatement("SELECT * FROM users WHERE email = ?");
ps.setString(1, email);
Параметризация закрывает значения, но не идентификаторы: имена таблиц, колонок и направление ORDER BY подставить плейсхолдером нельзя. Там работает только allowlist или типобезопасный query builder. Управление самими секретами — отдельная тема (35), здесь речь только про инъекции.
Главный практический вектор атак последних лет — не ваш код.
Два сокращения из заголовка отвечают на разные вопросы. SBOM — опись того, из чего собран артефакт: пакеты, версии, хеши. Она нужна не для отчётности, а чтобы на вопрос «затрагивает ли нас эта CVE» отвечать за минуты машинным запросом, а не за неделю опроса команд. SLSA — уровневая шкала требований не к коду, а к самой сборке: что она идёт из версионированного источника, на изолированном раннере, с подписанным provenance «этот бинарник собран из этого коммита этим пайплайном». Короче: SBOM говорит, что внутри, SLSA — можно ли верить тому, кто это собрал. Пиннинг — предусловие для обоих: без него опись описывает не то, что уедет в прод завтра (83).
Пример кода (конфигурация)
конфигурация
Rust · cargo-deny
# Cargo.lock коммитится + cargo-deny в CI
[bans]
deny = [{ name = "openssl" }] # политика: только rustls
[advisories]
vulnerability = "deny"
Go
// go.sum — криптографические хэши всех зависимостей; публичные модули
// дополнительно сверяются с checksum database (sum.golang.org).
//
// GOPRIVATE=git.example.com/* — приватные префиксы мимо proxy и checksum DB
// GONOSUMDB / GONOSUMCHECK — более узкие настройки и НЕ усиление:
// они ОТКЛЮЧАЮТ сверку, а не включают её
//
// govulncheck в CI — вызовной анализ: алертит только на реально достижимые уязвимости
Python · uv
# lock коммитится; плавающие версии запрещены, SBOM — на каждый релиз
uv lock --check # CI падает, если lock разошёлся с pyproject.toml
uv sync --frozen # ставим ровно то, что в lock, без пересчёта
syft dir:. -o spdx-json > sbom.json
Java · Gradle
# verification-metadata.xml фиксирует хеши всех артефактов сборки
./gradlew --write-verification-metadata sha256 help # сгенерировать
./gradlew build # падает, если хеш зависимости не совпал
# Maven — тот же смысл через dependency-lock и checksum-политику репозитория
Retention и удаление — свойство схемы, а не скрипт. Прямой конфликт с event sourcing разрешается crypto-shredding: PII шифруется per-subject ключом; удаление субъекта = удаление ключа, лог остаётся неизменным.
Пример кода (фрагмент)
Javaфрагмент
record CustomerEvent(UUID customerId, EncryptedPayload pii, byte[] nonPii) {}
// keystore.delete(customerId) => все события субъекта нечитаемы навсегда,
// Kafka-лог не переписывается
Чего crypto-shredding не делает. Удаление ключа делает недоступным именно зашифрованный payload — и только если все копии данных и ключей входят в тот же протокол удаления. Мимо него обычно остаются: PII в логах, трейсах и DLQ; производные таблицы и поисковые индексы; кэши; выгрузки; резервные копии ключей; расшифрованные копии в потребителях; агрегаты, допускающие повторную идентификацию. Crypto-shredding — часть стратегии удаления, а не замена инвентаризации данных и enforcement'а retention.
Кнут в оригинале: не «оптимизация — зло», а «без измерения — зло».
Пример кода (фрагмент)
фрагмент
Java · JMH
// единственный корректный способ микробенчмарка на JVM
@Benchmark
@BenchmarkMode(Mode.SampleTime)
public Portfolio settle(BenchState s) { return Settlement.settle(s.trades, s.rates, DAY); }
// не System.nanoTime вокруг цикла: JIT, warmup, DCE сделают из этого лотерею
Rust · criterion
// статистика + защита от DCE через black_box
c.bench_function("settle", |b| b.iter(|| settle(black_box(&trades), &rates, day)));
Go
// встроенный harness + профили из production (net/http/pprof — включён всегда)
func BenchmarkSettle(b *testing.B) {
for b.Loop() { Settle(trades, rates, day) }
}
// go tool pprof -http=: http://svc:6060/debug/pprof/profile
Python
# pyperf для микро; py-spy/scalene для прода — сэмплирующие, без инструментации
$ py-spy record --pid 4711 --format speedscope
Оговорки к примерам. JMH — стандарт для JVM-микробенчмарков, но не единственный корректный способ измерения: продовые профили и нагрузочные стенды отвечают на другие вопросы. testing.B.Loop требует Go 1.24+. net/http/pprof не «включён всегда»: пакет нужно импортировать и поднять HTTP-обработчик, а в проде закрыть сетью и аутентификацией — это отладочная поверхность.
Область применимости. Формула доказана для фиксированного размера задачи и идеального распараллеливания остатка: она не учитывает ни стоимость самой координации (её добавляет USL, 38), ни рост объёма работы вместе с ресурсами (контрапункт Густафсона). Практическая трудность не в математике, а в измерении s: последовательная доля редко видна в коде и обычно обнаруживается как общий ресурс — лидер, блокировка, одна партиция.
Ускорение при N исполнителях S(N) = 1 / (s + (1−s)/N), где s — доля работы, которую нельзя выполнить параллельно. При N → ∞ предел равен 1/s: 5 % последовательного кода означают потолок ×20, сколько ядер, узлов или воркеров ни добавляй.
Практическая ценность не в формуле, а в том, что она заставляет назвать s. В сервисах последовательная доля почти никогда не выглядит как «однопоточный участок кода»: это единственный писатель, глобальная блокировка, автоинкрементный ключ, координация транзакции, лидер в кворуме, общий рейт-лимитер, одна партиция, в которую сходится горячий ключ. Пока эта доля не найдена и не измерена, горизонтальное масштабирование — покупка ядер под потолок, посчитанный кем-то другим.
Закон посчитан для фиксированного размера задачи, и в этом его граница. Контрапункт Густафсона: если с ростом ресурсов растёт и объём работы (больше данных, выше разрешение, шире окно), доля последовательной части падает, и практический выигрыш ведёт себя лучше, чем обещает формула. Оба утверждения верны и отвечают на разные вопросы: «во сколько раз быстрее та же задача» и «насколько большую задачу можно взять за то же время». Что закон Амдала не описывает вовсе — падение производительности от самого согласования; это добавляет USL (38).
Область применимости. USL — модель с подгоняемыми параметрами, а не теорема: σ и κ получают регрессией по измерениям конкретной системы и между системами они не переносятся. Предсказательная сила ограничена диапазоном, в котором мерили; дальше это экстраполяция, и её честно называть гипотезой для следующего эксперимента.
USL: пропускная способность C(N) = λN / (1 + σ(N−1) + κN(N−1)). Второй член (когерентность κ) объясняет, почему добавление узлов даёт отрицательный прирост: crosstalk растёт квадратично.
Отношение к закону Амдала (89) прямое: при κ = 0 USL вырождается в него, а σ — та самая последовательная доля. Разница в том, что закон Амдала выводится из арифметики и даёт потолок — кривая выходит на плато, — а USL добавляет член, из-за которого кривая после максимума идёт вниз: согласование между узлами само становится работой. И разница в статусе: у Амдала нечего подгонять, у USL σ и κ существуют только как результат регрессии по вашим измерениям.
Практический вывод для Kafka/Flink: перед горизонтальным скейлом измерь σ и κ на нагрузочном стенде — иначе платишь за ноды, снижающие throughput.
σ (contention) и κ (coherency) не известны заранее — они оцениваются подгонкой кривой по измерениям на нескольких уровнях конкурентности. USL полезен как модель для экстраполяции и как язык для разговора, а не как формула, в которую подставляют числа из документации.
Область применимости. Все примеры исходят из cache line 64 B — это platform-specific. Размер проверяется на целевой платформе, выигрыш — hardware counters, а не рассуждением. @Contended — internal API JDK и требует отдельного флага запуска.
Cache line 64B, false sharing, NUMA, стоимость syscall. Смежно с DOD, но шире — включает знание рантайма.
Ключ ко всему списку один: процессор работает не с байтами, а со строками кэша по 64 байта. False sharing — два потока пишут в разные переменные, случайно попавшие в одну строку: логически конкуренции нет, физически строка мечется между ядрами, и код тормозит без единого лока (лечится паддингом до размера строки). NUMA — на многосокетной машине у памяти есть «свой» и «чужой» сокет, обращение к чужому дороже, поэтому важно, где поток запущен относительно своих данных. Syscall дорог не переключением как таковым, а тем, что после него кэши и предсказатель переходов работают уже не на вас, — отсюда батчинг вместо поштучных вызовов. Общее у всех четырёх: стоимость невидима в исходнике и проявляется только измерением (37).
Пример кода (фрагмент)
фрагмент
Java
// false sharing: два счётчика в одной cache line убивают многопоточный инкремент
@jdk.internal.vm.annotation.Contended // или padding вручную
volatile long producerIdx;
Rust
#[repr(align(64))] // счётчик на собственной cache line
struct PaddedCounter(AtomicU64);
Go
// Идиома: per-P шардированные счётчики + padding
type shard struct { n atomic.Uint64; _ [56]byte } // добивка до 64B
Python
# В CPython доминирует не строка кэша, а сам интерпретаторный цикл,
# поэтому механическая симпатия здесь — унести цикл из Python в C
total = sum(row.price for row in rows) # ~10**6 шагов интерпретатора
prices = np.frombuffer(buf, dtype=np.int64) # тот же расчёт одним вызовом:
total = int(prices.sum()) # цикл и векторизация внутри C
# без зависимостей то же делает memoryview:
# срез bytes копирует, срез memoryview — нет
window = memoryview(buf)[offset:offset + size]
Cost-per-request / cost-per-tenant — инженерная метрика с бюджетом и алертом, а не квартальный сюрприз из биллинга. Для multi-cloud egress — архитектурное ограничение первого порядка: cross-region / cross-cloud трафик может стоить дороже compute, и топология данных проектируется от него.
Пример кода (фрагмент)
Goфрагмент
// стоимость атрибутируется в том же wide event (см. 28)
logger.Info("request_completed",
"tenant", tid, "compute_ms", ms, "bytes_egress", n,
"downstream_calls", calls, "est_cost_usd", est) // → cost per tenant/endpoint
// отвечается запросом, не финансами
Связка с 55: fallback-каскад LLM — это cost engineering в чистом виде. Связка с 38: USL говорит, когда добавленная нода даёт отрицательный прирост, а cost SLI — сколько вы за этот отрицательный прирост платите. Unit economics ломается молча: алерт на cost-per-request важнее алерта на абсолютный счёт.
Рамка любого спора о «чистоте»: сложность предметной области неустранима; сложность, привнесённая инструментами и абстракциями, — устранима и подлежит атаке.
Вопрос на ревью: «какую essential-сложность выражает этот слой?» Нет ответа — слой accidental.
Область применимости. Исходная формулировка Галла — «спроектированная сложной с нуля не работает и не может быть починена» — афоризм, а не закон: спроектированные наперёд сложные системы существуют там, где спецификация зафиксирована заранее, а цена ошибки запрещает итерации, — авионика, протоколы, кремний. Эвристика работает там, где требования уточняются по ходу, то есть в большинстве продуктовой разработки, и стоит она ровно столько, сколько стоит промежуточное состояние.
Сложную систему безопаснее выращивать через работающие промежуточные состояния, чем вводить одним big-bang: на эволюционном пути каждый шаг даёт работающую систему, которую можно измерить и откатить, а у большого взрыва первая встреча с реальностью совпадает с датой запуска.
Практика: walking skeleton → инкременты, никогда big-bang.
Область применимости. Критерий — доказанная общая ось изменения (тот же, что в 7), а не счётчик копий; «три» живёт в названии как мнемоника. Обратная крайность так же реальна: одно знание, размноженное по десяти местам, расходится молча (43).
Абстракция появляется тогда, когда повторения обнажили общую ось изменения, — а не по счётчику копий. Первое дублирование дешевле неверной абстракции: неверная абстракция заставляет все будущие случаи врать о своей природе. «На третьем повторении» — мнемоника: к третьему разу ось обычно уже видна.
YAGNI — You Aren't Gonna Need It — читается буквально: не пишите код, который сегодня никому не нужен. Ни функцию «пусть будет», ни параметр «вдруг понадобится», ни фабрику под единственную реализацию. Платят не за строки: этот код с первого дня требует чтения, тестов, правки при каждом рефакторинге — и сообщает читателю ось изменения, которой не существует. Практический тест: если вы не можете назвать второй случай, уже присутствующий в коде, — второго случая нет. «Наверное, понадобится» на практике означает «не понадобится, а если понадобится — форма будет другая».
Как это выглядит на OCP. Интерфейс плюс фабрика вместо if по типу — каноническое «расширение новым кодом, не правкой старого» (1): новый вариант добавляется реализацией, старый код не трогают. Ровно эта конструкция и есть типовое нарушение YAGNI, пока реализация одна: три сущности вместо одной ветки ради оси изменения, которую никто не доказал. Порядок обратный привычному — сначала второе и третье повторение, потом абстракция; OCP применяют к оси, которая уже проявилась, а не к предсказанной.
Одинаковые пять строк в двух bounded contexts — не дублирование: они меняются по разным причинам. Их «устранение» связывает то, что обязано меняться независимо.
Обе крайности стоят денег. Скопированное знание — одно правило, физически лежащее в трёх местах, — расходится молча: правку внесли в две копии из трёх, третья продолжает считать по-старому, и никто об этом не узнает до инцидента. Устранённое совпадение — сведённый в общий хелпер код двух контекстов, одинаковый только на сегодня, — связывает то, что обязано расходиться: первая же различающаяся правка приезжает флагом в сигнатуре. Вопрос всегда один и тот же: это одно знание или два, которые сейчас выглядят одинаково. Признак второго — package common / utils, растущий бесконечно: у свалки нет владельца и нет собственной причины меняться, потому что причин у неё столько же, сколько потребителей.
Bounded context (14) — граница, внутри которой слова значат одно и то же: «Customer» биллинга и «Customer» маркетинга — разные типы, даже когда поля совпадают. DRY действует внутри такой границы. Через границу одинаковый текст — совпадение, а не дублирование, и выносить его в общий модуль значит склеить два контекста через код, который никому из них не принадлежит.
Пример кода (псевдокод)
Goпсевдокод
// НЕ надо: pkg/common/validation.go, куда billing и crm ходят за validateEmail
// НАДО: у каждого контекста своя копия — биллингу вскоре понадобится
// RFC-строгость для инвойсов, CRM — терпимость к маркетинговым мусорным адресам.
// Общая функция заставила бы один из контекстов врать.
Не удаляй код, назначения которого не понял. git log -S"strange_condition" перед удалением — минимальная процедура. Контрмера к рефакторинг-энтузиазму.
45
Lehman's Laws / технический долг как термодинамика
Область применимости. Законы Лемана выведены для E-type систем (встроенных в изменяющуюся среду) на материале 1970–90-х. Это эмпирические наблюдения, а не теоремы, а «термодинамика» в заголовке — метафора, которая не наследует ни одного свойства физической энтропии.
Используемая система обязана меняться; её сложность растёт, пока в неё не вкладывают работу специально. Бюджет на снижение энтропии — регулярная статья, а не «когда-нибудь потом».
Два разных утверждения в одной карточке, и они разного веса. Наблюдения Лемана — эмпирика: система, встроенная в изменяющуюся среду (E-type), либо меняется, либо теряет пригодность, и её структура деградирует, если работу по её сохранению не финансируют отдельно. «Термодинамика» же — метафора, и она врёт в главном: рост сложности не самопроизвольный процесс, а сумма локально рациональных решений, каждое из которых по отдельности дешевле правильного. Из физической аналогии следовало бы «сопротивляться бесполезно»; из эмпирики следует ровно обратное — это статья бюджета, и она управляема.
Мера удивления — не вкус автора, а сложившаяся идиома языка и окружающего кода: удивляет то, что ведёт себя иначе, чем девяносто девять похожих мест. Практическое правило: если поведение приходится объяснять комментарием, дешевле убрать поведение, чем написать комментарий — читатель, не заглянувший в него, всё равно ошибётся (47).
Пример кода (фрагмент)
фрагмент
Python
# Классика нарушения — мутабельный дефолт
def add_item(item, bucket=[]): ... # bucket разделяется между вызовами
def add_item(item, bucket=None): # ожидаемое поведение
bucket = bucket if bucket is not None else []
Java
// equals без hashCode: объект кладётся в HashSet и «пропадает» из него
class Money {
@Override public boolean equals(Object o) { ... } // hashCode — от Object
}
// set.add(new Money(5)); set.contains(new Money(5)) → false
// record генерирует equals и hashCode вместе и не даёт им разойтись
record Money(long cents, Currency currency) {}
Go
// nil-указатель в интерфейсе — не nil-интерфейс: у интерфейса два поля,
// тип и значение, и непустой тип делает его непустым целиком
func find() *MyErr { return nil }
var err error = find() // тип *MyErr, значение nil
if err != nil { // истина — и это удивляет каждого второго
log.Print("ошибка, которой нет")
}
// НОРМА: наружу возвращается error, а не конкретный тип ошибки
func find() error { return nil } // здесь err == nil, как и ожидается
Rust
// Deref предназначен для указателей, а не для is-a: через него методы
// Inner молча появляются у Wrapper, и читатель не найдёт их объявления
impl Deref for Wrapper {
type Target = Inner;
fn deref(&self) -> &Inner { &self.0 }
}
// НОРМА: делегировать явно ровно то, что обещано наружу (88)
impl Wrapper {
pub fn id(&self) -> Id { self.0.id() }
}
47
Код оптимизируется под чтение, а не под скорость написания
Область применимости. «На порядок чаще» из расхожей формулировки — риторическая оценка, а не измерение; проверяемая часть утверждения не в множителе, а в наборе проходов, через которые текст идёт после написания.
Обоснование явности над магией: в Java — меньше рефлексии и аннотационной магии; в Rust — меньше макро-DSL; в Go — это философия языка целиком; в Python — «explicit is better than implicit» из import this.
Расхожая формулировка — «код читается на порядок чаще, чем пишется». «На порядок» здесь риторика, а не измерение, и держать надо не её. Проверяемая часть: после первого набора текст проходит через ревью, диагностику инцидента, правку соседней команды и чтение того, кто ищет, где именно ломается, — и стоимость каждого из этих проходов зависит от того, сколько поведения выражено явно. Магия экономит время ровно один раз — в момент написания, тем, кто уже держит всю картину в голове.
У команды примерно три innovation tokens; каждая «интересная» технология тратит один — на операционку, найм, неизвестные failure modes. Boring ≠ плохое: boring = failure modes известны и загуглены. Postgres, пока не доказано обратное, — не консерватизм, а экономика.
Проверяемая форма. Новая технология в стеке требует ADR с ответом «какую essential-сложность (40) не решает скучный вариант» плюс измерение (37), а не бенчмарк из блога вендора. Токен тратится по профилю нагрузки, не по конференц-докладу.
Область применимости. В карточке два разных наблюдения. Закон Брукса — про стоимость координации: он зависит от декомпозируемости задачи, цены онбординга и стадии проекта, и на слабо связанной работе с готовым онбордингом почти не проявляется. Second-system effect — про мотивацию автора, а не про арифметику коммуникаций, и наступает даже в команде постоянного размера. Оба — наблюдения из «Мифического человеко-месяца», не универсальные ограничения.
Добавление людей в опаздывающий проект замедляет его: коммуникация растёт квадратично, онбординг отбирает самых знающих. Second-system effect: вторая версия — самая опасная, в неё сгружают всё, что «не влезло» в первую, — прямое нарушение Gall's Law (41) изнутри команды-автора.
Кодом не выражается; выражается решением «резать скоуп, а не нанимать» и требованием к rewrite-предложениям проходить через strangler fig (73), а не через «на этот раз сделаем правильно».
Сборка — чистая функция от объявленных входов; всё незадекларированное (системный toolchain, сеть, время, latest-теги) делает выход невоспроизводимым, а кэш — лотереей. Следствие, ради которого всё затевается: агрессивное кэширование (локальное, remote, build avoidance) корректно только при герметичности — cache hit по неполному ключу это тихая доставка stale-артефакта в прод.
Пример кода (фрагмент)
фрагмент
Gradle · Kotlin DSL
// условия кэшируемости — те же условия герметичности
tasks.register<Test>("integrationTest") {
inputs.files(configurations.runtimeClasspath).withNormalizer(ClasspathNormalizer::class)
inputs.property("jdk", java.toolchain.languageVersion) // toolchain — вход, не среда
outputs.cacheIf { true }
// системное время, env-переменные, сеть — НЕ входы;
// тест, читающий их, либо декларирует, либо некэшируем
}
CI
# проверка воспроизводимости — сама по себе тест в CI:
# два билда с чистым кэшем на разных агентах → диффа артефактов быть не должно
diff <(sha256sum build-a/*.jar) <(sha256sum build-b/*.jar)
Честная оценка. Полная герметичность в стиле Bazel/Nix дорога; прагматичная граница — герметичность ключа кэша: всё, что влияет на выход, входит в ключ. Инцидент «работает локально, падает в CI» — почти всегда незадекларированный вход, найденный эмпирически; каждый такой — патч в декларацию, не в README.
outputs.cacheIf { true } не делает задачу герметичной — оно только разрешает кэширование. Корректность даёт полнота декларации: все входы, toolchain, переменные окружения, локаль и сетевые зависимости либо объявлены, либо устранены. Для воспроизводимых архивов дополнительно нужны нормализованные timestamps и порядок файлов.
84
Trunk-based: долгоживущая ветка — это отложенная интеграция с процентами
Область применимости. Merge queue и 10–15 минут CI — ориентиры из практики крупных монорепозиториев, а не константы. Флаги — не единственный механизм: branch by abstraction и обратно-совместимые срезы дают то же без flag debt.
Merge-конфликт не создаётся слиянием — он создаётся расхождением и лишь обнаруживается слиянием; сотни долгоживущих веток = организация, месяцами копящая необнаруженные конфликты. Trunk-based переворачивает: интеграция непрерывна, незавершённая работа прячется за флагами (72), а не за ветками. Ветка живёт дни, не спринты.
Механика, без которой trunk-based на 80+ сервисах разваливается: merge queue (батчи PR тестируются в порядке будущего merge, не против устаревшего main — иначе «зелёный PR, красный main» ежедневно) плюс required checks быстрее 10–15 минут: медленный CI воссоздаёт долгоживущие ветки экономически, потому что людям дорого интегрироваться часто. Флаги вместо веток переносят риск из «конфликт при merge» в flag debt (72) — осознанный обмен, второе дешевле, потому что видимо.
Кодом не выражается; выражается политикой репозитория и, главное, скоростью CI — trunk-based это в первую очередь SLO на билд.
85
Paved road: платформа — продукт с добровольным принятием
Область применимости. Добровольность распространяется на developer-facing workflow, но не на security/compliance guardrails — те бывают обязательными по построению.
Внутренняя платформа, навязанная мандатом, получает malicious compliance; платформа-продукт выигрывает тем, что paved road дешевле обходного пути: онбординг за час, миграции — забота платформы, не команды. Отклонение легально, но с явной ценой: съехал с paved road — владеешь своим стеком сам, включая on-call и апгрейды. Метрика платформы — adoption и time-to-first-deploy, не количество фич.
Честная оценка. Тёмная сторона paved road — платформа становится монокультурой: её баг — всеобщий баг (blast radius, 27; версионируйте платформу и раскатывайте её саму canary, 72, по командам-потребителям). И Conway (15) во весь рост: платформенная команда, до которой нельзя дойти с вопросом, порождает обходные пути независимо от качества кода. Платформа без поддержки — это библиотека, а не продукт.
Выражается структурой владения и SLA платформенной команды; в коде видна как «шаблон нового сервиса = один командный вызов».
XII
Принципы AI-эры
Два разных множества: (a) инженерия систем, содержащих LLM; (b) инженерия с помощью AI-ассистентов. Смешивать их — категориальная ошибка.
Самый важный принцип раздела. Всё, что модель прочитала (web, документы, письма), может содержать инструкции; следовательно, выход модели имеет уровень доверия своего наименее доверенного входа. Классическая триада риска: доступ к приватным данным + недоверенный контент + канал наружу — никогда все три одновременно.
Пример кода (фрагмент)
фрагмент
Python
# Выход LLM парсится как недоверенный ввод — тот же parse-don't-validate.
# Tagged union: tool и args связаны дискриминатором, иначе
# {"tool": "search_orders", "args": <поля InvoiceArgs>} пройдёт валидацию
class SearchOrders(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
tool: Literal["search_orders"]
args: SearchArgs
class GetInvoice(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
tool: Literal["get_invoice"]
args: InvoiceArgs
ToolCall = Annotated[SearchOrders | GetInvoice, Field(discriminator="tool")]
raw = llm.complete(prompt)
call = TypeAdapter(ToolCall).validate_json(raw) # не eval, не getattr(obj, raw_name)
Rust
// Тот же принцип типом: LLM-текст входит в систему только через парсер
pub struct LlmRaw(String); // недоверенный
pub enum Action { SearchOrders(Query), GetInvoice(InvoiceId) }
impl LlmRaw { pub fn parse(self) -> Result<Action, Rejected> { ... } }
Схема — необходимое, но не достаточное. Валидный JSON всё ещё может содержать полностью легальную и полностью нежелательную команду. После разбора идут детерминированные проверки, которые модель не контролирует: авторизация от имени пользователя, а не агента; принадлежность объекта тенанту; семантические лимиты (суммы, объёмы, диапазоны дат); rate limit; egress-политика.
49
Least privilege для агентов / sandbox для tool use
Агент получает узкий набор инструментов под задачу, а не «весь API». Инструменты с побочными эффектами разделены по уровням риска.
Пример кода (фрагмент)
фрагмент
Go
// Инструменты как capability-объекты: у read-only агента writer'а физически нет
type ReadTools struct{ Orders OrderReader; Docs DocSearcher }
type WriteTools struct{ ReadTools; Refunds RefundIssuer } // выдаётся отдельно, с апрувом
func runSupportAgent(t ReadTools, query string) Answer { ... } // не может сделать refund
Sandbox
# Исполнение сгенерированного кода — только в песочнице
sandbox: { network: none, fs: readonly-except:/tmp, timeout: 30s, memory: 512Mi }
One-way doors (платёж, удаление, отправка письма клиенту) требуют подтверждения; two-way doors (черновик, поиск) — нет. Это принцип reversible/irreversible decisions, применённый к агентам.
Пример кода (фрагмент)
Javaфрагмент
sealed interface AgentAction permits Draft, Search, Irreversible {}
record Irreversible(Action a, Justification j) implements AgentAction {}
// pipeline: Irreversible идёт в очередь апрува; Draft/Search исполняются сразу
Один критерий обратимости приводит к rubber-stamping: если апрувов слишком много, их перестают читать. Порог складывается из обратимости, масштаба воздействия, полномочий инициатора и неопределённости. И апрув должен быть привязан к хешу конкретного неизменяемого действия — иначе между «показали на подтверждение» и «выполнили» остаётся TOCTOU-щель (time-of-check to time-of-use: между проверкой и использованием).
Evals — это тесты для недетерминированного компонента. Без evals замена промпта/модели — вслепую. Иерархия по цене: assertion-based → golden set + метрики → LLM-as-judge (сам нуждается в калибровке против человеческой разметки).
Пример кода (фрагмент)
Pythonфрагмент
# Уровень 1: дешёвые программные проверки, гоняются на каждый PR промпта
def eval_extraction(case: Case) -> Score:
out = pipeline(case.document)
return Score(
json_valid = is_valid_json(out),
schema_ok = validate_schema(out, InvoiceSchema),
amounts_ok = out["total"] == case.expected_total, # точный матч, не similarity
no_leak = case.ssn not in out_raw,
)
# Уровень 2: golden set версионируется рядом с промптом; регрессия = красный CI
# Уровень 3: LLM-judge — только для стиля/тональности,
# с периодической сверкой с людьми
Уровень 1 — не весь eval. Недетерминированный компонент требует: повторных прогонов и доверительных интервалов вместо одного числа; срезов (по языку, длине, типу документа), потому что среднее прячет провал на срезе; adversarial-кейсов; калибровки LLM-судьи против человеческой разметки; проверки на утечку golden set в промпт; и наблюдения за дрейфом на проде. Стоимость и латентность меряются рядом с качеством, а не отдельно.
52
Промпты, схемы и конфиг модели — версионируемые артефакты
Промпт = код: ревью, версия, changelog, откат. Модель пиннится точной версией — gpt-x-latest в проде — это unpinned dependency (Hyrum's law для моделей: поведение любой версии — наблюдаемый контракт, смена версии — breaking change до доказательства обратного).
Пример кода (конфигурация)
prompts/invoice_extract/v3.yamlконфигурация
# рядом golden set и eval-результаты
model: claude-haiku-4-5-20251001 # датированный snapshot; claude-haiku-4-5 —
# алиас семейства, то есть unpinned dependency
temperature: 0
prompt_sha: 8f3ab2
eval_baseline: { schema_ok: 0.998, amounts_ok: 0.974 } # ниже — деплой блокируется
temperature: 0 не даёт детерминизма — на нём его не строят. Версионируется весь контур: снапшот модели и провайдер, системный промпт, определения инструментов, схемы, версия retrieval-индекса, параметры рантайма и конфигурация безопасности.
Свободный текст между машинными компонентами — это stringly-typed интерфейс. Схема задаётся генерации, выход валидируется всё равно (двойной контроль).
Пример кода (фрагмент)
фрагмент
Java
// схема из типа, выход — в тип
record Invoice(String number, long totalCents, LocalDate due) {}
var invoice = mapper.readValue(llm.completeWithSchema(prompt, schemaOf(Invoice.class)),
Invoice.class); // и это может кинуть — обрабатываем
Go
type Extract struct {
Number string `json:"number"`
TotalCents int64 `json:"total_cents"`
}
// json.Unmarshal + доменная валидация ПОСЛЕ анмаршала — модель умеет
// вернуть синтаксически валидный, семантически ложный JSON
Тот же functional core, imperative shell: LLM-вызов — это I/O-эффект оболочки. Ядро (парсинг, маршрутизация, бизнес-правила) остаётся детерминированным и тестируется без модели.
Пример кода (фрагмент)
Rustфрагмент
trait Completion { async fn complete(&self, req: Req) -> Result<Raw>; }
// прод: HTTP-клиент; тест: записанные фикстуры (golden traces) — пайплайн
// тестируется детерминированно, evals гоняют живую модель отдельно и реже
Модель — зависимость с нетривиальным SLA и стоимостью. Каскад: кэш → дешёвая модель → дорогая модель → правило/шаблон → честный отказ. Circuit breaker и бюджеты (токены/латентность/деньги) — те же принципы из раздела IV, применённые к новому виду зависимости.
Пример кода (фрагмент)
Pythonфрагмент
async def classify(ticket: Ticket) -> Category:
if (hit := semantic_cache.get(ticket.embedding, threshold=0.97)): return hit
try:
return await small_model.classify(ticket, timeout=1.5)
except (Timeout, Overloaded):
return rules_fallback(ticket) # деградация видима в метриках, не молчалива
Риск семантического кэша. Похожесть эмбеддингов — не эквивалентность запросов, а общий кэш — канал утечки между тенантами. Ключ обязан включать тенанта, права пользователя, модель, версию промпта, версию индекса и состояние инструментов; доля ложных попаданий измеряется, а не предполагается.
RAG-ответ без ссылки на извлечённый фрагмент — непроверяемое утверждение. Но и непустой список ссылок ничего не гарантирует: проверять надо поддержку каждого утверждения отдельно, а не наличие библиографии у ответа целиком.
Пример кода (фрагмент)
Pythonфрагмент
class Claim(BaseModel):
text: str
support: list[ChunkRef]
support_status: Literal["supported", "partial", "unsupported"]
class GroundedAnswer(BaseModel):
claims: list[Claim] # утверждения, а не сплошной текст
@model_validator(mode="after")
def must_be_supported(self):
for c in self.claims:
if c.support_status != "supported":
raise ValueError(f"unsupported claim: {c.text[:60]}")
for ref in c.support:
if not index.exists(ref): # chunk реально существует
raise ValueError(f"phantom citation: {ref}")
if not acl.readable(ref, self.actor): # и виден этому пользователю
raise ValueError(f"citation leaks source: {ref}")
return self
Валидатор проверяет форму. Содержательная часть — отдельный шаг: entailment-проверка (поддерживает ли фрагмент утверждение) и coverage-метрика — какая доля проверяемых утверждений имеет поддержку. Обе выносятся в evals (51), потому что стоят дорого и недетерминированы. Отсутствие поддержки → «не знаю» дешевле галлюцинации.
Токены, стоимость, латентность, версия промпта и модели — в каждом trace-span, по семантическим соглашениям OpenTelemetry gen_ai.*, а не по самодельным именам: иначе дашборды и бэкенды не понимают ваши атрибуты. Дрейф качества ловится непрерывными evals на проде, не жалобами пользователей.
Пример кода (фрагмент)
Go · OpenTelemetry GenAIфрагмент
span.SetAttributes(
// стандартные атрибуты gen_ai.* — их понимают готовые дашборды
attribute.String("gen_ai.provider.name", "anthropic"),
attribute.String("gen_ai.request.model", "claude-haiku-4-5-20251001"),
attribute.Int("gen_ai.usage.input_tokens", usage.In),
attribute.Int("gen_ai.usage.output_tokens", usage.Out),
// своё — только там, где стандартного атрибута нет
attribute.String("app.prompt_sha", promptSHA),
attribute.Float64("app.cost_usd", cost),
attribute.Bool("app.cache_hit", hit),
)
// Содержимое (промпты, ответы, аргументы инструментов, retrieval-запросы)
// в span НЕ пишется: gen_ai-конвенции отдельно предупреждают, что там PII.
Содержимое — отдельный поток с другими правилами. Capture выключен по умолчанию и включается точечно; редакция PII — до экспортёра, а не в бэкенде; длина обрезается; включается сэмплирование; у метаданных и содержимого разные retention и ACL; разделение по тенантам обязательно; секреты в атрибутах спана недопустимы. В обычном трейсе едет версия промпта (prompt_sha), а не сам промпт.
58
Данные и их происхождение — под версионным контролем
Data-centric подход: качество системы чаще ограничено данными, чем моделью. Датасеты и эмбеддинг-индексы версионируются (DVC / lakeFS / Iceberg snapshots), lineage прослеживается: какой индекс, из каких документов, каким чанкером, какой моделью эмбеддингов.
Смена embedding-модели = полная переиндексация = migration с expand-contract, не in-place.
В git кладут не датасеты, а неизменяемый манифест: идентификаторы объектов и их хеши, схема, версия парсера и чанкера, модель эмбеддингов, версия политики доступа. Сами данные живут в объектном хранилище с версионированием.
B · Инженерия с AI-ассистентами
59
Ревью — узкое место; генерация дешева, верификация дорога
Область применимости. Жёсткий лимит на размер PR порождает искусственно раздробленные изменения. Работает risk-based ревьюабельность: логическая связность, отделение сгенерированного кода, владение, семантический дифф, поэтапная раскатка.
Стоимость сместилась: написать код почти бесплатно, проверить — нет.
Следствия: маленькие диффы (ревьюабельность важнее скорости генерации), запрет на PR больше, чем ревьюер способен честно прочитать, и ответственность автора не делегируется — «модель написала» не оправдание в постмортеме.
Чем строже типы и контракты, тем меньше степеней свободы у генерации ошибиться: Rust / строгий mypy дают ассистенту компилятор как немедленный фидбек-луп.
Тесты пишутся и ревьюятся человеком с особой тщательностью — сгенерированный тест, подогнанный под сгенерированный баг, зелёный и бесполезен.
Ассистент не удаляет непонятый код — и человек не принимает непонятый сгенерированный код. «Работает, но не знаю почему» — это накопление кода, у которого никогда не было понимавшего его автора; долг нового типа.
Область применимости. «Качество генерации = качество контекста» редуктивно: результат зависит ещё от модели, задачи, инструментов, policy и eval-петли. Маленькие файлы — не самоцель, связность важнее размера.
Качество генерации = качество контекста.
Практики: CLAUDE.md / conventions-файлы в репозитории (соглашения проекта как машиночитаемый артефакт), структура репо, дружественная извлечению (маленькие файлы, говорящие имена), актуальные README у модулей. Документация впервые получила потребителя, который её реально читает.
XIII
Сводная таблица конфликтов
Принципы — эвристики с областью применимости, не аксиомы. Известные пары противоречий:
Пары принципов, которые тянут в разные стороны, и способ разрешения
Конфликт
Разрешение
DOD ↔ information hiding
Hot path — DOD; всё остальное — hiding. Граница по профилю
Event sourcing ↔ privacy by design
Crypto-shredding, вместе с инвентаризацией всех копий данных и ключей
DRY ↔ bounded contexts
DRY только внутри контекста
Local reasoning ↔ dataflow
Dataflow там, где планировщик даёт измеримый выигрыш
OCP ↔ YAGNI
Rule of three, а точнее — доказанная ось изменения
Postel ↔ эволюция схем
Строгость на внешней границе, толерантность к новым полям на внутренних контрактах
Backpressure ↔ availability
Load shedding — тоже availability, но честная
HITL ↔ автономность агентов
Порог по обратимости, влиянию и полномочиям, не по «уверенности» модели
Linearizability ↔ latency/availability (PACELC)
Линеаризуемость только для инвариантов (деньги, уникальность); остальное — session guarantees
Кэш ↔ консистентность
TTL = задокументированный staleness budget, выведенный из бизнес-требования
Saga ↔ изоляция
Семантические локи и pending-состояния; компенсация — новый факт, не rollback
Feature flags ↔ local reasoning
Removal date в декларации флага; удаление — часть DoD
Canary ↔ миграции схем
Expand — migrate — contract обязателен: две версии кода живут одновременно всегда
Outbox ↔ latency записи
Outbox добавляет строку в транзакцию — это цена корректности; CDC с WAL, если и она велика
Chaos engineering ↔ error budget
Chaos тратит бюджет осознанно и останавливается при его исчерпании
Boring technology ↔ mechanical sympathy
Innovation token тратится по измеренному профилю, не по докладу
Contract testing ↔ скорость провайдера
Провайдер получает право ломать то, что ни один контракт не использует, — это ускорение, не тормоз
Watermark: полнота ↔ латентность
Bounded out-of-orderness — из бизнес-требования; late data меряется и имеет политику до последнего потребителя
Replay ↔ побочные эффекты
Эффекты только за идемпотентным/транзакционным sink; иначе replay в теневой sink + сравнение
DLQ ↔ per-key ordering
Для ordering-чувствительных ключей — парковка ключа или re-drive через replay ключа, не сообщением
Retention как стоимость ↔ replay-горизонт
Retention = страховой горизонт репроцессинга; сокращение — one-way door с ADR
Кэш сборки ↔ корректность
Cache hit легален только при герметичном ключе; невоспроизводимость — баг, не особенность
Trunk-based ↔ незавершённые фичи
Флаги или branch by abstraction вместо веток: скрытый долг меняется на видимый
Paved road ↔ автономия команд
Отклонение легально с полной ценой владения; guardrails безопасности остаются обязательными
Paved road ↔ blast radius
Монокультура платформы: версионирование и canary самой платформы по командам
Immutable infra ↔ stateful-системы
Машины — cattle, данные — pet: пересоздаём compute, мигрируем данные, не путаем
Shadow-run ↔ стоимость
Двойной прогон — временный, с датой cutover и порогом diff-rate, записанными до старта
Инженерное решение — это выбор, какой принцип нарушить в данной точке, с записанным обоснованием (ADR).