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!