We have a hard rule in our codebase: GET handlers never mutate application state. Incidental server-side effects such as access logging are compatible with HTTP's definition of a safe method, but a GET must not create, update, or delete an application resource. This matters even more when TLS 1.3 early data is enabled.
The 0-RTT Replay Problem
We use Cloudflare with 0-RTT Connection Resumption enabled. TLS 1.3 early data can reduce latency for a resumed connection, but early data does not have inherent replay protection. An attacker may capture and replay it.
Cloudflare supports 0-RTT for GET, HEAD, and OPTIONS requests and marks them with Early-Data: 1. That safe-method restriction reduces the surface; it does not make a side-effecting GET safe. A GET that increments a counter, creates a record, or marks a notification as read may run more than once.
The broader rule comes from RFC 9110's safe-method semantics: clients, crawlers, caches, and prefetchers may invoke GET without expecting state changes. 0-RTT is one reason to respect that contract, not the only reason.
Our Layered Approach
Layering and REST are separate ideas; this is the dependency flow we use for HTTP requests:
router → middleware → handler → service → repository → model/DB
Each layer has a clear role:
| Layer | Responsibility |
|---|---|
| Router | URL → handler mapping |
| Middleware | Auth, country, locale, CSRF, rate limiting |
| Handler | Parse request, call service, return response |
| Service | Business logic, validation |
| Repository | SQL/GORM queries |
| Model | Database entity definitions |
By convention, handlers do not import repositories directly and services do not construct SQL. Go does not enforce these boundaries automatically, so import checks, tests, and review are still useful. The payoff is that storage details can change without rewriting HTTP concerns.
Practical Enforcement
GET handlers depend on query-oriented service interfaces:
type CatalogQueries interface {
List(context.Context, CatalogFilter) ([]CatalogCard, error)
}
func (h *CatalogHandler) List(c *gin.Context) {
var filter CatalogFilter
if err := c.ShouldBindQuery(&filter); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid filters"})
return
}
cards, err := h.queries.List(c.Request.Context(), filter)
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, cards)
}
Publishing, editing, and deleting use POST, PUT, PATCH, or DELETE. Cloudflare does not accept those methods as 0-RTT early data, but the method alone does not prevent duplicates: clients and proxies can retry requests, and users can double-submit. Important mutations still need transactions, uniqueness constraints, or idempotency keys.
DTO Separation
We split transport types by application context. A card view should expose only what the page needs:
type CatalogCard struct {
ID uint
Title string
Slug string
Price string
Currency string
Image string
}
The repository also uses an explicit projection for list queries instead of loading large text and JSONB columns. This reduces transferred and decoded data, although the actual gain must be measured against the real query and dataset rather than assumed.
What This Architecture Helps Prevent
- Accidental side effects on safe routes — GET stays safe under replay, crawling, and prefetching
- Scattered business rules — mutations have one service-level entry point
- Over-fetching — explicit repository projections make selected columns reviewable
- Transport/storage coupling — HTTP types do not have to mirror database models
It is more boilerplate than a flat handler-does-everything approach. The rule is worth it because it makes behavior easier to test and reason about. It is not, by itself, complete replay protection: mutation endpoints still require their own idempotency and authorization controls.
This article is based on lessons from building Towami, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.
Top comments (0)