ADR: записани архитектурни решения
Накратко: ADR (architecture decision record) е кратък документ с контекста, вариантите, решението и последиците му; старото решение не се пренаписва, а се заменя с ново.
Проблем
Защо сме с опашка?
Нов колега отваря кода на плащането. Известията минават през опашка, имейлът понякога идва с минута по-късно, а потребителят му трябва да хвърля дубликати. „Това е сложно. Защо просто не викаме имейла директно?“
Никой не помни. Който е решил, вече не е във фирмата. Обсъждането е било в чат, който отдавна е изтрит. Остават два лоши избора: да пазиш нещо, което не разбираш, или да го махнеш и на следващия Black Friday да настъпиш същата грабла, заради която е сложено.
Идеята
ADR: решението и защо
ADR (architecture decision record, запис на архитектурно решение) е кратък документ за едно решение. Пише се, когато решението се взима, и стои в хранилището до кода (docs/adr/0007-notifications-queue.md). Има шест части:
- заглавие и номер: какво е решено, с пореден номер;
- статус: предложено, прието, заменено;
- контекст: какво е вярно сега, кое боли и кое ограничава;
- варианти: поне два, които наистина са обмислени;
- решение: кой вариант и защо;
- последици: какво става по-лесно и какво става по-трудно.
Ето ADR-ът, който колегата е търсел:
# ADR-0007: Известията минават през опашка
## СтатусПрието. Заменя ADR-0004.
## КонтекстADR-0004: плащането вика имейла директно. На Black Friday доставчикътна имейли отговаряше за 8 секунди и плащанията изтичаха по timeout.
## Варианти1. Директно извикване с кратък timeout: плащането минава, имейлът се губи.2. Опашка: плащането пуска събитие, известията го четат по-късно.
## РешениеОпашка. Загубен имейл за платена поръчка е по-лошо от закъснял.
## Последици- Плащането не чака имейла.- Имейлът може да дойде с минута по-късно.- Доставката е „поне веднъж“, затова потребителят е идемпотентен.- Още една част за наблюдение: опашката.Последната половина е най-ценната: тя казва на колегата защо има дубликати и какво ще счупи, ако махне опашката. Опашката и идемпотентният потребител са от урока за събитията.
Идеята
Статусите и смяната на решение
ADR минава през няколко статуса:
- предложено (proposed): написано е, обсъжда се, обикновено в pull request;
- прието (accepted): от днес това е правилото;
- заменено (superseded): по-ново ADR го смени, а старото сочи към него;
- отменено (deprecated): вече не важи, без заместник.
Приетото ADR не се пренаписва. Когато решението вече не е вярно, пишеш ново ADR, което заменя старото, а на старото слагаш статус „заменено от ADR-0007“. Старото е било вярно в своя контекст: ако го изтриеш, губиш защо е било избрано и какво се е случило после.
Виж го
Решение, последици, ново решение
static final Set<String> STATUSES = Set.of("proposed", "accepted", "superseded", "deprecated");
static List<String> check(List<Adr> adrs) { Map<Integer, Adr> all = byNumber(adrs); List<String> bad = new ArrayList<>(); for (Adr a : adrs) { String n = a.name(); if (!STATUSES.contains(a.status())) bad.add(n + ": непознат статус " + a.status()); if (a.context().isBlank()) bad.add(n + ": няма контекст"); if (a.options().size() < 2) bad.add(n + ": по-малко от два варианта"); if (a.decision().isBlank()) bad.add(n + ": няма решение"); if (a.consequences().isBlank()) bad.add(n + ": няма последици"); if (a.status().equals("superseded")) { Adr next = all.get(a.supersededBy()); if (next == null) bad.add(n + ": заменено, но не казва с кое"); else if (!next.status().equals("accepted")) bad.add(n + " → " + next.name() + ": заместникът не е приет"); } } return bad;}Adr adr4 = new Adr(4, "Известията се пращат синхронно", "proposed", "Първата версия: имейлът е бърз", List.of("директно извикване", "опашка"), "Плащането вика имейла директно", "Просто е, но бавният имейл бави плащането", null);Adr adr4 = new Adr(4, "Известията се пращат синхронно", "accepted", "Първата версия: имейлът е бърз", List.of("директно извикване", "опашка"), "Плащането вика имейла директно", "Просто е, но бавният имейл бави плащането", null);Adr adr4 = new Adr(4, "Известията се пращат синхронно", "accepted", "Първата версия: имейлът е бърз", List.of("директно извикване", "опашка"), "Плащането вика имейла директно", "Просто е, но бавният имейл бави плащането", null);
Adr adr7 = new Adr(7, "Известията минават през опашка", "proposed", "ADR-0004: имейлът бави плащанията", List.of("директно с timeout", "опашка"), "Плащането пуска събитие в опашката", "Не чака; имейлът може да дойде два пъти", null);Adr adr4 = new Adr(4, "Известията се пращат синхронно", "superseded", "Първата версия: имейлът е бърз", List.of("директно извикване", "опашка"), "Плащането вика имейла директно", "Просто е, но бавният имейл бави плащането", 7);
Adr adr7 = new Adr(7, "Известията минават през опашка", "accepted", "ADR-0004: имейлът бави плащанията", List.of("директно с timeout", "опашка"), "Плащането пуска събитие в опашката", "Не чака; имейлът може да дойде два пъти", null);static final Set<String> STATUSES = Set.of("proposed", "accepted", "superseded", "deprecated");
static List<String> check(List<Adr> adrs) { Map<Integer, Adr> all = byNumber(adrs); List<String> bad = new ArrayList<>(); for (Adr a : adrs) { String n = a.name(); if (!STATUSES.contains(a.status())) bad.add(n + ": непознат статус " + a.status()); if (a.context().isBlank()) bad.add(n + ": няма контекст"); if (a.options().size() < 2) bad.add(n + ": по-малко от два варианта"); if (a.decision().isBlank()) bad.add(n + ": няма решение"); if (a.consequences().isBlank()) bad.add(n + ": няма последици"); if (a.status().equals("superseded")) { Adr next = all.get(a.supersededBy()); if (next == null) bad.add(n + ": заменено, но не казва с кое"); else if (!next.status().equals("accepted")) bad.add(n + " → " + next.name() + ": заместникът не е приет"); } } return bad;}Стъпка по стъпка, с думи
- ADR-0004 е предложено: контекст, два варианта и решение. Плащането вика имейла директно.
- Екипът го приема. Записана е и цената: бавният имейл бави плащането.
- Black Friday: доставчикът на имейли отговаря за 8 секунди и плащанията изтичат. Записаната последица се сбъдва.
- Ново решение, ADR-0007: известията минават през опашка. Контекстът му сочи към ADR-0004.
- Вариантите и цената: плащането не чака, но имейлът може да дойде по-късно и два пъти.
- ADR-0007 е прието. Старото не се трие и не се пренаписва: статусът му става „заменено“ и сочи към новото.
- Проверката от урока: замененото ADR трябва да сочи към прието. ADR-0004 → ADR-0007 минава.
- Който пита „защо опашка?“, чете ADR-0007, а оттам и ADR-0004. Историята остава, с вариантите и цената.
Виж го
Проверка на ADR-ите
В истински проект ADR-ите са markdown файлове, а проверката чете заглавията им. Тук всяко ADR е запис със същите полета, за да е програмата кратка:
record Adr(int number, String title, String status, String context, List<String> options, String decision, String consequences, Integer supersededBy) {
String name() { return "ADR-%04d".formatted(number); }}Adr adr4 = new Adr(4, "Известията се пращат синхронно", "superseded", "Първата версия: имейлът е бърз", List.of("директно извикване", "опашка"), "Плащането вика имейла директно", "Просто е, но бавният имейл бави плащането", 7);
Adr adr7 = new Adr(7, "Известията минават през опашка", "accepted", "ADR-0004: имейлът бави плащанията", List.of("директно с timeout", "опашка"), "Плащането пуска събитие в опашката", "Не чака; имейлът може да дойде два пъти", null);Проверката пази двете неща, които хората забравят: частите на ADR и веригата „заменено от“.
static Map<Integer, Adr> byNumber(List<Adr> adrs) { Map<Integer, Adr> map = new HashMap<>(); for (Adr a : adrs) map.put(a.number(), a); return map;}static final Set<String> STATUSES = Set.of("proposed", "accepted", "superseded", "deprecated");
static List<String> check(List<Adr> adrs) { Map<Integer, Adr> all = byNumber(adrs); List<String> bad = new ArrayList<>(); for (Adr a : adrs) { String n = a.name(); if (!STATUSES.contains(a.status())) bad.add(n + ": непознат статус " + a.status()); if (a.context().isBlank()) bad.add(n + ": няма контекст"); if (a.options().size() < 2) bad.add(n + ": по-малко от два варианта"); if (a.decision().isBlank()) bad.add(n + ": няма решение"); if (a.consequences().isBlank()) bad.add(n + ": няма последици"); if (a.status().equals("superseded")) { Adr next = all.get(a.supersededBy()); if (next == null) bad.add(n + ": заменено, но не казва с кое"); else if (!next.status().equals("accepted")) bad.add(n + " → " + next.name() + ": заместникът не е приет"); } } return bad;}ADR-0005 е отбелязано като заменено, но не казва с кое. ADR-0009 е с един вариант и без последици:
Adr adr5 = new Adr(5, "Отстъпката се смята в контролера", "superseded", "Един екран, едно правило", List.of("в контролера", "в домейна"), "Правилото е в контролера", "Бързо, но правилото се дублира", null);Adr adr9 = new Adr(9, "Кеш на страниците на продуктите", "proposed", "Базата е на 100% в кампания", List.of("Redis"), "Cache-aside с TTL 5 минути", "", null);List<String> problems = check(List.of(adr4, adr5, adr7, adr9));problems.forEach(System.out::println);System.out.println(problems.size() + " проблема");ADR-0005: заменено, но не казва с коеADR-0009: по-малко от два вариантаADR-0009: няма последици3 проблемаТакава проверка тръгва с тестовете при всеки pull request, и ADR без последици не стига до основния клон.
Пробвай
Предвиди изхода
Проверката от горе получава две ADR-а за отчетите. Какво ще отпечата?
Adr adr11 = new Adr(11, "Отчетите четат основната база", "superseded", "Отчетите са малко", List.of("основната", "реплика"), "Отчетите четат основната", "Просто, но товари базата", 12);Adr adr12 = new Adr(12, "Отчетите четат реплика", "proposed", "Отчетите бавят плащанията", List.of("реплика"), "Отчетите четат реплика", "Данните са с минута по-стари", null);check(List.of(adr11, adr12)).forEach(System.out::println);ADR-0011 е заменено от ADR-0012, а ADR-0012 е още предложено: правилото пада, защото заместникът трябва да е приет. ADR-0012 е и с един вариант.
Виж го
Защо сме с опашка: по веригата
Колегата тръгва от ADR-0004, което вижда в старите обсъждания, и стига до действащото решение:
static void history(Map<Integer, Adr> all, int number) { Adr a = all.get(number); System.out.println(a.name() + " " + a.title() + " [" + a.status() + "]"); while (a.supersededBy() != null) { a = all.get(a.supersededBy()); System.out.println("→ " + a.name() + " " + a.title() + " [" + a.status() + "]"); }}// защо известията минават през опашка?history(byNumber(List.of(adr4, adr7)), 4);ADR-0004 Известията се пращат синхронно [superseded]→ ADR-0007 Известията минават през опашка [accepted]Две минути четене вместо седмица гадаене. Ако контекстът на ADR-0007 вече не е вярен (имейлите отиват в друга система, която отговаря бързо), колегата пише ADR-0015, което го заменя, и веригата расте.
Пробвай
Намери грешката
Колега предлага ADR за кеша от урока за кеша. Кой ред прави ADR-а безполезен за следващия?
Избери реда с грешката.
Последиците са само добри. Решение без цена не е решение, а реклама. Липсва това, което ще потрябва след година: цената може да е стара до 5 минути; всеки, който пише цени, трябва да трие копията; Redis е още една система за наблюдение; ако Redis падне, цялото натоварване се връща на базата.
Идеята
Кога ADR помага и кога е бюрокрация
- Помага за решения, които струват скъпо, ако се сменят (опашка, база, граници на модулите, формат на API), и които имат повече от един разумен отговор.
- Помага, когато се пише преди решението: вариантите и цената излизат наяве, докато още може да се избере друго.
- Вреди, когато всяка дреболия иска ADR. Името на клас или библиотека, която сменяш за час, не е архитектурно решение.
- Вреди, когато се пише след това, за да оправдае вече направеното: тогава вариантите са измислени, а последиците са само добри.
- Безполезно е, когато никой не го намира. Сложи номера на ADR-а в коментара на мястото в кода и в pull request-а.
По-точно, за любопитните
Откъде идва. Форматът е на Майкъл Найгард (Michael Nygard, 2011). Популярни шаблони са неговият и MADR (Markdown Any Decision Records), който добавя „плюсове и минуси на всеки вариант“.
Инструменти. adr-tools прави нов файл с поредния номер и сменя статусите; log4brains прави сайт от ADR-ите. Проверка като тази в урока се пише за един следобед и се пуска с тестовете.
ADR, RFC и design doc. Design doc описва цяла система или голяма промяна, преди да се строи. RFC е предложение за обсъждане от много хора. ADR е едно решение, кратко, и остава като история. Често design doc-ът завършва с няколко ADR-а.
Кой ги пише. Не само архитектът. Който предлага промяната, пише ADR-а в pull request, а прегледът на кода става и преглед на решението.
Идеята
Кога коя форма
Модулът показа девет начина да подредиш система. Никой не е добър сам по себе си: всеки решава една болка и носи цена.
| Форма | Помага, когато | Вреди, когато |
|---|---|---|
| Слоеве | правилата трябва да се тестват без база и без екран | приложението е само форми към таблици и слоевете само препращат |
| Hexagonal | домейнът живее дълго, а плащането и базата се сменят | всеки клас има интерфейс без втора реализация |
| Модулен монолит | един или няколко екипа, един deploy | границите не се пазят и модулите бъркат в чужди таблици |
| Услуги | независими екипи, различен мащаб, отделен deploy | един екип: мрежата, данните и наблюдението струват повече от ползата |
| Събития и опашки | бавна последица не бива да бави главното действие | отговорът трябва да е веднага; дубликатите и редът се пазят на ръка |
| Outbox | събитие не бива да се губи при срив | няма опашка: извикване в същия процес стига |
| Договор на API | клиенти, които не можеш да обновиш (мобилното приложение) | код в един процес, който се мени заедно |
| Кеш | едно и също се чете много и може да е малко старо | стойността трябва да е точна или не може да се инвалидира |
| Мащабиране | един сървър не стига или не бива да е единствен | един сървър стига, а тясното място е базата |
Монолит първо. Нова система започва като модулен монолит с ясни граници. Услуга се отделя, когато има конкретна причина: отделен екип, различен мащаб, независим deploy. Причината се записва в ADR, заедно с цената. Обратното (услуги от първия ден) плаща цената на разпределената система, преди да е ясно къде са истинските граници.
Упражнение
Съдържанието на папката
Реши задачата в редактора си.
Папката docs/adr/ расте и никой не помни кое действа. Напиши toc: печата всички ADR-и, подредени по номер, със статуса им, а при замененото и номера на заместника. Дай му ADR-0007, ADR-0001, ADR-0009 и ADR-0004 в този ред.
ADR-0001 Модулен монолит вместо услуги [accepted]ADR-0004 Известията се пращат синхронно [superseded → ADR-0007]ADR-0007 Известията минават през опашка [accepted]ADR-0009 Кеш на страниците на продуктите [proposed]Още задачи
- Действащите. Отпечатай само приетите ADR-и: това е „архитектурата днес“.
- Цикъл. Какво става с
history, ако ADR-0004 е заменено от 0007, а 0007 от 0004? Добави проверка, която намира такъв цикъл.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Transactional outbox · Дизайн на API: договор, версии, повторени заявки · Кеш · Мащабиране
- Продължава в: Нефункционални изисквания, които се мерят · Компромиси · Смяна на жива система (strangler fig, expand–contract) · Архитектурен преглед
- Задачи по темата: Софтуерни архитектури