Questo articolo mostra come progettare e implementare un’API GraphQL in Go concentrandosi su un unico model. L’obiettivo è capire lo scheletro essenziale: definizione dello schema, resolver tipizzati, ordinamento, paginazione “page-based”, validazione, autorizzazione e pubblicazione dell’endpoint /graphql con CORS configurato in modo sicuro. Mantenere il perimetro al solo modello Post rende il percorso lineare e poi facilmente estendibile.
Stack e organizzazione
- Linguaggio: Go (versione recente, con mod attivi)
- Server GraphQL: gqlgen(tipizzato, codegen) sunet/http
- Persistenza: a scelta (driver SQL, GORM, sqlc, o repository in-memory per iniziare)
- Validazione: go-playground/validatoro regole mirate nei resolver
- Autorizzazione: middleware che popola il context con l’utente
- CORS: github.com/rs/corso middleware equivalente
Il flusso tipico è: definire lo schema GraphQL tipizzando Post e le operazioni, generare i tipi con gqlgen, implementare i resolver delegando la logica di dati a un repository, esporre il server e applicare CORS e sicurezza.
Schema GraphQL: tipo Post e operazioni
Lo schema espone un tipo essenziale e un set ridotto di operazioni. L’elenco prevede ordinamento per publishedAt e paginazione “page-based”.
"""Rappresenta un contenuto pubblicato."""
type Post {
  id: ID!
  title: String!
  body: String!
  publishedAt: String
  createdAt: String!
  updatedAt: String!
}
"""Metadati di paginazione."""
type PaginatorInfo {
  count: Int!
  currentPage: Int!
  lastPage: Int!
  perPage: Int!
  total: Int!
  hasMorePages: Boolean!
}
"""Risultato paginato di Post."""
type PostPaginator {
  data: [Post!]!
  paginatorInfo: PaginatorInfo!
}
type Query {
  posts(page: Int = 1, perPage: Int = 10, sort: String = "desc"): PostPaginator!
  post(id: ID!): Post
}
type Mutation {
  createPost(title: String!, body: String!, publishedAt: String): Post!
  updatePost(id: ID!, title: String, body: String, publishedAt: String): Post!
  deletePost(id: ID!): Boolean!
}Installazione di gqlgen e bootstrap
Si prepara il progetto, si aggiunge gqlgen e si generano i file iniziali. La configurazione di gqlgen crea tipi Go tipizzati, interfacce per i resolver e un server GraphQL pronto da agganciare a net/http.
go mod init example.com/postapi
go get github.com/99designs/gqlgen@latest
go run github.com/99designs/gqlgen init
# Poi sostituisci lo schema con quello qui sopra e rigenera
go run github.com/99designs/gqlgen generateModello dominio Post e repository
Il dominio rimane minimale. Un repository incapsula le operazioni sui dati (in-memory o DB). In produzione sostituiscilo con GORM/sqlc e indicizza il campo di ordinamento.
package domain
import "time"
type Post struct {
  ID          string     `json:"id"`
  Title       string     `json:"title"`
  Body        string     `json:"body"`
  PublishedAt *time.Time `json:"publishedAt,omitempty"`
  CreatedAt   time.Time  `json:"createdAt"`
  UpdatedAt   time.Time  `json:"updatedAt"`
}
type PostRepository interface {
  List(page, perPage int, sortAsc bool) (items []Post, total int, err error)
  FindByID(id string) (*Post, error)
  Create(p Post) (Post, error)
  Update(id string, patch map[string]any) (Post, error)
  Delete(id string) (bool, error)
}Resolver con gqlgen
I resolver sono semplici: convalidano input, applicano l’autorizzazione tramite context, orchestrano il repository e costruiscono i metadati di paginazione. L’ordinamento usa publishedAt e, all’occorrenza, un tie-breaker su ID.
package graph
import (
  "context"
  "strings"
  "time"
"example.com/postapi/domain"
"example.com/postapi/graph/model"
)
type Resolver struct {
  Posts domain.PostRepository
}
func (r *queryResolver) Posts(ctx context.Context, page *int, perPage *int, sort *string) (*model.PostPaginator, error) {
  p := 1
  if page != nil && *page > 0 { p = *page }
  pp := 10
  if perPage != nil && *perPage > 0 && *perPage <= 100 { pp = *perPage }
  s := "desc"
  if sort != nil { s = *sort }
  asc := strings.ToLower(s) == "asc"
  items, total, err := r.Posts.List(p, pp, asc)
  if err != nil { return nil, err }
  last := max(1, (total+pp-1)/pp)
  data := make([]*model.Post, 0, len(items))
  for _, it := range items {
    data = append(data, &model.Post{
      ID: it.ID,
      Title: it.Title,
      Body: it.Body,
      CreatedAt: it.CreatedAt.Format(time.RFC3339),
      UpdatedAt: it.UpdatedAt.Format(time.RFC3339),
      PublishedAt: toPtrIfNotNil(it.PublishedAt),
    })
  }
return &model.PostPaginator{
  Data: data,
  PaginatorInfo: &model.PaginatorInfo{
    Count:        len(items),
    CurrentPage:  p,
    LastPage:     last,
    PerPage:      pp,
    Total:        total,
    HasMorePages: p < last,
  },
 }, nil
}
func (r *queryResolver) Post(ctx context.Context, id string) (*model.Post, error) {
  it, err := r.Posts.FindByID(id)
  if err != nil || it == nil { return nil, err }
  return &model.Post{
    ID: it.ID,
    Title: it.Title,
    Body: it.Body,
    CreatedAt: it.CreatedAt.Format(time.RFC3339),
    UpdatedAt: it.UpdatedAt.Format(time.RFC3339),
    PublishedAt: toPtrIfNotNil(it.PublishedAt),
  }, nil
}
func (r *mutationResolver) CreatePost(ctx context.Context, title string, body string, publishedAt *string) (*model.Post, error) {
  if !isAuthorized(ctx) { return nil, ErrUnauthorized }
  if err := validateCreate(title, body, publishedAt); err != nil { return nil, err }
  now := time.Now().UTC()
  var pub *time.Time
  if publishedAt != nil && *publishedAt != "" {
    t, err := time.Parse(time.RFC3339, *publishedAt)
    if err != nil { return nil, err }
    pub = &t
  }
  created, err := r.Posts.Create(domain.Post{
    Title:       title,
    Body:        body,
    PublishedAt: pub,
    CreatedAt:   now,
    UpdatedAt:   now,
  })
  if err != nil { return nil, err }
  return &model.Post{
    ID: created.ID,
    Title: created.Title,
    Body: created.Body,
    CreatedAt: created.CreatedAt.Format(time.RFC3339),
    UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
    PublishedAt: toPtrIfNotNil(created.PublishedAt),
  }, nil
}
func (r *mutationResolver) UpdatePost(ctx context.Context, id string, title *string, body *string, publishedAt *string) (*model.Post, error) {
  if !isAuthorized(ctx) { return nil, ErrUnauthorized }
  patch := map[string]any{}
  if title != nil { patch["title"] = *title }
  if body != nil { patch["body"] = *body }
  if publishedAt != nil { patch["publishedAt"] = *publishedAt }
  patch["updatedAt"] = time.Now().UTC()
  updated, err := r.Posts.Update(id, patch)
  if err != nil { return nil, err }
  return &model.Post{
    ID: updated.ID,
    Title: updated.Title,
    Body: updated.Body,
    CreatedAt: updated.CreatedAt.Format(time.RFC3339),
    UpdatedAt: updated.UpdatedAt.Format(time.RFC3339),
    PublishedAt: toPtrIfNotNil(updated.PublishedAt),
  }, nil
}
func (r *mutationResolver) DeletePost(ctx context.Context, id string) (bool, error) {
  if !isAuthorized(ctx) { return false, ErrUnauthorized }
  return r.Posts.Delete(id)
}
// Helpers
func max(a, b int) int { if a > b { return a }; return b }
func toPtrIfNotNil(t *time.Time) *string {
  if t == nil { return nil }
  s := t.UTC().Format(time.RFC3339)
  return &s
}Server HTTP e CORS su /graphql
Si pubblica l’endpoint con net/http. Il middleware CORS deve consentire il preflight OPTIONS e includere eventuali header di autorizzazione. Evitare wildcard con credenziali attive e preferire una lista di origin ammessi.
package main
import (
  "log"
  "net/http"
  "os"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/rs/cors"
"example.com/postapi/graph"
"example.com/postapi/graph/generated"
"example.com/postapi/inmemory"
)
func main() {
  // Repo di esempio; sostituisci con DB reale
  repo := inmemory.NewPostRepo()
  srv := handler.NewDefaultServer(generated.NewExecutableSchema(
    generated.Config{Resolvers: \&graph.Resolver{Posts: repo}},
  ))
  mux := http.NewServeMux()
  mux.Handle("/graphql", srv)
  mux.Handle("/", playground.Handler("GraphQL", "/graphql"))
  // CORS: elenca gli origin consentiti; abilita credentials se usi cookie
  corsMw := cors.New(cors.Options{
    AllowedOrigins:   []string{"[http://localhost:5173](http://localhost:5173)", "[https://  app.example.com"}](https://app.example.com}),
    AllowedMethods:   []string{"GET", "POST", "OPTIONS"},
    AllowedHeaders:   []string{"content-type", "authorization"},
    AllowCredentials: true,
    MaxAge:           600,
  })
  addr := ":4000"
  if fromEnv := os.Getenv("PORT"); fromEnv != "" { addr = ":" + fromEnv }
    log.Printf("GraphQL su [http://localhost%s/graphql](http://localhost%s/graphql)", addr)
    if err := http.ListenAndServe(addr, corsMw.Handler(mux)); err != nil {
      log.Fatal(err)
    }
}Validazione e autorizzazione
La validazione degli input può essere fatta in modo leggero con funzioni dedicate o integrando librerie come validator. L’autorizzazione, in un progetto reale, si effettua montando un middleware HTTP che risolve l’identità (token, cookie) e inserisce l’utente nel context. I resolver leggeranno dal context per consentire o negare le operazioni di scrittura.
package graph
import (
  "context"
  "errors"
  "time"
)
var ErrUnauthorized = errors.New("unauthorized")
func isAuthorized(ctx context.Context) bool {
  // Esempio: recupera un utente dal context (settato dal middleware)
  _, ok := ctx.Value(userKey{}).(*User)
    return ok
}
func validateCreate(title, body string, publishedAt *string) error {
  if len(title) == 0 || len(title) > 255 { return errors.New("invalid title") }
  if len(body) == 0 { return errors.New("invalid body") }
  if publishedAt != nil && *publishedAt != "" {
    if _, err := time.Parse(time.RFC3339, *publishedAt); err != nil {
      return errors.New("invalid publishedAt")
    }
  }
  return nil
}
type userKey struct{}
type User struct{ ID string }Esempi dal punto di vista del client
Le query richiedono solo i campi necessari; le mutation restituiscono l’oggetto aggiornato o un boolean nel caso di eliminazione. Una mutation che ritorna Boolean! non ammette una subselection.
query {
  posts(page: 1, perPage: 5, sort: "desc") {
    data { id title publishedAt }
    paginatorInfo { currentPage lastPage perPage total hasMorePages count }
  }
}
query {
post(id: "123") { id title body publishedAt }
}
mutation {
createPost(title: "Titolo", body: "Testo", publishedAt: "2025-01-01T00:00:00Z") {
id title publishedAt
}
}
mutation {
updatePost(id: "123", title: "Nuovo titolo") { id title updatedAt }
}
mutation {
deletePost(id: "123")
}Paginazione e ordinamento: note progettuali
La paginazione “page-based” è semplice e adatta a molti casi d’uso; per flussi continui o dataset molto dinamici è possibile adottare una paginazione a cursori, usando un cursore opaco derivato da publishedAt e id. È consigliabile creare un indice su publishedAt (e sull’ID) per mantenere bassa la latenza delle query di elenco.
Performance e affidabilità
- Selettività dei campi: far sì che i resolver mappino le selezioni GraphQL su query dati che leggono solo le colonne necessarie.
- Limiti: imporre soglie su perPagee, se serve, usare plugin/estensioni per limitare complessità e profondità delle query.
- Error handling: restituire errori prevedibili per validazione e autorizzazione; loggare quelli inattesi lato server.
- Test: coprire i resolver con test che includano casi di ordinamento, paginazione, validazione e accesso negato.
Conclusione
Con gqlgen e pochi componenti mirati si ottiene rapidamente un’API GraphQL in Go incentrata su Post: schema chiaro, resolver tipizzati, paginazione e ordinamento prevedibili, validazione e autorizzazione esplicite, CORS configurato per l’uso reale. Questa base, per la sua semplicità e coerenza, è un ottimo punto di partenza per aggiungere nuovi modelli e funzionalità mantenendo un disegno pulito e sostenibile.