Architecture microservice
Architecture
This document explains how azatcloud is put together after Phase 1: a server-rendered DevOps blog with persistent storage, a token-protected CMS, RSS, SEO tags, and HTMX pagination — all in a single Go binary.
It is written to be read top to bottom: start with the big picture, then drill into each layer and the important flows (reading an article, writing one, migrating the database, starting and stopping the server).
1. Design goals
- Server-rendered, low-JS. Pages are HTML produced on the server with
html/template. HTMX adds small interactive touches (load-more) without a SPA framework. This is fast and SEO-friendly. - Storage behind an interface. The HTTP layer never talks to a database
directly — it depends on the
ArticleRepositoryinterface. That makes the app runnable with zero infrastructure (in-memory store) and swappable to Postgres without touching handlers. - Single deployable artifact. Templates and SQL migrations are embedded
into the binary with
//go:embed. The container is a tiny distroless image. - Dependencies point inward.
domainis the center and imports nothing app-specific; outer layers (delivery, repository implementations) depend on the inner ones, never the reverse.
2. Big picture
flowchart LR
subgraph Clients
B["Browser"]
C["curl / scripts"]
F["RSS readers"]
end
B --> RT
C --> RT
F --> RT
subgraph App["azatcloud — single Go binary"]
RT["chi Router + middleware"]
H["HTTP handlers"]
RND["render: goldmark + chroma"]
TPL["html/template, embedded"]
REPO["ArticleRepository interface"]
RT --> H
H --> RND
H --> TPL
H --> REPO
end
REPO -->|"DATABASE_URL set"| PG[("PostgreSQL")]
REPO -->|"otherwise"| MEM["in-memory store"]
A request enters the chi router, passes through the middleware chain, and lands
on a handler. The handler reads or writes through the repository interface,
optionally renders Markdown to HTML, fills a template, and writes the response.
Which repository implementation is wired in is decided once, at startup, by the
presence of DATABASE_URL.
3. Packages and dependency direction
flowchart TB
CMD["cmd/server (main)"]
DEL["internal/delivery/http"]
RND["internal/render"]
REPO["internal/repository (interface)"]
MEM["internal/repository/memory"]
PG["internal/repository/postgres"]
DOM["internal/domain"]
CFG["pkg/config"]
TXT["pkg/textutil"]
WEB["web (templates)"]
MIG["migrations (SQL)"]
CMD --> DEL
CMD --> REPO
CMD --> MEM
CMD --> PG
CMD --> CFG
CMD --> MIG
DEL --> REPO
DEL --> RND
DEL --> CFG
DEL --> TXT
DEL --> WEB
MEM --> DOM
PG --> DOM
REPO --> DOM
| Package | Responsibility |
|---|---|
internal/domain |
The Article entity. No dependencies on anything app-specific. |
internal/repository |
The ArticleRepository interface and ErrNotFound. |
internal/repository/memory |
In-memory implementation (mutex + map). Used for go run with no DB and by the unit tests. |
internal/repository/postgres |
pgx/pgxpool implementation plus the built-in migrator. |
internal/render |
Markdown to HTML via goldmark with chroma syntax highlighting. |
internal/delivery/http |
chi router, public handlers, admin CMS handlers, auth middleware, template sets. |
pkg/config |
Loads configuration from environment variables. |
pkg/textutil |
Slugify and ReadingTime helpers. |
web |
Embedded html/template files. |
migrations |
Embedded *.sql schema migrations. |
cmd/server |
Composition root: load config, pick a repository, run migrations, start the server, shut down gracefully. |
main is the only place that knows about concrete implementations; everything
else works against interfaces and the standard library.
4. The storage abstraction
The whole point of the interface is that the delivery layer is blind to where data lives. The contract:
type ArticleRepository interface {
// Public reads (published only)
ListPublished(ctx, limit, offset int) ([]domain.Article, error)
CountPublished(ctx) (int, error)
GetBySlug(ctx, slug string) (domain.Article, error)
ListByTag(ctx, tag string) ([]domain.Article, error)
// Admin reads/writes (drafts included)
ListAll(ctx) ([]domain.Article, error)
GetByID(ctx, id string) (domain.Article, error)
Create(ctx, a domain.Article) (domain.Article, error)
Update(ctx, a domain.Article) (domain.Article, error)
Delete(ctx, id string) error
}
Two implementations satisfy it:
- memory keeps a
map[string]Articleguarded by async.RWMutex, hands out IDs likemem-1, and seeds two demo posts. It is the default when no database is configured, and it backs the unit tests (so CI needs no database for them). - postgres runs the same operations as SQL over a
pgxpoolconnection pool.
main selects one at startup:
flowchart TD
S["Start: config.Load()"] --> Q{"DATABASE_URL set?"}
Q -->|"no"| M["memory.NewArticleRepo()"]
Q -->|"yes"| P1["pgxpool.NewWithConfig (MaxConns 10, MinConns 2)"]
P1 --> P2["pool.Ping"]
P2 --> P3["postgres.Migrate(migrations.FS)"]
P3 --> P4["postgres.New(pool)"]
P4 --> P5["repo.SeedIfEmpty"]
M --> H["NewHandler(repo, logger, cfg)"]
P5 --> H
Because both implementations return the same domain.Article values and the
same ErrNotFound sentinel, handlers treat them identically.
5. Public request lifecycle (reading the home page)
sequenceDiagram
participant U as Browser
participant R as chi Router
participant MW as Middleware chain
participant H as ListArticles
participant Repo as ArticleRepository
participant T as Template
U->>R: GET /?page=1
R->>MW: RequestID, RealIP, Logger, Recoverer, Timeout
MW->>H: dispatch
H->>Repo: ListPublished(perPage, offset)
Repo-->>H: list of articles
H->>Repo: CountPublished()
Repo-->>H: total
Note over H: hasMore = offset + len < total
alt HX-Request header present
H->>T: render "articleitems" partial only
else normal request
H->>T: render full page ("base")
end
T-->>U: HTML
The handler computes the page offset, fetches one page of published articles plus the total count, and decides whether a "load more" control is needed. The same handler answers both the full-page request and the HTMX fragment request — the only difference is which template it executes.
Middleware chain
Applied in order to every route:
| Order | Middleware | Purpose |
|---|---|---|
| 1 | RequestID |
Attach a unique ID to each request for log correlation. |
| 2 | RealIP |
Resolve the client IP from proxy headers. |
| 3 | Logger |
Structured access logging. |
| 4 | Recoverer |
Turn panics into 500s instead of crashing the server. |
| 5 | Timeout(30s) |
Bound request duration. |
Routes
| Method | Path | Handler | Access |
|---|---|---|---|
| GET | /health, /readyz |
Health |
public |
| GET | / |
ListArticles |
public |
| GET | /articles/{slug} |
GetArticle |
public |
| GET | /tags/{tag} |
ListByTag |
public |
| GET | /rss |
RSS |
public |
| GET / POST | /admin/login |
AdminLoginForm / AdminLogin |
public |
| GET | /admin |
AdminIndex |
protected |
| POST | /admin/logout |
AdminLogout |
protected |
| GET | /admin/articles/new |
AdminNew |
protected |
| POST | /admin/articles |
AdminCreate |
protected |
| GET | /admin/articles/{id}/edit |
AdminEdit |
protected |
| POST | /admin/articles/{id} |
AdminUpdate |
protected |
| POST | /admin/articles/{id}/delete |
AdminDelete |
protected |
Protected routes live in a chi group guarded by the RequireAdmin middleware.
The login routes are intentionally outside that group so an unauthenticated user
can reach them.
6. Article rendering pipeline
Articles are stored as Markdown and converted to HTML at request time.
flowchart LR
MD["Article.Content (Markdown)"] --> GM["goldmark parser"]
GM --> HL["chroma highlighter (monokai)"]
HL --> HTML["safe HTML"]
HTML --> TPL["article template"]
TPL --> OUT["rendered page"]
render.ToHTML runs goldmark with the highlighting extension so fenced code
blocks (Go, YAML, Dockerfile, etc.) come out colorized as inline-styled markup.
The result is injected into the article template.
7. Data model (Postgres)
Tags are normalized rather than stored as a column, which makes "list by tag" a clean join and avoids duplicated tag strings.
erDiagram
ARTICLES ||--o{ ARTICLE_TAGS : has
TAGS ||--o{ ARTICLE_TAGS : labels
ARTICLES {
uuid id PK
text slug UK
text title
text content
text excerpt
text cover_url
bool published
int reading_min
timestamptz created_at
timestamptz updated_at
}
TAGS {
uuid id PK
text name UK
}
ARTICLE_TAGS {
uuid article_id FK
uuid tag_id FK
}
Indexes exist on articles(slug), articles(published), and
articles(created_at DESC) to support slug lookups, published filtering, and
newest-first ordering. article_tags cascades on delete, so removing an article
cleans up its links automatically.
Reads
Selects use a LEFT JOIN through article_tags to tags and ARRAY_AGG the
tag names back into the Article.Tags slice in a single query, grouped by
article id.
Writes and tag sync
Create and Update run inside a transaction. After writing the article row,
syncTags rebuilds the tag links: it deletes the existing article_tags rows
for that article, upserts each tag name into tags (ON CONFLICT DO NOTHING),
then re-inserts the links. If anything fails, the transaction rolls back and the
article is left untouched.
8. Migrations
Migrations are plain SQL files in migrations/, embedded into the binary, and
applied by a small built-in migrator — no external tool is required to run the
app.
flowchart TD
A["Migrate(pool, embedded FS)"] --> B["CREATE TABLE IF NOT EXISTS schema_migrations"]
B --> C["List embedded *.up.sql, sorted"]
C --> D{"version in schema_migrations?"}
D -->|"yes"| E["skip"]
D -->|"no"| F["BEGIN tx"]
F --> G["exec migration SQL"]
G --> H["INSERT version into schema_migrations"]
H --> I["COMMIT"]
E --> J["next file"]
I --> J
Each applied file is recorded by filename in schema_migrations, so startups
are idempotent: already-applied migrations are skipped. For production teams who
prefer explicit control, the same files work with the golang-migrate CLI
(make migrate); the built-in migrator is the zero-dependency default.
9. The CMS and authentication
Phase 1 uses a single shared secret (ADMIN_TOKEN) to protect the admin
area. This is deliberately minimal — Phase 2 replaces it with real user auth
(OAuth / JWT). If ADMIN_TOKEN is empty, the admin area returns 503 (disabled).
How RequireAdmin decides
flowchart TD
R["Request to /admin/*"] --> T{"ADMIN_TOKEN configured?"}
T -->|"no"| D503["503 admin disabled"]
T -->|"yes"| TOK["read token: admin_session cookie or Bearer header"]
TOK --> V{"constant-time match?"}
V -->|"yes"| N["proceed to handler"]
V -->|"no"| B{"browser GET?"}
B -->|"yes"| RED["303 redirect to /admin/login"]
B -->|"no"| U401["401 unauthorized"]
The token is accepted from either the admin_session cookie (set by the login
form) or an Authorization: Bearer header (handy for scripts and curl), and is
compared with subtle.ConstantTimeCompare to avoid timing leaks.
Writing an article (admin create)
sequenceDiagram
participant A as Admin (browser)
participant MW as RequireAdmin
participant H as AdminCreate
participant F as articleFromForm
participant Repo as ArticleRepository
A->>MW: POST /admin/articles (form)
MW-->>A: 303 to /admin/login (if not authed)
MW->>H: authed
H->>F: parse form
Note over F: slug = Slugify(slug or title); reading_min = ReadingTime(content); tags split + dedupe; published = checkbox
F-->>H: domain.Article
H->>Repo: Create(article)
Repo-->>H: saved (with id, timestamps)
H-->>A: 303 redirect to /admin
articleFromForm is the single place that turns form fields into a domain
object: it auto-generates the slug when blank, computes reading time from the
content, splits the comma-separated tags (de-duplicated), and reads the
published checkbox. AdminUpdate reuses the same function and sets the id
from the URL.
10. HTMX pagination (load more)
The home page shows perPage (5) articles. If more exist, a "Load more" button
is rendered. Clicking it asks the server for the next page as a fragment and
swaps it in place.
flowchart TD
P1["GET / -> full page: 5 articles + Load more button"]
P1 --> CLICK["user clicks Load more"]
CLICK --> HX["hx-get /?page=2 with HX-Request header"]
HX --> SRV["ListArticles renders ONLY the articleitems partial"]
SRV --> SWAP["hx-swap=outerHTML replaces the button with: next 5 articles + a new Load more button"]
SWAP --> CLICK
The trick is that the partial template (articleitems) renders both the article
items and the next button together. Because the button swaps itself with
outerHTML, each click appends the next batch and leaves a fresh button — until
hasMore is false, at which point no button is emitted and the chain ends. The
same handler serves the full page and the fragment; it checks the HX-Request
header to decide which template to execute.
11. Templates
Templates are embedded from web/templates and composed into named sets in
NewHandler. A base layout defines the shared shell (head, header, footer) and
a content block; each page template fills that block.
| Set | Files | Used by |
|---|---|---|
| list | base + list + partials | home page, tag page |
| partial | partials | HTMX load-more fragment |
| single | base + article | single article |
| adminList | base + admin_list | /admin |
| adminForm | base + admin_form | new / edit forms |
| adminLogn | base + admin_login | login page |
The base layout also emits Open Graph / SEO <meta> tags from a Meta value
the handler passes in, plus a <link rel="alternate" ... /rss> and the HTMX
script tag.
12. RSS and SEO
- RSS (
/rss): the handler pulls the 20 most recent published articles and builds an RSS 2.0 feed withgorilla/feeds, served asapplication/rss+xml. Item links are absolute, built fromBASE_URL. - SEO / Open Graph: every page passes a
Metastruct (title, description, canonical URL, type) that the base template renders into<meta>tags so links unfurl nicely and search engines get clean metadata.
13. Configuration
All configuration comes from environment variables (see .env.example):
| Variable | Default | Effect |
|---|---|---|
ADDR |
:8080 |
HTTP listen address |
ENV |
development |
environment label |
BASE_URL |
http://localhost:8080 |
absolute URLs for OG tags and RSS |
DATABASE_URL |
empty | Postgres DSN; empty selects the in-memory store |
ADMIN_TOKEN |
empty | admin secret; empty disables the admin area |
14. Startup and graceful shutdown
sequenceDiagram
participant OS
participant M as main / run
participant DB as Postgres
participant SRV as http.Server
M->>M: config.Load()
M->>DB: buildRepo -> pool, Ping, Migrate, SeedIfEmpty
DB-->>M: repository ready
M->>SRV: NewHandler + NewRouter, ListenAndServe (goroutine)
Note over M: signal.NotifyContext(SIGINT, SIGTERM)
OS-->>M: SIGINT / SIGTERM
M->>SRV: Shutdown(ctx, 10s timeout)
SRV-->>M: in-flight requests drained
M->>DB: pool.Close (deferred cleanup)
M-->>OS: exit 0
The server runs in a goroutine; main blocks on either a server error or an
OS signal. On a signal it calls Shutdown with a 10-second deadline so
in-flight requests finish, then the deferred cleanup closes the connection pool.
15. Build and delivery
flowchart LR
SRC["source + embedded templates and migrations"] --> B1["build stage: golang 1.26, CGO disabled, static binary"]
B1 --> B2["runtime stage: distroless static, nonroot"]
B2 --> IMG["image"]
IMG --> REG["private GitLab Container Registry"]
The Dockerfile is multi-stage: it compiles a static binary, then copies only
that binary into a distroless image (no shell, no package manager, runs as
non-root). The GitLab pipeline runs lint -> test -> build; the test job
attaches a postgres:16 service for integration tests, and the build job pushes
the image to the project's private registry on pushes to main.
16. How to extend
- Add a field to articles: add it to
domain.Article, create a newmigrations/0000XX_*.up.sql(and a.down.sql), update the Postgres select columns / insert / update, and update the memory store and templates. The migrator applies the new file automatically on next startup. - Add an endpoint: add a handler method in
internal/delivery/http, then register the route inNewRouter(inside the admin group if it needs protection). - Swap to sqlc: the Postgres repository is hand-written pgx today. Adding a
sqlc.yamlandquery.sqlto generate the query layer is a drop-in replacement behind the sameArticleRepositoryinterface — nothing else changes. - Real auth (Phase 2): replace
RequireAdminand the shared-token login with user accounts, sessions/JWT, and roles. The route group and handler signatures stay the same.