DEV Community

Antonio Towami
Antonio Towami

Posted on

Our Layered REST Architecture in Go: Why GET Must Not Mutate Application State

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Accidental side effects on safe routes — GET stays safe under replay, crawling, and prefetching
  2. Scattered business rules — mutations have one service-level entry point
  3. Over-fetching — explicit repository projections make selected columns reviewable
  4. 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)