Skip to content
Back to skills

Backend Patterns

ASecurity

Guides backend service architecture — layering, N+1 query prevention, caching, retries, rate limiting, and background job processing, in Go primarily and TypeScript/PHP where the pattern is language-agnostic. Use when structuring handler/service/repository layers, reviewing a service for N+1 queries, adding a cache in front of a slow read, implementing retry logic, or setting up a background job queue.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 16, 2026
developmenttypescriptgophpsqlapidatabasebackendperformance

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 16, 2026

npx -y skills add shennawardana23/skillme --skill backend-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Backend Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Backend Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/shennawardana23-backend-patterns/badge)](https://www.skillsdirectory.com/skills/shennawardana23-backend-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: backend-patterns
description: Guides backend service architecture — layering, N+1 query prevention, caching, retries, rate limiting, and background job processing, in Go primarily and TypeScript/PHP where the pattern is language-agnostic. Use when structuring handler/service/repository layers, reviewing a service for N+1 queries, adding a cache in front of a slow read, implementing retry logic, or setting up a background job queue.
license: Apache-2.0
metadata:
  version: "0.1.0"
---

# Backend Patterns

Structural patterns for server-side code, independent of any one
framework. For the shape of the request/response contract itself, see
`api-design`; for query- and index-level database patterns, see
`postgres-patterns`; for structured logs/metrics/traces, see
`observability-and-instrumentation`.

## Layering: handler → service → repository

Keep three concerns separate so each can be tested and changed
independently:

```go
// repository: only knows SQL, returns domain types or a wrapped error
type ReservationRepo struct{ db *sql.DB }

func (r *ReservationRepo) ByID(ctx context.Context, hotelID int64, id string) (*Reservation, error) {
    const q = `SELECT id, hotel_id, status FROM reservations WHERE hotel_id = $1 AND id = $2`
    var res Reservation
    if err := r.db.QueryRowContext(ctx, q, hotelID, id).Scan(&res.ID, &res.HotelID, &res.Status); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("reservation %s: %w", id, ErrNotFound)
        }
        return nil, fmt.Errorf("query reservation %s: %w", id, err)
    }
    return &res, nil
}

// service: business logic, orchestrates repositories, knows nothing about HTTP
type ReservationService struct{ repo *ReservationRepo }

func (s *ReservationService) Cancel(ctx context.Context, hotelID int64, id string) error {
    res, err := s.repo.ByID(ctx, hotelID, id)
    if err != nil {
        return err
    }
    if res.Status == StatusCheckedOut {
        return fmt.Errorf("cancel reservation %s: %w", id, ErrAlreadyCheckedOut)
    }
    return s.repo.SetStatus(ctx, hotelID, id, StatusCancelled)
}

// handler: only knows HTTP — decode, call service, map errors to status codes
func (h *Handler) CancelReservation(w http.ResponseWriter, r *http.Request) {
    hotelID, id := parseParams(r)
    if err := h.svc.Cancel(r.Context(), hotelID, id); err != nil {
        writeError(w, err) // maps ErrNotFound -> 404, ErrAlreadyCheckedOut -> 409, else 500
        return
    }
    w.WriteHeader(http.StatusNoContent)
}
```

The service layer must not import `net/http`, and the repository layer
must not contain business rules (e.g. "can't cancel after checkout"
belongs in the service, not buried in a `WHERE status != 'CHECKED_OUT'`
clause the caller can't see). This mirrors the interface-boundary
guidance in `api-and-interface-design`: the service depends on a small
consumer-defined repository interface, not a concrete `*sql.DB`.

## N+1 query prevention

The most common backend performance bug: fetching a list, then issuing
one query per row to fetch related data.

```go
// BAD: N+1 — one query per reservation to fetch its guest
reservations, _ := repo.ListByHotel(ctx, hotelID)
for _, r := range reservations {
    guest, _ := guestRepo.ByID(ctx, hotelID, r.GuestID) // N queries
    r.Guest = guest
}

// GOOD: batch-fetch every guest in one query
reservations, _ := repo.ListByHotel(ctx, hotelID)
guestIDs := make([]string, len(reservations))
for i, r := range reservations {
    guestIDs[i] = r.GuestID
}
guests, _ := guestRepo.ByIDs(ctx, hotelID, guestIDs) // 1 query, IN ($1, $2, ...) or ANY($1)
guestByID := make(map[string]*Guest, len(guests))
for _, g := range guests {
    guestByID[g.ID] = g
}
for _, r := range reservations {
    r.Guest = guestByID[r.GuestID]
}
```

`ByIDs` still needs the `hotel_id` predicate for partition pruning — see
`postgres-hotel-partitioning`. In an ORM (Prisma, Eloquent, GORM), the
same fix is an explicit eager load (`Preload`, `with()`, `include`), not
a loop calling `Find` per row.

## Caching: cache-aside with explicit invalidation

```go
func (s *ConfigService) Get(ctx context.Context) (*Config, error) {
    if cached, ok := s.cache.Get("config"); ok {
        return cached.(*Config), nil
    }
    cfg, err := s.repo.LoadConfig(ctx)
    if err != nil {
        return nil, err
    }
    s.cache.SetWithTTL("config", cfg, 5*time.Minute)
    return cfg, nil
}
```

Two failure modes to design against explicitly:

- **Stale-after-write**: any write path that changes cached data must
  invalidate (or update) the cache entry in the same transaction/request,
  not rely on TTL expiry alone — otherwise readers see stale data for the
  full TTL window after a write.
- **Thundering herd**: when a hot key expires, many concurrent requests
  can all miss the cache and hit the database at once. Use
  single-flight (Go's `golang.org/x/sync/singleflight`) so only one
  request per key actually queries the backing store while the rest wait
  for its result.

## Retries with backoff — only for retryable failures

```go
func withRetry(ctx context.Context, maxAttempts int, fn func() error) error {
    var err error
    for attempt := 0; attempt < maxAttempts; attempt++ {
        if err = fn(); err == nil {
            return nil
        }
        if !isRetryable(err) {
            return err // don't retry a 400 or a validation error
        }
        backoff := time.Duration(1<<attempt) * 100 * time.Millisecond
        jitter := time.Duration(rand.Int63n(int64(backoff / 2)))
        select {
        case <-time.After(backoff + jitter):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return fmt.Errorf("after %d attempts: %w", maxAttempts, err)
}
```

Jitter matters at scale: without it, every client backing off from the
same outage retries in lockstep and re-creates the load spike each
interval. Only retry idempotent operations, or operations made idempotent
via an idempotency key — retrying a bare `POST /charge` can double-charge.

## Rate limiting

Prefer a token-bucket limiter (`golang.org/x/time/rate` in Go) over a
hand-rolled sliding window — it's O(1) per check and battle-tested:

```go
limiter := rate.NewLimiter(rate.Limit(100), 20) // 100 req/s, burst 20

func RateLimit(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !limiter.Allow() {
            http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}
```

A single process-wide limiter only protects one instance; behind a load
balancer with N instances, the effective limit is N times higher. For a
service-wide limit, back the limiter with Redis (`INCR` + `EXPIRE`, or a
Lua script for atomicity) instead of in-memory state.

## Background jobs: durable queue, not an in-memory slice

An in-memory job queue (a slice plus a goroutine) loses every queued job
on process restart or crash. For anything that must survive a restart,
use a durable queue: Postgres-backed (`SELECT ... FOR UPDATE SKIP LOCKED`,
see `postgres-patterns`), or a dedicated queue (SQS, Redis Streams, NATS
JetStream). Design every job handler to be safely re-run (idempotent) —
a worker can crash after doing the work but before acknowledging it, and
the job will be redelivered.

## Gotchas

- `SELECT *` in a repository method silently breaks the moment someone
  adds a column with a default the code doesn't expect, or drops one the
  code still scans into — select named columns explicitly.
- A service method that returns `(T, error)` and also logs the error
  internally causes it to be logged twice once the caller (correctly)
  logs it again at the boundary — log once, at the boundary, and wrap the
  error with context on the way up instead.
- A cache with no TTL and no invalidation path is not a cache, it's a
  slow memory leak that also serves stale data forever.
- Rate limiting only at the load balancer, and not per-user/per-key
  inside the service, means one abusive API key can still starve every
  other tenant sharing the aggregate limit.

## Real-world grounding

The repository/service/handler split described here is the backend
analogue of the layered architecture popularized under names like "ports
and adapters" (hexagonal architecture, Alistair Cockburn, 2005) and
"clean architecture" (Robert C. Martin) — both make the same core claim
this skill does: business logic (the service layer) should have zero
import-level dependency on delivery mechanism (HTTP) or storage
mechanism (SQL driver), so either can be swapped or unit-tested without
the other.

## Verification

- [ ] Service layer has no `net/http` (or framework) import; repository layer has no business rules
- [ ] Every list-then-fetch-related-data path is a single batched query, not a loop
- [ ] Every cache write path has an explicit invalidation or update on the corresponding write path
- [ ] Retries apply jitter and skip non-retryable errors; retried operations are idempotent
- [ ] Rate limiting is enforced per key/tenant, not only in aggregate
- [ ] Background jobs are durable (survive a process restart) and job handlers are idempotent

Files in this skill

  • SKILL.md9.1 KB
  • evals/evals.json2.7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.