Към съдържанието
wannacode

Твоята първа архитектурна задача

Накратко: Проект по софтуерна архитектура: изисквания с числа, три варианта с цената им, матрица на решението, ADR, план за миграция и регистър на рисковете за магазин, който расте.

Проблем

Задачата

Собственикът на магазина пише:

От пролетта продаваме и в Гърция и Хърватия. Пускаме и външни търговци: качват свои стоки, ние взимаме комисионна. Black Friday да не падне като миналата година. Колко ще струва и кога?

Това е истинската първа задача на архитекта. Никой не ти казва „направи модулен монолит“. Казват ти желание, а ти трябва да върнеш:

  1. изисквания с числа, които могат да се мерят;
  2. поне два варианта с цената им;
  3. решение, записано в ADR;
  4. план за миграция на живата система, обратим на всяка стъпка;
  5. регистър на рисковете;
  6. проверка, която пази новите граници.

Трите държави са в еврозоната, затова парите остават в евро. Всичко друго се различава: език, ДДС, куриери, закони за връщане.

Идеята

Планът

Всяка стъпка ползва урок от модула:

  1. Изискванията с персентили и бюджет на наличността, както в „Нефункционални изисквания“.
  2. Вариантите с компромисите от „Компромиси“ и формите от „Монолит, модулен монолит, услуги“.
  3. ADR като в „ADR: записани архитектурни решения“.
  4. Миграцията със strangler fig и expand–contract.
  5. Рисковете като в „Архитектурен преглед“.
  6. Fitness function за границите, пак от прегледа.

Всяка стъпка има изход, с който да сравниш своя. Решенията са в „Решение“ под всяка стъпка, а накрая е примерното решение на цялата задача. Опитай първо сам: архитектурата се учи, като решаваш, а не като четеш решения.

Упражнение

Стъпка 1: изискванията с числа

„Да е бързо и да не пада“ не е изискване. Превърни всяко от тези желания в сценарий на качеството: източник, стимул, отговор и мярка с число.

  • „Black Friday да не падне.“
  • „Плащането винаги да работи.“
  • „Търговците да виждат продажбите си веднага.“
  • „Нова държава да се пуска лесно.“
Примерни сценарии
Качество Източник и стимул Отговор Мярка
мащаб Black Friday, 5 пъти обичайния трафик страницата на плащането отговаря p95 до 800 ms, p99 до 2 s
наличност купувач във всяка от трите държави плаща плащането минава 99,7% от минутите в месеца, до 130 мин. престой
съгласуваност търговец сменя наличност купувачите я виждат до 2 мин.; продадено над наличното се отменя с извинение до 1 час
изолация търговец качва 10 000 продукта наведнъж магазинът работи както преди p95 на каталога расте с не повече от 20%
разширяемост четвърта държава пуска се с настройки до 2 седмици, без промяна в кода на поръчките

Числата тук са примерни: в истинската задача идват от собственика („колко струва час престой на Black Friday?“) и от мерене на днешната система.

„Търговците да виждат продажбите си веднага“ стана „до 2 минути“. Това е съзнателен избор на евентуална съгласуваност: „веднага“ би вкарало системите на търговците в пътя на плащането.

Упражнение

Стъпка 1б: бюджетът на веригата

Плащането минава през API-то на магазина, базата и платежния доставчик. Първата идея за търговците е при всяко плащане да питаш системата на търговеца дали стоката я има. Наличностите и p95 по-долу са примерни (на доставчиците от договора, на търговците от проба):

chain = [
Hop("API на магазина", 99.95, 120),
Hop("база", 99.95, 40),
Hop("плащане", 99.9, 450),
Hop("наличност при търговците", 99.0, 400),
]
budget("Наличността при търговците е в пътя на плащането:", chain, 99.7, 800)
budget("Без нея (копие, обновявано от събития):", chain[:3], 99.7, 800)

Напиши budget, който умножава наличността по веригата, събира p95 и сравнява с бюджета: 99.7% (престоят в минути на месец от 30 дни) и p95 до 800 ms. Пусни го с търговците във веригата и без тях:

Изход
Наличността при търговците е в пътя на плащането:
наличност 98.80% → 517 мин. престой на месец (бюджет 130) ✗
p95 1010 ms (бюджет 800) ✗
Без нея (копие, обновявано от събития):
наличност 99.80% → 86 мин. престой на месец (бюджет 130) ✓
p95 610 ms (бюджет 800) ✓
Решение
@dataclass
class Hop:
name: str
availability: float # %
p95: int # ms
MINUTES_PER_MONTH = 30 * 24 * 60
def budget(title, chain, target, p95_budget):
availability = 1.0
p95 = 0
for hop in chain:
availability *= hop.availability / 100
p95 += hop.p95 # сборът на p95 е горна граница, не точно p95 на веригата
down = round((1 - availability) * MINUTES_PER_MONTH)
allowed = round((1 - target / 100) * MINUTES_PER_MONTH)
print(title)
mark = "✓" if down <= allowed else "✗"
print(f" наличност {availability * 100:.2f}% → {down} мин. престой на месец (бюджет {allowed}) {mark}")
print(f" p95 {p95} ms (бюджет {p95_budget}) {'✓' if p95 <= p95_budget else '✗'}")

Една система с 99,0% сваля плащането до 98.80%: 517 минути престой на месец срещу бюджет 130. Закъснението също не се събира. Затова наличността при търговците не може да е синхронна стъпка на плащането. Тя става копие при нас, обновявано от събития (урокът „Събития и опашки“), а цената е, че копието може да закъснее. Това решение идва от числата, не от вкуса.

Сборът на p95 е горна граница: p95 на веригата обикновено е по-малък, защото рядко всички стъпки са бавни наведнъж. За бюджета горната граница е безопасната страна.

Упражнение

Стъпка 2: варианти с цената им

Опиши поне два варианта, които наистина биха работили, и цената на всеки: пари, време, хора и какво губиш. Три естествени варианта:

A: три копия на магазина B: модулен монолит C: услуги от първия ден
идея по един магазин на държава държавата е настройка, търговците са модул каталог, поръчки, търговци, плащане поотделно
време до пускане най-бързо средно най-бавно
цена на месец три пъти инфраструктура и поддръжка една система много услуги, наблюдение, мрежа
какво губиш копията се разминават и не се сливат обратно промяна за една държава се пуска за трите екипът учи разпределени системи, докато пуска

Сравнението се прави в матрица на решението: критерии с тегла (колко важен е всеки) и оценка от 1 до 5 за всеки вариант. Теглата и оценките са на екипа и собственика:

# време до пускане 3, наличност 3, цена на месец 2, екипът го може 2, обратимост 2
weights = [3, 3, 2, 2, 2]
options = {
"A: три копия на магазина": [5, 3, 2, 4, 2],
"B: модулен монолит, държавата е настройка": [3, 4, 4, 4, 4],
"C: услуги от първия ден": [1, 4, 2, 2, 2],
}
for name, scores in options.items():
print(f"{name}: {total(scores, weights)}")
print("Избор:", best(options, weights))

Напиши total (сбор от тегло × оценка) и best:

Изход
A: три копия на магазина: 40
B: модулен монолит, държавата е настройка: 45
C: услуги от първия ден: 27
Избор: B: модулен монолит, държавата е настройка
Решение
def total(scores, weights):
return sum(w * s for w, s in zip(weights, scores))
def best(options, weights):
return max(options, key=lambda name: total(options[name], weights))

Матрицата не взима решението вместо теб. Тя прави видими теглата: ако собственикът каже „времето е всичко“, това е число, с което може да се спори.

Упражнение

Стъпка 2б: колко крехко е решението

Оценките са преценка. Провери чувствителността: вдигай теглото на времето до пускане от 3 нагоре и намери първото тегло, при което печели друг вариант:

Изход
При тегло 6 на времето печели A: три копия на магазина (55 срещу 54)

Изборът издържа, докато времето не стане два пъти по-важно от наличността. Това казваш на собственика: „ако пускането до пролетта е по-важно от всичко друго, копията печелят, но ето цената им“.

Упражнение

Стъпка 3: ADR

Запиши решението в ADR. Проверката от урока за ADR гледа дали разделите ги има. Тук я правиш по-строга, защото ADR без цена е реклама:

  • поне два варианта в „Варианти“;
  • поне една последица, която започва с „- лошо:“;
  • в „Кога преразглеждаме“ има число, което може да се мери.

Ето черновата, която колега ти праща:

adr-014-draft.md
# ADR-014: Три държави и търговци в модулния монолит
## Статус
предложено
## Контекст
Магазинът тръгва в Гърция и Хърватия и пуска външни търговци. Плащането: 99.7% на месец, p95 до 800 ms.
## Варианти
- B: модулен монолит, държавата е настройка, търговците са модул
## Решение
B. Наличността при търговците идва асинхронно и не е в пътя на плащането.
## Последици
- добро: един код за трите държави

Напиши check_adr и я пусни върху черновата и върху своя окончателен ADR:

Изход
Чернова: 3
✗ липсва раздел „Кога преразглеждаме“
✗ Варианти: 1, нужни са поне 2
✗ Последици: няма нито едно „лошо:“
Окончателен: ✓ ОК
Решение
REQUIRED = ["Статус", "Контекст", "Варианти", "Решение", "Последици", "Кога преразглеждаме"]
def sections(adr):
result, current = {}, None
for line in adr.strip().split("\n"):
if line.startswith("## "):
current = result[line[3:]] = []
elif current is not None and line.strip():
current.append(line)
return result
def check_adr(adr):
s = sections(adr)
problems = [f"✗ липсва раздел „{name}“" for name in REQUIRED if name not in s]
count = sum(1 for line in s.get("Варианти", []) if line.startswith("- "))
if count < 2:
problems.append(f"✗ Варианти: {count}, нужни са поне 2")
if not any(line.startswith("- лошо:") for line in s.get("Последици", [])):
problems.append("✗ Последици: няма нито едно „лошо:“")
if "Кога преразглеждаме" in s and not any(ch.isdigit() for ch in " ".join(s["Кога преразглеждаме"])):
problems.append("✗ Кога преразглеждаме: няма число, което да се мери")
return problems
for name, text in [("Чернова", DRAFT), ("Окончателен", FINAL)]:
problems = check_adr(text)
print(f"{name}: {len(problems) if problems else '✓ ОК'}")
for p in problems:
print(" " + p)

Окончателният ADR е в примерното решение най-долу.

Упражнение

Стъпка 4: планът на миграцията

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

  1. 1% от гръцкия трафик минава по новия път (флаг)
  2. новият код пише country, старият още работи
  3. добави колоната country (празна, старият код не я чете)
  4. 10%, 50%, 100% само при зелени метрики, назад при червени
  5. backfill: старите поръчки получават BG, после сравни броя
  6. изключи стария път и махни стария код

Дяловете (1%, 10%, 50%, 100%) не се вдигат по календар, а по метрики. Напиши rollout: за всеки дял чете следващите записани метрики. При зелени (до 5 грешки на 1000 и p95 до 800 ms) минава напред, при червени връща трафика на предишния дял и опитва пак след поправката. Метриките са записан сценарий, за да е изходът един и същ:

observed = [
Metrics(2, 610),
Metrics(9, 700),
Metrics(3, 640),
Metrics(4, 690),
Metrics(3, 700),
]
rollout([1, 10, 50, 100], iter(observed))
Изход
1%: грешки 2/1000, p95 610 ms ✓
10%: грешки 9/1000, p95 700 ms ✗ връщане към 1%
10%: грешки 3/1000, p95 640 ms ✓
50%: грешки 4/1000, p95 690 ms ✓
100%: грешки 3/1000, p95 700 ms ✓
Старият път е изключен: точка без връщане.
Решение
@dataclass
class Metrics:
errors_per_1000: int
p95: int # ms
def healthy(self):
return self.errors_per_1000 <= 5 and self.p95 <= 800
def rollout(shares, observed):
step = 0
while step < len(shares):
m = next(observed)
line = f"{shares[step]}%: грешки {m.errors_per_1000}/1000, p95 {m.p95} ms"
if m.healthy():
print(line + " ✓")
step += 1
else:
back = 0 if step == 0 else shares[step - 1]
print(f"{line} ✗ връщане към {back}%")
print("Старият път е изключен: точка без връщане.")

Точката без връщане е само една и е последна: махането на стария път. Всичко преди нея се връща с флаг. Колоната се добавя празна (expand), а се маха чак когато никой не я чете (contract).

Пробвай

Намери грешката

Колега пише проверката на метриките. Пускането минава гладко, но 10% тръгва с 9 грешки на 1000:

Изход
1%: грешки 2/1000, p95 610 ms ✓
10%: грешки 9/1000, p95 700 ms ✓
50%: грешки 3/1000, p95 640 ms ✓
100%: грешки 4/1000, p95 690 ms ✓
Старият път е изключен: точка без връщане.

Избери реда с грешката.

Упражнение

Стъпка 5: регистърът на рисковете

Ето рисковете, които екипът събра. Напиши register, който ги подрежда (по резултат, при равенство по последствие) и показва мярката. Висок риск (12 и повече) без мярка е отворена дупка и се показва с „✗“, а по-малък без мярка е съзнателно приет.

risks = [
Risk("ДДС и фактурите по държави се объркват", 3, 5, "тест с фактура за всяка държава преди 1%"),
Risk("куриерът в Хърватия няма API за проследяване", 2, 4, ""),
Risk("трафикът на Black Friday в Гърция е 5 пъти по-голям", 3, 4, ""),
Risk("копието на наличността закъснява и продаваме изчерпана стока", 4, 3, "резервация при плащане и отмяна с извинение"),
Risk("екипът не познава модула за търговци", 3, 2, "работа по двойки първите два месеца"),
]
register(risks)
Изход
15 ДДС и фактурите по държави се объркват → тест с фактура за всяка държава преди 1%
12 трафикът на Black Friday в Гърция е 5 пъти по-голям → ✗ няма мярка
12 копието на наличността закъснява и продаваме изчерпана стока → резервация при плащане и отмяна с извинение
8 куриерът в Хърватия няма API за проследяване → (приемаме)
6 екипът не познава модула за търговци → работа по двойки първите два месеца
Решение
@dataclass
class Risk:
name: str
probability: int
impact: int
measure: str = ""
@property
def score(self):
return self.probability * self.impact
def register(risks):
for r in sorted(risks, key=lambda r: (r.score, r.impact), reverse=True):
measure = r.measure or ("✗ няма мярка" if r.score >= 12 else "(приемаме)")
print(f"{r.score:2} {r.name} → {measure}")

За Black Friday мярката е товарен тест с 5 пъти трафика преди пускането в Гърция и план кое се изключва първо (препоръките, търсенето по търговци), за да оцелее плащането. Мащабирането е от „Мащабиране“, а изключването е като прекъсвача от „Когато съседът е бавен“.

Упражнение

Стъпка 6: fitness function за новите граници

Модулът на търговците е нов. Ако още първия месец започне да чете вътрешностите на поръчките, след година няма да може да се отдели. Правилото: всеки модул има api (публичното) и internal; чужд модул се вика само през api. Import-ите на модулите:

imports.txt
sellers.internal -> orders.api
sellers.internal -> orders.internal
orders.internal -> payments.api
orders.internal -> sellers.internal
catalog.internal -> sellers.api
orders.internal -> orders.db
Изход
✗ sellers.internal -> orders.internal: вика вътрешностите на orders
✗ orders.internal -> sellers.internal: вика вътрешностите на sellers
Нарушения: 2
if from_module != to_module and to_part != "":
Решение
# чужд модул се вика само през api
def boundary_violations(imports):
found = []
for line in imports:
source, target = line.split(" -> ")
from_module, _ = source.split(".")
to_module, to_part = target.split(".")
if from_module != to_module and to_part != "api":
found.append(f"{line}: вика вътрешностите на {to_module}")
return found
imports = [
"sellers.internal -> orders.api",
"sellers.internal -> orders.internal",
"orders.internal -> payments.api",
"orders.internal -> sellers.internal",
"catalog.internal -> sellers.api",
"orders.internal -> orders.db",
]
found = boundary_violations(imports)
for v in found:
print("✗ " + v)
print("Нарушения:", len(found))

Пусни я при всеки комит с baseline, както в прегледа. Днешните две нарушения се оправят, преди модулът на търговците да излезе.

Виж го

Примерно решение

Сравни своето решение с това. То не е единственото вярно: вярно е всяко решение, чиито числа, варианти и рискове са честни и записани.

Изискванията. Петте сценария от стъпка 1. Ключовото: плащането е 99,7% и p95 до 800 ms, а наличността при търговците е евентуално съгласувана, до 2 минути.

Вариантите. A, B и C с цената им; изборът е B с 45 точки срещу 40 и 27. Решението издържа до тегло 6 на времето.

Архитектурата на B.

  • Държавата е настройка: език, ДДС, куриер и правила за връщане идват от таблица по държава, не от if в кода.
  • Търговците са модул в монолита, със свои данни и публичен api („Монолит, модулен монолит, услуги“).
  • Наличността при търговците идва като събития в копие при нас (outbox от тяхна страна или опашка от нашата).
  • Продадено над наличното се отменя със saga: резервация, плащане, а при отказ от търговеца връщане на парите.
  • Склад 2009 и счетоводството остават зад anti-corruption layer, както досега.

ADR-ът.

adr-014.md
# ADR-014: Три държави и търговци в модулния монолит
## Статус
прието
## Контекст
Магазинът тръгва в Гърция и Хърватия и пуска външни търговци. Плащането: 99.7% на месец, p95 до 800 ms.
## Варианти
- A: три копия на магазина, по едно на държава
- B: модулен монолит, държавата е настройка, търговците са модул
- C: услуги от първия ден
## Решение
B. Наличността при търговците идва асинхронно и не е в пътя на плащането.
## Последици
- добро: един код за трите държави
- лошо: промяна за една държава се пуска и за трите
- лошо: копието на наличността може да закъснее с минути
## Кога преразглеждаме
Ако търговците станат над 500 или екипите станат повече от 3.

Планът. Колоната country (expand), новият код пише в нея, backfill на старите поръчки като България, после 1%, 10%, 50% и 100% от гръцкия трафик при зелени метрики, накрая махането на стария път (contract). Хърватия тръгва след Гърция по същия план.

Рисковете. Регистърът от стъпка 5, с мярка за Black Friday. Отговаря собственикът на всеки риск, а регистърът се гледа на всеки две седмици до пускането.

Проверките. Границите от стъпка 6 и бюджетът от стъпка 1 като fitness function: товарен тест преди всяко пускане, който пада, ако p95 на плащането е над 800 ms. Как се пускат в pipeline-а е тема на ZeroToOps, курса по DevOps.

Какво не решихме. Отделна услуга за търговците. ADR-ът казва кога да се върнем към този въпрос: над 500 търговци или повече от 3 екипа.

Тест

Провери се

1. Защо наличността при търговците излиза от пътя на плащането?
2. Матрицата избира B. Какво проверява анализът на чувствителността?
3. Кое прави ADR полезен след две години?
4. Пускането на 10% показва 9 грешки на 1000 при граница 5. Какво прави планът?
5. Кога настъпва точката без връщане в плана?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·