3
Go + htmx + Postgres
my full stack setup for go projects
LLucas Oliveira
·Jan 12, 2026·18 views# Go + htmx + Postgres Stack
Production patterns for full-stack Go. No JS frameworks, no build steps, just HTML over the wire.
## Tech Stack
- **Language**: Go 1.22+
- **Frontend**: htmx + Alpine.js (minimal JS)
- **Database**: Postgres 16
- **Templates**: html/template with templ components
- **Router**: chi
- **Migrations**: goose
- **Testing**: stdlib + testcontainers
## Project Structure
```
.
├── cmd/
│ └── server/
│ └── main.go # Entry point
├── internal/
│ ├── config/
│ │ └── config.go # Env loading, validation
│ ├── database/
│ │ ├── database.go # Connection pool
│ │ ├── queries.sql # sqlc queries
│ │ └── migrations/ # goose migrations
│ ├── handler/
│ │ ├── handler.go # HTTP handlers
│ │ ├── middleware.go # Auth, logging, recovery
│ │ └── routes.go # Route definitions
│ ├── model/
│ │ └── model.go # Domain types
│ ├── service/
│ │ └── service.go # Business logic
│ └── view/
│ ├── layout.templ # Base layout
│ ├── components/ # Reusable templ components
│ └── pages/ # Page templates
├── static/
│ └── css/
│ └── app.css # Tailwind output
├── docker-compose.yml
├── Dockerfile
├── Makefile
└── go.mod
```
## Commands
```bash
# Development
make dev # Run with hot reload (air)
make db-up # Start Postgres container
make db-migrate # Run migrations
make db-seed # Seed test data
make generate # Generate sqlc + templ
# Testing
make test # Run all tests
make test-unit # Unit tests only
make test-int # Integration tests (needs DB)
make coverage # Generate coverage report
# Production
make build # Build binary
make docker # Build Docker image
make lint # golangci-lint
```
## Code Conventions
### Handlers
Handlers receive requests, call services, render responses. No business logic.
```go
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := decode(r, &req); err != nil {
h.renderError(w, r, err, http.StatusBadRequest)
return
}
user, err := h.userService.Create(r.Context(), req.Email, req.Name)
if err != nil {
h.renderError(w, r, err, http.StatusInternalServerError)
return
}
// htmx partial or full page based on request
if isHTMX(r) {
h.render(w, r, view.UserRow(user))
} else {
h.render(w, r, view.UserPage(user))
}
}
```
### Services
Services contain business logic. They receive interfaces, return concrete types.
```go
type UserService struct {
db UserRepository
mailer Mailer
}
func (s *UserService) Create(ctx context.Context, email, name string) (*User, error) {
// Validate
if !isValidEmail(email) {
return nil, ErrInvalidEmail
}
// Check uniqueness
existing, err := s.db.GetByEmail(ctx, email)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("check existing: %w", err)
}
if existing != nil {
return nil, ErrEmailTaken
}
// Create
user, err := s.db.Create(ctx, email, name)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
// Side effects
if err := s.mailer.SendWelcome(ctx, user.Email); err != nil {
slog.Warn("failed to send welcome email", "err", err, "user_id", user.ID)
// Don't fail the request for email issues
}
return user, nil
}
```
### Error Handling
Use sentinel errors for expected cases, wrap for context.
```go
var (
ErrNotFound = errors.New("not found")
ErrInvalidEmail = errors.New("invalid email format")
ErrEmailTaken = errors.New("email already registered")
)
// Wrapping for context
return nil, fmt.Errorf("get user %s: %w", id, err)
// Checking
if errors.Is(err, ErrNotFound) {
// Handle not found
}
```
### Database Queries (sqlc)
Define queries in SQL, generate Go code.
```sql
-- queries.sql
-- name: GetUser :one
SELECT * FROM users WHERE id = $1;
-- name: ListUsers :many
SELECT * FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2;
-- name: CreateUser :one
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING *;
```
### htmx Patterns
Return HTML fragments for htmx requests.
```go
// Check if htmx request
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// Handler returns partial or full page
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.userService.List(r.Context())
if err != nil {
h.renderError(w, r, err, http.StatusInternalServerError)
return
}
if isHTMX(r) {
h.render(w, r, view.UserTable(users))
} else {
h.render(w, r, view.UsersPage(users))
}
}
```
templ component example:
```go
// view/components/user_row.templ
templ UserRow(user *model.User) {
<tr id={ fmt.Sprintf("user-%d", user.ID) }>
<td>{ user.Email }</td>
<td>{ user.Name }</td>
<td>
<button
hx-delete={ fmt.Sprintf("/users/%d", user.ID) }
hx-target="closest tr"
hx-swap="outerHTML"
hx-confirm="Delete this user?"
>
Delete
</button>
</td>
</tr>
}
```
### Migrations
Use goose for migrations. One migration per change.
```sql
-- migrations/001_create_users.sql
-- +goose Up
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_users_email ON users(email);
-- +goose Down
DROP TABLE users;
```
## Testing
### Unit Tests
Test services with mocked dependencies.
```go
func TestUserService_Create(t *testing.T) {
t.Run("valid user", func(t *testing.T) {
db := &mockUserRepo{}
mailer := &mockMailer{}
svc := NewUserService(db, mailer)
user, err := svc.Create(context.Background(), "test@example.com", "Test")
require.NoError(t, err)
assert.Equal(t, "test@example.com", user.Email)
assert.True(t, mailer.welcomeSent)
})
t.Run("duplicate email", func(t *testing.T) {
db := &mockUserRepo{existing: &User{Email: "taken@example.com"}}
svc := NewUserService(db, nil)
_, err := svc.Create(context.Background(), "taken@example.com", "Test")
assert.ErrorIs(t, err, ErrEmailTaken)
})
}
```
### Integration Tests
Use testcontainers for real database tests.
```go
func TestUserRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
container, db := setupTestDB(t, ctx)
t.Cleanup(func() { container.Terminate(ctx) })
repo := NewUserRepository(db)
t.Run("create and retrieve", func(t *testing.T) {
user, err := repo.Create(ctx, "test@example.com", "Test")
require.NoError(t, err)
found, err := repo.GetByID(ctx, user.ID)
require.NoError(t, err)
assert.Equal(t, user.Email, found.Email)
})
}
```
## Configuration
Twelve-factor style. Environment variables, validated at startup.
```go
type Config struct {
Port int `env:"PORT" envDefault:"8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
}
func Load() (*Config, error) {
cfg := &Config{}
if err := env.Parse(cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return cfg, nil
}
```
## Docker
Multi-stage build for small images.
```dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/server
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /server
COPY --from=builder /app/static /static
EXPOSE 8080
CMD ["/server"]
```
## Observability
Structured logging with slog.
```go
slog.Info("user created",
"user_id", user.ID,
"email", user.Email,
"duration_ms", time.Since(start).Milliseconds(),
)
```
Request logging middleware:
```go
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := wrapResponseWriter(w)
next.ServeHTTP(wrapped, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.status,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", r.Header.Get("X-Request-ID"),
)
})
}
```
## Production Checklist
- [ ] Graceful shutdown handling
- [ ] Health check endpoint (`/healthz`)
- [ ] Ready check endpoint (`/readyz`)
- [ ] Request ID propagation
- [ ] Panic recovery middleware
- [ ] Rate limiting
- [ ] CORS configuration
- [ ] Database connection pooling
- [ ] Migrations run on deploy
- [ ] Structured JSON logging
Comments
No comments yet
Be the first to share your thoughts!