Проектирование и соблюдение контрактов
Подстановимость — это поведение, а не форма: типокорректная реализация всё равно может ломать вызывающих. Зафиксируй контракт как пред/пост/инварианты, форму закодируй в типах, а поведение прибей одним общим контрактным тестом против каждой реализации — и дрейф будет пойман.
Правило Лисков легко проговорить — подтипы должны быть подстановимы вместо своего базового типа — и легко нарушить так, что никто не заметит. Ты выпускаешь интерфейс, три команды его реализуют, и каждая реализация проходит свои собственные тесты. Полгода спустя вызывающий меняет одну реализацию на другую, и очередь забивается, потому что эта реализация тихо решила, что «подтвердить» означает «запланировать на потом», а не «надёжно сохранено». Ничего не упало. Ни один тест не провалился. Подстановка всё это время была небезопасной — ты просто ещё не задействовал её.
Причина в том, что интерфейс зафиксировал только формы — имена методов и типы. То, что на самом деле должно выполняться во всех реализациях, — это поведение: что вызывающий вправе предполагать до вызова, что ему обещано после и что остаётся истинным на всём протяжении. Это поведение и есть контракт, а интерфейс, который его не формулирует, позволяет каждому реализующему выдумать собственный.
После этого урока ты можешь написать явный поведенческий контракт для интерфейса — его предусловия, постусловия и инварианты — и решить, какие пункты закодировать в системе типов, а какие прибить общим контрактным тестом, прогоняемым против каждой реализации. Ты умеешь распознать реализацию, которая выглядит соответствующей, но тонко нарушает постусловие, объяснить, почему «говори, а не спрашивай» и узкие интерфейсы делают контракты меньше и безопаснее, и понять, когда подстановимость настолько неопределённа, что стоит взять композицию вместо общего супертипа.
Контракт — это три пункта: предусловия, постусловия, инварианты — и подстановимость определяется через них. Предусловие — это то, что вызывающий обязан гарантировать перед вызовом (входы в допустимом диапазоне, система в корректном состоянии). Постусловие — это то, что реализация гарантирует на выходе. Инвариант — это то, что остаётся истинным до и после каждой операции. Правило Лисков, сформулированное точно, именно о них: подстановка может ослабить предусловия (требовать от вызывающих не больше) и усилить постусловия (обещать не меньше) и обязана сохранять инварианты — но никогда наоборот.
/**
* Stores a record and returns its assigned id.
* @pre record.id is unset; record passes validate()
* @post the record is durably persisted before this resolves;
* get(returnedId) returns an equal record
* @inv ids are never reused, even after delete
*/
interface Repository<T extends { id?: string }> {
save(record: T): Promise<string>;
get(id: string): Promise<T | null>;
}Doc-комментарий — не украшение: это та часть интерфейса, на которую вызывающий на самом деле опирается. save, который возвращает управление до того, как запись стала надёжной, не изменил тип — но сломал контракт.
Соответствующая реализация и тонко нарушающая могут быть идентичны по типам — типы ловят форму, а не поведение. Вот два репозитория. Оба точно удовлетворяют интерфейс TypeScript. Один соблюдает контракт; другой усиливает предусловие и ослабляет постусловие — а это ровно запрещённое направление.
// Conforming: weaker preconditions are fine, postcondition is met.
class PgRepository<T extends { id?: string }> implements Repository<T> {
async save(record: T): Promise<string> {
const id = crypto.randomUUID();
await this.db.insert({ ...record, id }); // awaits durability
return id; // post: persisted before resolve
}
async get(id: string) { return this.db.findById(id); }
}
// Violating — yet it compiles and passes naive unit tests.
class CacheRepository<T extends { id?: string }> implements Repository<T> {
async save(record: T): Promise<string> {
if (!record.id) throw new Error("id required"); // STRENGTHENED precondition
this.buffer.push(record); // not yet durable
return record.id; // WEAKENED postcondition
}
async get(id: string) { return this.map.get(id) ?? null; }
}CacheRepository требует, чтобы вызывающий передал id (базовый говорил, что не должен), и возвращает управление до того, как запись стала надёжной (базовый обещал, что станет). Вызывающий, написанный под контракт — «я могу сохранить без id и верить, что оно сохранено», — ломается в тот момент, когда ему вручают эту реализацию. Компилятор молчит, потому что с типами всё в порядке.
Закодируй в типах то, что можешь; остальное прибей общим контрактным тестом, прогоняемым против каждой реализации. Часть пунктов переезжает в систему типов: сделай id действительно необязательным на входе и обязательным на выходе (save(r: Omit<T,"id">): Promise<T & {id: string}>) — и вызывающего нельзя заставить его передать. Но надёжность, порядок, неповторное использование id — свойства поведения во времени — нельзя выразить структурным типом. Для них напиши один набор тестов, параметризованный фабрикой, и прогони его против каждой реализации. Этот набор и есть исполняемый контракт; реализация, которая дрейфует, проваливает общий тест, а не только свой собственный.
// contract.test.ts — one suite, every implementation must pass it.
export function repositoryContract(make: () => Repository<Doc>) {
test("save assigns an id and persists before resolving", async () => {
const repo = make();
const id = await repo.save({ title: "x" }); // no id supplied
expect(await repo.get(id)).toMatchObject({ title: "x" }); // durable now
});
test("ids are never reused after delete", async () => { /* ... */ });
}
// Each implementation opts in — drift in any one is caught here, not in prod.
describe("PgRepository", () => repositoryContract(() => new PgRepository()));
describe("CacheRepository",() => repositoryContract(() => new CacheRepository()));CacheRepository теперь проваливает первое утверждение (он требовал id). Контракт перестал быть комментарием, который люди бегло проглядывают, и стал воротами, которые обязана пройти каждая реализация.
Сожми контракт: «говори, а не спрашивай» и узкие интерфейсы оставляют меньше пунктов для соблюдения — и меньше для нарушения. Каждый метод и каждый геттер, который ты выставляешь, — это пункт, который реализующие обязаны выполнить правильно, и то, к чему вызывающие могут привязаться. «Говори, а не спрашивай» — дай объекту команду и позволь ему решать, вместо того чтобы вытаскивать его состояние и решать за него — убирает целые семейства предусловий («вызывай applyDiscount только когда isEligible истинно» превращается в «вызови checkout(), он сам знает»). Разделение интерфейсов делает то же структурно: толстый Repository с save/get/query/stream/migrate заставляет каждую реализацию соблюдать пункты, которые ей не нужны. Разбей его — и кэш в памяти реализует только Reader: для него нет постусловия migrate, которое можно тихо нарушить.
// Narrow contracts: each implementation honours only the clauses it actually has.
interface Reader<T> { get(id: string): Promise<T | null>; }
interface Writer<T> { save(record: Omit<T, "id">): Promise<T & { id: string }>; }
// A read-through cache is a Reader. It can't violate a Writer postcondition
// it was never asked to make.Меньший контракт — это не просто меньше кода: это меньшая поверхность подстановимости, поэтому у реализации меньше способов оказаться тонко неправильной.
Когда подстановимость по-настоящему неопределённа, предпочитай композицию наследованию. Общий супертип — это обещание, что каждая реализация взаимозаменяема. Если ты не можешь действительно дать это обещание — классический Square extends Rectangle, где setWidth обязан нарушить инвариант независимости сторон Rectangle, или ReadOnlyList extends List, чей add обязан бросать исключение, — то навязанное наследование выпускает ложь, которой вызывающие будут доверять и которую никакой общий тест не спасёт, потому что сам контракт неудовлетворим. Senior-ход — перестать навязывать является и использовать имеет: коллекция только для чтения оборачивает список и выставляет лишь те операции, которые действительно может выполнить. Никакого ложного супертипа, никакого нарушения Лисков, никакого throw new Error("unsupported"), притаившегося за методом, который тип объявляет существующим.
// Don't: ReadOnlyList "is a" List but can't honour add()/remove().
// Do: it "has a" list and exposes only what it can truly promise.
class ReadOnlyView<T> {
constructor(private readonly items: readonly T[]) {}
get(i: number) { return this.items[i]; }
get length() { return this.items.length; }
// no add/remove to lie about
}Интерфейс платёжного шлюза, который дрейфует в проде. Команда определяет один интерфейс и позволяет каждому провайдеру его реализовать:
// Before: contract lives in nobody's head.
interface PaymentGateway {
charge(amountCents: number, token: string): Promise<{ id: string }>;
}Адаптер Stripe списывает синхронно и возвращает управление, как только списание захвачено. Новый адаптер PayPal возвращает управление, как только запрос принят, — захват происходит позже через webhook. Оба компилируются. Оба проходят свои собственные интеграционные тесты. Вызывающий делает const { id } = await gateway.charge(...); markOrderPaid(id); — верно для Stripe, неверно для PayPal, где заказ помечается оплаченным до того, как деньги движутся. Баг проявляется только после замены, в проде, перемежающимся образом.
// After: the contract is explicit AND pinned by a shared test.
interface PaymentGateway {
/**
* @pre amountCents > 0; token is a single-use payment token
* @post resolves ONLY after funds are captured; never reuses an id
*/
charge(amountCents: number, token: string): Promise<{ id: string; captured: true }>;
}
// gateway-contract.test.ts — run against Stripe AND PayPal adapters.
export function gatewayContract(make: () => PaymentGateway) {
test("resolves only after capture", async () => {
const g = make();
const res = await g.charge(100, testToken());
expect(res.captured).toBe(true); // PayPal's accept-only adapter FAILS here
expect(await isCaptured(res.id)).toBe(true);
});
}Адаптер PayPal теперь проваливает общий тест на этапе интеграции, с ясным сообщением, вместо того чтобы тихо помечать заказы неправильно в проде. Исправление тоже становится очевидным: адаптер должен дождаться webhook о захвате перед возвратом управления, либо интерфейс должен разделить authorize и capture, чтобы постусловие, которое адаптер реально может выполнить, было тем, которое он рекламирует. В любом случае дрейф был пойман — единственным тестом, который ни одна реализация не вправе пропустить.
▸Почему это работает
Почему общий тест, а не просто тщательные тесты на каждую реализацию? Потому что тесты на реализацию кодируют трактовку контракта каждым автором — а весь режим отказа в том, что трактовки расходятся. Если автор PayPal считал, что «charge» означает «принять», его собственные тесты честно проверят неправильную вещь и пройдут. Общий набор, написанный один раз против интерфейса, — единственное место, где контракт сформулирован исполняемо; каждая реализация измеряется одними и теми же утверждениями, поэтому дрейфующая трактовка не может спрятаться за своими зелёными тестами. Это разница между тем, чтобы каждый проверял свой экзамен сам, и тем, чтобы один экзамен проверялся одинаково для всех.
▸Частая ошибка
Соблазнительная ошибка — «оно реализует интерфейс, значит подстановимо». Реализация интерфейса доказывает лишь, что формы совпадают; компилятор никогда не читал твой комментарий @post. Подстановимость — свойство поведения, а поведение невидимо структурному типу. Поэтому наличие implements PaymentGateway не говорит тебе ничего о том, захватывает ли charge средства перед возвратом управления. Считай, что у каждого интерфейса две половины — типизированная форма и поведенческий контракт — и помни, что компилятор проверяет только первую. Вторую прибивать тебе: в типах, где можешь, и в общем тесте, где не можешь.
Ты выпускаешь интерфейс, три команды его реализуют, и каждая реализация проходит свои собственные модульные тесты. Месяцы спустя замена одной реализации на другую вызывает перемежающийся баг в проде. Какой способ надёжнее всего поймал бы это до замены?
Подстановимость — свойство поведения, а не формы, поэтому типокорректную реализацию всё равно может быть небезопасно подставлять. Сформулируй контракт явно как три пункта — предусловия (подстановка вправе только ослаблять их), постусловия (вправе только усиливать) и инварианты (обязана сохранять), — а затем прибей каждый пункт там, где он реально живёт: закодируй форму в системе типов, где можешь, и прибей поведение во времени одним общим контрактным тестом, прогоняемым против каждой реализации, где не можешь. Сожми контракт через «говори, а не спрашивай» и узкие, разделённые интерфейсы, чтобы пунктов для соблюдения было меньше и способов дрейфа — тоже. А когда ни один честный супертип не способен выполнить контракт, предпочти композицию наследованию, а не выпускай неудовлетворимое обещание. Режим отказа, от которого это защищает, нем: каждая реализация трактует контракт по-своему, каждый тест на реализацию проходит, и подстановимость размывается незаметно — пока замена в проде не вскроет её. Один общий тест — это то, что делает дрейф громким, а не тихим.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.