Слоёная архитектура в Go: плоская модульная структура на примере tashirka

GoClean ArchitectureEchoDIАрхитектура

Этот сайт - один бинарник на Go без внешних зависимостей. Вся архитектура уложена в три папки internal/ и собирается без магии: интерфейсы, database/sql, templ и явная инициализация в main.go.

Структура: flat modular + layered

cmd/tashirka/main.go   - DI, инициализация, graceful shutdown
internal/core/         - db, health, view (без бизнес-логики)
internal/site/         - контент: markdown → HTML (post/project/service)
internal/lead/         - лиды: SQLite + SMTP
content/               - post|projects|services/*.md
static/                - embed.FS, PicoCSS, htmx
migrations/            - goose

Правило из AGENTS.md: core не импортирует другие модули. Модули site и lead не импортируют друг друга - связь строго через интерфейсы, которые внедряются в cmd/tashirka/main.go.

5 слоёв внутри каждого модуля

Каждый изолированный модуль строго из 5 директорий:

internal/site/model/     - структуры БД и DTO (без внешних импортов)
internal/site/storage/   - SQL через database/sql, кэш, goldmark
internal/site/service/   - бизнес-логика, зависит только от интерфейсов storage
internal/site/handler/   - Echo-хендлеры, валидация, рендер templ
internal/site/views/     - .templ с PicoCSS + htmx (hx-post, hx-target)

Пример site:

// model - конкретные типы, ноль импортов
type Post struct {
    Slug, Title, Date, Description, Cover string
    Tags []string
    HTML string
}
// storage - интерфейс + реализация на диске (не на БД)
type SiteStorage struct {
    baseDir       string
    postsCache    *ttlcache.Cache[string, []model.Post]
    // ...
}
func (s *SiteStorage) GetPosts(ctx context.Context) ([]model.Post, error)
// service - зависит только от интерфейса storage
type Storage interface {
    GetPosts(ctx context.Context) ([]model.Post, error)
    GetProjects(ctx context.Context) ([]model.Project, error)
    GetServices(ctx context.Context) ([]model.Service, error)
}
type SiteService struct { s Storage; baseURL string }
func (s *SiteService) GetPost(ctx context.Context, slug string) (*model.Post, error) {
    posts, err := s.s.GetPosts(ctx)
    // slices.IndexFunc, nil = не найдено
}
// handler - парсит запрос, вызывает service, рендерит фрагмент
func (h *Page) PostDetail(c echo.Context) error {
    post, err := h.s.GetPost(c.Request().Context(), c.Param("slug"))
    if post == nil { return view.RenderTemplate(c, site_view.PostNotFoundPage()) }
    return view.RenderTemplate(c, site_view.PostPage(post))
}
// views - templ компонент с hx-атрибутами
templ LeadForm(form model.LeadForm) {
    <form hx-post="/lead" hx-target="this" hx-swap="outerHTML">
}

Тот же паттерн в lead: model.Lead → storage.Lead.Insert(ctx, lead) + storage.SmtpNotifier.NotifyLead → service.LeadService.Create с sentinel-ошибками → handler.Lead.CreateLead.

DI без фреймворка - весь main.go

Никаких контейнеров, только конструкторы:

func Run() error {
    database, err := db.NewDB(os.Getenv("DB_NAME"))
    defer database.Close()

    e := echo.New()
    e.HideBanner = true
    e.Use(middleware.ContextTimeout(10 * time.Second))

    siteStorage := site_storage.NewSiteStorage("content", time.Hour)
    siteStorage.StartEviction()
    defer siteStorage.StopEviction()

    siteSvc := site_service.NewSiteService(siteStorage, siteURL)
    leadSvc := lead_service.NewLeadService(
        lead_storage.NewLeadStorage(database),
        lead_storage.NewSmtpNotifier(host, port, user, password, to, siteURL),
    )
    lead_handler.SetupHandlers(e, leadSvc)
    site_handler.SetupHandlers(e, siteSvc, lead_handler.NewLead(leadSvc))
}

Что важно:

  • SiteService не знает про SiteStorage - только про интерфейс Storage. В тестах подменяется struct-моком без генераторов.
  • Page хендлер получает LeadFormProvider - абстракция Form(c echo.Context) templ.Component, а не конкретный *Lead. Модули не импортируют друг друга.
  • Вся инициализация явная: нет init(), нет глобальных переменных, нет panic.

Жёсткие ограничения из AGENTS.md

Этот проект пишется под минимальный VPS и офлайн-работу, поэтому:

  • Нет ORM - только database/sql и чистый SQL с ? плейсхолдерами.
  • Нет any/interface{} - все типы конкретные (model.Post, model.LeadForm).
  • Нет reflect - парсинг frontmatter руками через strings.Split.
  • Нет глубокой вложенности - ранний return, максимум 3 уровня.
  • Максимум 2 возвращаемых значения - (result, error), (*T, error) где nil = не найдено, не ошибка.
  • Контекст первым - ctx context.Context во всех service/storage.
  • Бинарник в bin/ - make build-bin собирает в bin/tashirka.

Data Flow запроса

Клик/Форма (htmx) → handler (парсинг, валидация)
  → service (use case, sentinel-ошибки: ErrNameRequired)
    → storage (SQL/файлы, ttlcache)
  ← service ← storage
← handler (RenderTemplate с templ.Component)
→ htmx обновляет DOM (hx-swap="outerHTML")

Для /post/:slug: Page.PostDetail дёргает SiteService.GetPost → SiteStorage.GetPosts (кэш или readPosts + goldmark + sort.Slice по Date desc) → PostPage(post).

Тестирование - table-driven + struct-моки

Файлы _test.go рядом с кодом. Моки - лёгкие структуры внутри теста:

type mockStorage struct { posts []model.Post; err error }
func (m *mockStorage) GetPosts(ctx context.Context) ([]model.Post, error) {
    return m.posts, m.err
}

Проверяются успех (Main Course) и все ветки ошибок (Exceptional Course) - ErrNameRequired, ErrContactRequired, ErrConsentRequired.

Проверка

make check # go tool templ generate && go fmt ./... && golangci-lint run ./... && go test ./...
make dev   # air + templ generate, http://localhost:8000
curl -s localhost:8000/post | grep -q "Статьи"

Итого

Пять папок + интерфейсы + явный main.go дают изоляцию без оверинжиниринга. Код компилируется в один бинарь ~15MB, работает без сети, тестируется без мок-генераторов и читается сверху вниз.