Слоёная архитектура в 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, работает без сети, тестируется без мок-генераторов и читается сверху вниз.