Iteracyjna architektura z AI — komponenty, rundy jakości i cykle refaktoryzacji
ai,symfony,php,statyczna-strona,architekturaCzęść 11 z 11
- 19 lat z WordPressem — dlaczego w końcu zrezygnowałem
- Symfony jako generator stron statycznych — jak działa holas.pl
- Zabezpieczenie jedynego dynamicznego endpointu strony statycznej — formularz kontaktowy
- Drzewo decyzyjne narzędzi dla Claude Code z globalną pamięcią
- Środowisko deweloperskie — od lokalnego devu do produkcji w dwóch kontenerach
- Budowanie holas.pl z AI — Claude Code, MCP i lokalne generowanie obrazów
- 4×100 w Lighthouse Mobile — co daje statyczna strona
- Inżynieria SEO na statycznej stronie — dane strukturalne, karty społecznościowe i sygnały dla crawlerów
- Responsywne obrazy i zaplanowane wpisy na statycznej stronie — rozwiązania w kroku budowania
- Wielojęzyczność na stronie statycznej — konfiguracja zamiast kodu
- Iteracyjna architektura z AI — komponenty, rundy jakości i cykle refaktoryzacji
To jest część 10 serii o migracji holas.pl z WordPressa na niestandardowy generator stron statycznych oparty na Symfony. Część 9 opisuje system wielojęzyczności.
Część 5 opisywała jak Claude Code był używany podczas początkowego budowania — AGENTS.md jako instrukcja obsługi dla AI, generowanie serwisów i szablonów z konwencji, tłumaczenie treści. Ten wpis opisuje co dzieje się później: strona działa, build przechodzi, ale architektura ma niedociągnięcia. Trzy rundy poprawek jakości, ekstrakcja komponentów i reorganizacja namespace'ów — wszystko napędzane cyklami przeglądów z pomocą AI.
Dlaczego iterować?
Pierwsza działająca wersja priorytetyzuje wysyłkę. Opublikuj treść, niech build przejdzie, wdróż na Raspberry Pi. Dług techniczny narasta naturalnie po drodze: metody zwracają tablice asocjacyjne zamiast value objects, serwisy wstrzykiwane są jako klasy konkretne zamiast interfejsów, szablony rosną w monolity.
W zespole code review wyłapuje te wzorce. W solowym projekcie nie ma nikogo do przeglądania pull requestów. AI wypełnia tę lukę — nie jako pieczątka akceptacji, ale jako systematyczny reviewer czytający każdy plik i raportujący problemy z numerami linii.
Cykl iteracji:
- Poproś AI o audyt pod kątem konkretnych wzorców (strict types, naruszenia SOLID, problemy DRY)
- AI czyta każdy plik PHP, raportuje problemy ze ścieżkami plików i numerami linii
- Zaplanuj poprawki w pliku
.plans/z checkboxami - Implementuj fazami, weryfikuj
ddev code-checkpo każdej - Powtórz z kolejnym fokusem jakościowym
Każda runda ma konkretny zakres. Próba naprawienia wszystkiego naraz prowadzi do szumnych diffów i przeoczonych regresji. Skupione rundy produkują dające się przejrzeć i zweryfikować zmiany.
Runda 1 — Value Objects zamiast tablic
Pierwsza runda celowała w własną zasadę projektu: "Nigdy nie zwracaj tablic asocjacyjnych dla złożonych danych."
Problem
MarkdownParser::parse() zwracał tablicę:
// Przed
public function parse(string $markdown): array
{
// ...
return [
'frontMatter' => $frontMatter,
'html' => $html,
];
}
// Konsument
$result = $this->parser->parse($content);
$frontMatter = $result['frontMatter']; // brak bezpieczeństwa typów
$html = $result['html']; // literówka = cichy bug
Ten sam wzorzec dla nawigacji sąsiednich wpisów — ContentTree::getAdjacentPosts() zwracał ['prev' => $post, 'next' => $post].
Problemy: brak bezpieczeństwa typów, brak autouzupełniania w IDE, PHPStan nie wyłapie literówki w $result['htlm'].
Poprawka
final readonly class ParsedMarkdown
{
/** @param array<string, mixed> $frontMatter */
public function __construct(
public array $frontMatter,
public string $html,
) {
}
}
final readonly class AdjacentPosts
{
public function __construct(
public ?ContentItem $prev,
public ?ContentItem $next,
) {
}
}
Teraz parser zwraca ParsedMarkdown, konsument odwołuje się do $parsed->frontMatter i $parsed->html, a PHPStan wyłapuje każdą literówkę w nazwie właściwości na etapie analizy.
Siedem value objects zostało utworzonych lub przeniesionych w tej rundzie:
| Value Object | Zastępuje | Właściwości |
|---|---|---|
ParsedMarkdown |
tablicę ['frontMatter', 'html'] |
frontMatter, html |
AdjacentPosts |
tablicę ['prev', 'next'] |
prev, next |
ArchiveMonth |
tablicę inline | year, month, count |
CategoryCount |
tablicę inline | slug, count |
TagCount |
tablicę inline | slug, count |
SidebarData |
wiele wartości zwrotnych | recentPosts, categories, tags, archiveMonths |
RenderResult |
ad-hoc statystyki | pages, skipped, errors |
Wszystkie są readonly, używają constructor property promotion i żyją w src/Content/ValueObject/.
Runda 2 — interfejsy dla wszystkiego
Druga runda wymusiła kolejną zasadę projektu: "Każda wstrzykiwana klasa w src/Service/ musi mieć odpowiadający interfejs."
Problem
Dwa serwisy wstrzykiwane były jako klasy konkretne:
// Przed
public function __construct(
private readonly ContentTreeBuilder $builder,
private readonly MarkdownParser $parser,
) {
}
Działało, ale naruszało zasadę odwrócenia zależności. Reszta bazy kodu już wstrzykiwała przez interfejsy (ContentServiceInterface, ImageResizerInterface). Te dwa były wyjątkami.
Poprawka
interface MarkdownParserInterface
{
public function parse(string $markdown): ParsedMarkdown;
}
interface ContentTreeBuilderInterface
{
public function build(string $locale): ContentTree;
}
// Po
public function __construct(
private readonly ContentTreeBuilderInterface $builder,
private readonly MarkdownParserInterface $parser,
) {
}
Klasy konkretne implementują interfejsy. Autowiring Symfony obsługuje powiązanie. Zasada jest teraz wymuszona wszędzie: 12 interfejsów serwisów w 4 podkatalogach.
src/Service/
├── Content/
│ ├── ContentServiceInterface + ContentService
│ ├── ContentTreeBuilderInterface + ContentTreeBuilder
│ ├── MarkdownParserInterface + MarkdownParser
│ ├── SidebarDataProviderInterface + SidebarDataProvider
│ └── TranslationMapBuilderInterface + TranslationMapBuilder
├── Image/
│ ├── ImageResizerInterface + ImageResizer
│ └── ResponsiveImageServiceInterface + ResponsiveImageService
├── Preview/
│ ├── DraftPreviewServiceInterface + DraftPreviewService
│ └── ScheduledPreviewServiceInterface + ScheduledPreviewService
├── SiteConfigServiceInterface + SiteConfigService
└── TurnstileValidatorInterface + TurnstileValidator
Dodatkowa zasada z tej rundy: stałe należą do interfejsu, nie do klasy konkretnej. Klasa konkretna dziedziczy je przez self::CONSTANT_NAME.
Runda 3 — eliminacja zakodowanych wartości
Trzecia runda celowała w subtelniejszy problem: wartości, które działają dziś, ale rozjadą się jutro.
Zakodowane URL-e w szablonach
Przełącznik języka miał zakodowane ścieżki archiwum:
{# Przed — psuje się gdy routing się zmieni #}
{% set url = locale is same as('pl') ? '/archive/' : '/pl/archiwum/' %}
{# Po — używa nazwanych tras #}
{% set url = path('blog_archive_' ~ other, {year: archive_year, month: '%02d'|format(archive_month)}) %}
Ten sam wzorzec w BlogController dla przełączania tagów między locale — zakodowane /pl/tag/ i /blog/ zastąpione przez $this->generateUrl('blog_tag_'.$otherLocale, ...).
Zakodowane progi obrazów
Szablon responsywnych obrazów miał szerokości srcset zakodowane jako stringi. Gdyby zmienna środowiskowa IMAGE_VARIANT_WIDTHS się zmieniła, szablon odwoływałby się do plików, które nie istnieją:
{# Przed — szablon musi ręcznie pasować do konfiguracji env #}
srcset="...640w.webp 640w, ...960w.webp 960w, ..."
Poprawka: wstrzyknij szerokości wariantów jako Twig global z SiteConfigExtension, potem generuj srcset dynamicznie. Jedno źródło prawdy dla progów.
Magiczne liczby
Response::HTTP_BAD_REQUEST zastąpiło zakodowane 400. Mała zmiana, ale spójna z zasadą: każda literalna wartość to przyszły bug, gdzie ktoś zmieni logikę ale nie liczbę.
Ekstrakcja komponentów Twig
Między rundami jakości osobne wysiłki wyodrębniły reużywalne komponenty z monolitycznych szablonów.
Strona "O mnie"
Strona "o mnie" była najgorszym przypadkiem — 146 linii mieszających markup profilu, karty ekspertyzy, pigułki umiejętności, projekty open source i bloki cytatów rekomendacji w jednym szablonie.
Po ekstrakcji:
{# about.html.twig — czysty i czytelny #}
{% block body %}
<article class="page-content" data-pagefind-body>
{{ include('components/about_profile.html.twig', {
author: site_author,
role: 'about.role'|trans,
headline: 'about.headline'|trans
}) }}
<section class="about-section">
<h2>{{ 'about.expertise_title'|trans }}</h2>
{{ include('components/expertise_grid.html.twig', { expertise: expertise_items }) }}
</section>
{{ include('components/skills_pills.html.twig', { skills: ..., labels: ... }) }}
{{ include('components/opensource_grid.html.twig', { projects: os_projects }) }}
{% for rec in site_author.recommendations %}
{{ include('components/recommendation_card.html.twig', { rec: rec, ... }) }}
{% endfor %}
{{ include('components/about_cta.html.twig', { text: ..., url: ..., label: ... }) }}
</article>
{% endblock %}
Osiem komponentów wyodrębnionych z jednej strony: about_profile, expertise_grid, skills_pills, opensource_grid, recommendation_card, about_cta, plus error_terminal i coming_soon_terminal z innych stron.
Korzyść ze styleguide'a
holas.pl ma dostępny tylko w dev styleguide pod /styleguide/, który pokazuje wszystkie komponenty UI. Przed ekstrakcją styleguide miał własny zakodowany markup dla każdego komponentu — który rozjeżdżał się z prawdziwymi szablonami przy zmianach.
Po ekstrakcji zarówno prawdziwa strona, jak i styleguide includują te same pliki komponentów:
{# styleguide.html.twig #}
{{ include('components/recommendation_card.html.twig', { rec: demo_recommendation, ... }) }}
{# about.html.twig #}
{{ include('components/recommendation_card.html.twig', { rec: rec, ... }) }}
Zmień komponent raz, oba się aktualizują automatycznie. Styleguide z ręcznego obciążenia synchronizacyjnego stał się bezobsługowy.
Liczba komponentów wzrosła z ~15 do 25 w ramach tych ekstrakcji.
Reorganizacja namespace'ów
Praca nad value objects i interfejsami stworzyła wystarczająco dużo plików, że płaskie namespace'y stały się zatłoczone:
# Przed
src/Content/AdjacentPosts.php
src/Content/ArchiveMonth.php
src/Content/CategoryCount.php
src/Content/CardLayout.php
src/Service/ContentService.php
src/Service/ImageResizer.php
src/Service/DraftPreviewService.php
# Po
src/Content/ValueObject/AdjacentPosts.php
src/Content/ValueObject/ArchiveMonth.php
src/Content/ValueObject/CategoryCount.php
src/Content/Enum/CardLayout.php
src/Service/Content/ContentService.php
src/Service/Image/ImageResizer.php
src/Service/Preview/DraftPreviewService.php
CardLayout przeniesiony do Enum/ — to PHP enum z backing type dla wariantów layoutu kart wpisów:
enum CardLayout: string
{
case Top = 'layout-top';
case Right = 'layout-right';
case Text = 'layout-text';
case Left = 'layout-left';
/** @return list<string> */
public static function cycle(): array
{
return array_map(fn (self $l) => $l->value, self::cases());
}
}
Bezpieczny typowo, z autouzupełnianiem, niemożliwy do literówki. Metoda cycle() zwraca wartości layoutów, przez które szablon listingu wpisów rotuje dla wizualnej różnorodności.
Reorganizacja dotknęła 46 plików w jednym commicie — każda instrukcja use odwołująca się do przeniesionej klasy wymagała aktualizacji. To jest dokładnie ten rodzaj mechanicznej refaktoryzacji, w którym AI błyszczy: zmień namespace, zaktualizuj wszystkie importy, zweryfikuj że nic się nie popsuło. Człowiek decyduje o docelowej strukturze; AI obsługuje żmudną część.
Workflow AI dla refaktoryzacji
Praktyczny workflow stojący za tymi rundami:
Krok 1: Zawężony audyt. Poproś Claude Code o przegląd wszystkich plików PHP pod kątem konkretnej kategorii problemów. Nie "znajdź wszystkie problemy" — zbyt ogólne. Zamiast tego: "sprawdź każdy plik w src/ pod kątem metod zwracających tablice asocjacyjne zamiast value objects." AI czyta każdy plik i raportuje konkretne problemy ze ścieżkami plików i numerami linii.
Krok 2: Plan. Napisz plik .plans/ dokumentujący co trzeba zmienić, które pliki są dotknięte i kroki implementacji jako checkboxy. Plan jest źródłem prawdy — nie podsumowaniem intencji, ale szczegółową specyfikacją implementacji.
Krok 3: Implementacja fazami. Wykonaj jedną fazę, uruchom ddev code-check (PHP CS Fixer + PHPStan level 6), zweryfikuj że build przechodzi z ddev build. Przejdź do następnej fazy.
Krok 4: Weryfikacja. Po zakończeniu rundy uruchom pełny zestaw kontroli jakości. Liczby dla holas.pl:
$ ddev code-check
PHP CS Fixer: Found 0 of 53 files that can be fixed
PHPStan: [OK] No errors (53 files, level 6)
53 pliki PHP, zero problemów CS Fixer, zero błędów PHPStan. Automatyczne narzędzia potwierdzają to, co przegląd zamierzał.
W czym AI jest dobry przy refaktoryzacji
- Systematyczny przegląd plik po pliku: czyta 53 pliki i raportuje każde naruszenie wzorca. Ludzie prześlizgują się; AI nie.
- Mechaniczna refaktoryzacja: zmiana nazw namespace'ów w 46 plikach, aktualizacja instrukcji import, przenoszenie stałych z klas konkretnych do interfejsów.
- Sprawdzanie spójności: weryfikacja że każdy serwis ma interfejs, każdy value object jest
readonly, każde porównanie używa stylu Yoda — w całej bazie kodu.
Do czego AI potrzebuje ludzi
- Decydowanie które abstrakcje wprowadzić: czy
SidebarDatapowinno być jednym value object czy czterema osobnymi wartościami zwrotnymi? AI może zaimplementować jedno i drugie; człowiek decyduje co jest czystsze. - Ocena kiedy interfejs dodaje wartość vs. obciążenie: serwis używany w jednym miejscu nie potrzebuje interfejsu dla elastyczności testowania. Zasada projektu mówi "każdy serwis dostaje interfejs" — ale to człowiek ustalił tę zasadę i człowiek może ją zmienić.
- Wiedzieć kiedy przestać: trzy rundy jakości wystarczą. Baza kodu jest czysta. Czwarta runda mikro-optymalizacji byłaby over-engineeringiem.
Pętla zwrotna AGENTS.md
Każda runda odkrywa wzorce warte udokumentowania. Runda 1 dodała konwencję value objects do AGENTS.md. Runda 2 dodała zasadę nazewnictwa interfejsów. Runda 3 dodała zasadę "zero zakodowanych URL-i".
Następnym razem gdy Claude Code generuje nowy serwis, od razu stosuje nauki ze wszystkich trzech rund. Pierwsza wersja serwisu teraz wysyłana jest z interfejsem, używa value objects dla złożonych zwrotów i odwołuje się do nazwanych tras zamiast zakodowanych ścieżek. Iteracja kumuluje się.
Rezultat
Po trzech rundach stan bazy kodu:
- 53 pliki PHP, wszystkie z
declare(strict_types=1), jawnymi typami zwrotnymi, warunkami Yoda - 12 interfejsów serwisów — każdy serwis wstrzykiwany przez interfejs
- 7 value objects — zero tablic asocjacyjnych dla wielopolowych zwrotów
- 1 enum — bezpieczne typowo warianty layoutu kart
- 25 komponentów Twig — reużywalne, współdzielone między stroną a styleguide'em
- 0 problemów PHP CS Fixer, 0 błędów PHPStan na poziomie 6
- 0 zakodowanych URL-i w szablonach i kontrolerach
Nic z tego nie było w pierwszej wersji. Pierwsza wersja miała wstrzyknięcia konkretnych klas, zwroty tablic, monolityczne szablony i zakodowane sprawdzenia locale. Działała — strona się budowała, strony się renderowały, użytkownicy mogli czytać posty na blogu.
Różnica to utrzymywalność. Dodanie systemu wielojęzyczności (poprzedni wpis) było proste, bo baza kodu była już czysta: interfejsy dla wszystkiego, typowane zwroty, czyste rozdzielenie odpowiedzialności. Refaktoryzacja czystej bazy kodu jest szybka. Refaktoryzacja bałaganu jest wolna i podatna na błędy.
Wyślij najpierw. Iteruj potem. Używaj AI do systematycznej pracy, którą solowy deweloper pominąłby — lub odłożył aż stanie się problemem. Trzy skupione rundy, każda budująca na poprzedniej, każda zweryfikowana automatycznymi narzędziami. Kod jest mierzalnie lepszy, a inwestycja to kilka godzin cykli przeglądów, nie tygodniowy rewrite.