Как собрать блог на .NET 10 силами одной LLM
Как собрать блог на .NET 10 силами одной LLM
Это учебный-исследовательский проект. Задачей было выкатить в продакшн готовое приложение, пользуясь только LLM (в основном — GPT-5.2). Для решения задачи использовались приёмы, наработанные в прошлых проектах.
Проект: self−hosted блог‑движок под одного автора, с очень жёсткими рамками: один бинарник, SQLite как единственная БД, public SSR без обязательного JS, admin без SPA, темы CSS−only через HTML Contract v1, комментарии только через Telegram Login Widget, и Telegram Instant View в R1 только через t.me/iv?…&rhash=…
—
TL; DR
- Фреймворк и язык — вторичны; первичны инварианты. Жёсткие запреты со старта экономят кучу итераций и «LLM‑галлюцинаций».
- Две самые дорогие зоны в таком продукте: SQLite‑конкурентность/бэкапы и AOT/trim‑совместимость.
- Скорость разработки LLM легко превращается в хаос, если нет «цемента»: ADR, тестов на контракты, ограничителей на рост памяти, и запрета на магию.
- По цифрам видно, что это был именно «инженерный» проект: 74.7% churn в src/, 16.1% — в tests/, и это за 637 коммитов за 8 дней.
—
Исходные инварианты (то, что определило всю архитектуру)
Если бы нужно было выделить один «секрет», почему проект вообще собрался, то он здесь: правила в AGENTS.md и спецификация в techspec.md / prd.md как механизм управления моделью.
- Один бинарник, без обязательных внешних сервисов.
- AOT‑готовность − обязательный элемент «boring ops»
- SQLite — единственная БД
- Public UI без JS (JS — только улучшения).
- Admin UI без SPA.
- Темы CSS−only, а публичный HTML — контракт (HTML Contract v1), который нельзя ломать без версии.
- Telegram−only комментарии (Login Widget) и Instant View через t.me/iv + rhash.
—
Статистика: куда ушло сколько усилий
Общая «теплокарта» churn по репозиторию
Churn − это грубая метрика «сколько кода перелопатили»: сумма добавленных + удалённых строк за выбранный период по истории git.
За всю историю (на момент анализа):
- Коммитов: 637
- Окно разработки: неделя с 2 026‑01‑03 по 2 026‑01‑10
- Общий churn: 118 251 строк (добавления+удаления)
Разбивка по верхним директориям:
- src/: 88 389 строк (74.7%)
- tests/: 19 042 строк (16.1%)
- docs/: 5 680 строк (4.8%)
Интерпретация: быстро строим систему → потом много фиксируем тестами и ADR, чтобы система перестала «плыть» при каждом рефакторинге.
Где именно «горело» в src/Blog. Web
Топ‑зоны по churn («где было сложно/дорого»):
- Admin UI и endpoints: src/Blog. Web/Endpoints/Admin/* — 17 833
- Точка сборки/маршрутизация: src/Blog. Web/Program.cs — 10 680
- SQLite слой: src/Blog. Web/Storage/Repositories/* — 9 705
- Импорт: src/Blog. Web/Importing/Sources/* — 6 146
- Рендеринг разметки: src/Blog. Web/Rendering/Markup.cs — 4 533
- Переезд HTML из C# в Razor Slices: src/Blog. Web/Slices/Admin/* — 3 138
- Telegram: src/Blog. Web/Integrations/Telegram/* — 770
- L10n (ресурсы и ключи): src/Blog. Web/L10n/* — ~2 000 суммарно
Что «цементировали» в тестах
Топ‑направления в tests/Blog. Web. Tests:
- Admin endpoints и страницы: tests/Blog. Web. Tests/Endpoints/Admin/* и крупные интеграционные тесты админки
- HTML Contract regression: ContractRegressionTests.cs — фиксируем публичную разметку как API
- Импорт: тесты телеграм‑каналов, пайплайна импорт‑джоб, источников
- Backup: тесты на параллельные записи, стриминг, регрессии database is locked
- Security/infra: guardrails forwarded headers, rate limiting, CSRF, Open Redirect на /lang
- Telegram Login/notifications: отдельные тесты verifier«а и уведомлений
Смысл: тесты здесь чтобы заморозить контракт и поведение, чтобы LLM могла рефакторить без „тихих“ поломок.
—
Хронология разработки (как реально шёл процесс)
Хронологически проект выглядит как серия „инженерных волн“. Судя по распределению коммитов, основные „боевые дни“ — 5–9 января.
2 026‑01‑03 — рамки и скелет
- Старт репо.
- Формализация целей в prd.md и docs/techspec.md.
- Включение дисциплины ADR (docs/adr/*) как обязательного реестра решений.
2 026‑01‑05 — базовая архитектура и „SQLite как нервная система“
- Большой пласт по разбиению Program.cs на модули, первичная декомпозиция.
- Переход к scoped SQLite connection per request + WAL (это сразу снижает класс ошибок и делает поведение предсказуемым под нагрузкой).
2 026‑01‑06 — безопасность и "production realism"
- Включение системных мер: CSRF/antiforgery, forwarded headers, ограничения на выдачу SVG (CSP sandbox), hardening на пути/ассеты, подготовка к AOT через JSON source generation.
- Переход с самописного Markdown на Markdig
2 026‑01‑07 — стабилизация: очереди, кэши, разделение ответственности
- Уведомления Telegram становятся best‑effort (уходят в фоновую очередь), чтобы не ломать создание комментария.
- SSR начинает жить по правилам HTTP‑кэширования (ETag/Last‑Modified, инвалидация кэша по изменению медиа и runtime‑настроек).
- SQLite lease становится async‑only
2 026‑01‑08 — „полировка продукта“
- Локализация через. resx.
- Runtime UI settings в SQLite.
- Custom pages как отдельный kind (страницы навигации без комментариев).
2 026‑01‑09 — крупнейший рефактор UI: Razor Slices в админке и отказ от склеивания строк
Судя по CHANGELOG.md и docs/changelog/*, это была целая серия миграций:
- Админские страницы и ошибки перестают собираться строками в C# и переезжают в. cshtml (Razor Slices).
- Убираются остатки
$$"""..."""и в public SSR, без нарушения HTML Contract v1.
Этим пришлось заниматься потому что LLM поначалу решил, что выдавать на выход склеенные строки — отличная идея.
2 026‑01‑10 — финальный рывок: NativeAOT/trim‑готовность
- Минимальные API‑эндпоинты переведены на более AOT‑дружелюбные handler и [FromServices].
- JSON сериализация централизована через source‑generated контекст.
- SSR fragment cache уходит с HybridCache на IMemoryCache, потому что trimming‑warnings «снаружи» ломают AOT‑сборку.
- Появляются./build−linux и./build−macos для удобства AOT‑сборок под разные RID.
—
Что вылезло и пришлось исправлять
Если смотреть на docs/adr/README.md, там есть важный маркер «мы пробовали и откатывали»: 3 Superseded ADR из 48.
1) Бэкапы SQLite: «VACUUM INTO» не тот инструмент
- Изначально: «сделаем консистентный snapshot через VACUUM INTO».
- Реальность: жёсткие блокировки (особенно под нагрузкой), плюс есть тонкие security footguns с правами и временными файлами.
- Итог: переход на SQLite Online Backup API
2) Конкурентность scoped‑соединения: сначала «починим re-entrancy», потом «уберём sync»
Сначала появляются решения вида:
- «сделаем Enter() re−entrant»
- «запретим смешивать Enter() и EnterAsync()»
это лечение симптомов, нормальное решение:
- делаем lease API async‑only
- и сериализуем всё через один механизм, чтобы не плодить режимы.
3) AOT/trim: зависимость может убить сборку
Даже если код «почти AOT‑safe», может прилететь откуда угодно:
- предупреждения ILLinker«а,
- reflection‑магия в библиотеке,
- генератор, который»не туда«сгенерировал.
Поэтому часть функциональности (например, кэш SSR‑фрагментов) пришлось реализовать через более»скучные«и надёжные компоненты.
—
Самые проблемные зоны
1) SQLite под нагрузкой: concurrency, транзакции, бэкапы
Получилась самая дорогая часть движка, пришлось искать и править, чтобы
- не упасть на дедлоках,
- не получить «database is locked» при бэкапе,
- не съесть thread pool из−за sync I/O,
- и не»сломать атомарность«на nested transactions.
2) SSR + HTTP caching: ETag/Last‑Modified и „липкие 304“
Любая мелочь, меняющая SSR HTML, должна быть учтена в ETag/LM — иначе можно получить „застывшие страницы“.
В репо видно, что этому уделено много внимания: инвалидация по media.updated_utc, runtime settings с устойчивым ETag‑part, централизованные хелперы.
3) Security „по краям“: не одна большая дыра, а 50 маленьких
Самые показательные классы фиксов:
- CSRF на state−changing POST,
- open redirect,
- forwarded headers spoofing guardrails,
- SVG as HTML/XSS и CSP sandbox,
- path traversal в теме/uploads,
- hard caps на in−memory трекеры (защита от OOM через proxy‑rotation).
—
Сравнение с конкурентами
Ghost (динамическая платформа публикаций)
Официальная документация описывает Ghost как „современное decoupled веб‑приложение“ с:
- core JSON API
- admin client app
- front‑end theme layer
а также упоминает, что по умолчанию используется ORM слой и что SQLite — default в dev, а MySQL рекомендуется для production (см. Ghost Architecture).
Сравнение с нашим проектом:
- у Ghost больше продуктовых возможностей (memberships/newsletters и т. п.), но и больше сложность/зависимости.
- у нас наоборот: минимум surface area, SQLite‑only, и максимально»boring ops«(один бинарник + папка data).
Hugo (статический генератор)
На главной Hugo прямо написано: open-source static site generator, и что он написан на Go
Сравнение:
- Hugo выигрывает в»нулевой эксплуатации«: нет БД, нет рантайм‑слоя, почти не за что ломаться.
- наш движок выигрывает в»живом продукте«: редактор/админка, комментарии, импорт, runtime‑настройки; но за это платим сложностью.
Jekyll (статические сайты/блоги)
Jekyll прямо продаёт идею: „No more databases…“ и «static sites come out ready for deployment»
Сравнение:
- Jekyll — „контент в git/файлах“, зато редактор/комментарии обычно внешние.
- наш движок — „контент в SQLite + uploads“, зато единый UX и self−contained runtime.
Orchard Core (большой. NET‑комбайн)
Orchard Core на официальных доках формулируется очень ясно: open-source modular, multi-tenant application framework and CMS for ASP. NET Core (см. docs.orchardcore.net).
Сравнение:
- Orchard — это»платформа для всего«(модульность, multi−tenant), то есть другая лига сложности.
- наш движок — „моно‑продукт для одного автора“, где контракты и минимализм важнее расширяемости.
Miniblog.Core (лёгкий ASP. NET Core блог)
В README проекта указано «An ASP. NET Core blogging engine» и перечислены фичи (RSS/ATOM, comments, SEO, service worker, etc.) (см. Miniblog. Core).
Сравнение:
- ближе всего по классу «не монстр−CMS, а блог‑движок».
- Miniblog.Core прекрасный блог который пытается сделать „как у взрослых“ минимальными силами.
- Наш проект − выкидывает фичи в угоду простоте. Например, поддерживать полноценный емэйл в 2 026 году это просто головная боль − значит email просто не будет.
—
Как выглядел процесс разработки
- Много маленьких коммитов (637 за 8 дней)
- ADR как «память проекта»: 44 принятых решения + фиксация откатов (Superseded).
- Тесты как «стоп−кран»: особенно контрактные тесты публичного HTML и регрессии по безопасности/кэшированию/бэкапам.
- Нулевая терпимость к warnings и фокус на AOT‑совместимость
—
Итог: что получилось и где границы
В рамках R1 получился движок, который концептуально можно описать так:
- «Ультимативная простота эксплуатации»: один бинарник + data/ (SQLite + uploads + темы + бэкапы).
- Public — SSR и живёт без JS.
- Admin — SSR, но современный UX через улучшения (без SPA).
- Темы — CSS−only, а HTML — контракт.
- Комментарии/социальность — через Telegram, без email.
- Архитектура — с прицелом на NativeAOT/trim.