Padrões de serviço

O que todo serviço desta plataforma implementa igual.

Service Patterns - Asender (Go)

Padroes comuns pra todos os servicos Go. Cada servico segue este layout.

Layout de Diretorio (cada servico Go)

asender-<service>/
├── cmd/
│   └── server/
│       └── main.go              # Entry point
├── internal/
│   ├── config/
│   │   └── config.go            # env vars via envconfig
│   ├── db/
│   │   ├── db.go                # pgx pool factory
│   │   ├── migrations/          # SQL files numbered
│   │   │   └── 001_init.up.sql
│   │   │   └── 001_init.down.sql
│   │   └── queries/             # sqlc .sql files (ou query methods)
│   ├── http/
│   │   ├── server.go            # chi router + middleware
│   │   ├── handlers/            # per-resource handlers
│   │   └── middleware/          # local middleware (auth, tenant, etc)
│   ├── service/                 # business logic, framework-agnostic
│   │   └── ...
│   └── repo/                    # DB access layer (wraps sqlc queries)
│       └── ...
├── migrations/                  # (alt location - some prefer root)
├── Dockerfile
├── Makefile
├── go.mod
├── go.sum
├── .env.example
└── README.md

Dependencias Padrao (go.mod)

require (
    github.com/go-chi/chi/v5 v5.1.0
    github.com/jackc/pgx/v5 v5.7.1
    github.com/kelseyhightower/envconfig v1.4.0
    github.com/golang-jwt/jwt/v5 v5.2.1
    github.com/nats-io/nats.go v1.37.0
    github.com/redis/go-redis/v9 v9.6.1
    github.com/rs/xid v1.6.0
    golang.org/x/crypto v0.27.0
)

(Cada servico inclui so o que usa.)

config.go (padrao)

package config

import "github.com/kelseyhightower/envconfig"

type Config struct {
    Env             string `envconfig:"ENV" default:"development"`
    HTTPPort        int    `envconfig:"HTTP_PORT" default:"8080"`
    LogLevel        string `envconfig:"LOG_LEVEL" default:"info"`

    DatabaseURL     string `envconfig:"DATABASE_URL" required:"true"`
    RedisURL        string `envconfig:"REDIS_URL" default:"redis://localhost:6379"`
    NatsURL         string `envconfig:"NATS_URL" default:"nats://localhost:4222"`

    ServiceSecret   string `envconfig:"SERVICE_SECRET" required:"true"`
    JWTSecret       string `envconfig:"JWT_SECRET" required:"true"`

    AuthServiceURL  string `envconfig:"AUTH_SERVICE_URL" default:"http://asender-auth:8080"`
    CoreServiceURL  string `envconfig:"CORE_SERVICE_URL" default:"http://asender-core:8080"`
}

func Load() (*Config, error) {
    var c Config
    err := envconfig.Process("", &c)
    return &c, err
}

main.go (padrao)

package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "asender-<service>/internal/config"
    "asender-<service>/internal/http/server"
)

func main() {
    cfg, err := config.Load()
    if err != nil {
        slog.Error("config load failed", "err", err)
        os.Exit(1)
    }

    logger := newLogger(cfg.LogLevel)
    slog.SetDefault(logger)

    srv, err := server.New(cfg, logger)
    if err != nil {
        slog.Error("server init failed", "err", err)
        os.Exit(1)
    }

    httpSrv := &http.Server{
        Addr:              fmt.Sprintf(":%d", cfg.HTTPPort),
        Handler:           srv.Router(),
        ReadHeaderTimeout: 10 * time.Second,
    }

    // Graceful shutdown
    go func() {
        slog.Info("server listening", "port", cfg.HTTPPort)
        if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            slog.Error("http listen", "err", err)
            os.Exit(1)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    slog.Info("shutting down")
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    if err := httpSrv.Shutdown(ctx); err != nil {
        slog.Error("shutdown", "err", err)
    }
}

HTTP Server (padrao)

package server

import (
    "net/http"
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

type Server struct {
    cfg    *config.Config
    router *chi.Mux
    // ...deps
}

func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
    r := chi.NewRouter()
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)
    r.Use(loggingMiddleware(logger))
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(30 * time.Second))

    s := &Server{cfg: cfg, router: r}
    s.routes()
    return s, nil
}

func (s *Server) Router() http.Handler {
    return s.router
}

func (s *Server) routes() {
    s.router.Get("/healthz", s.handleHealth)
    s.router.Get("/readyz", s.handleReady)
    s.router.Get("/metrics", s.handleMetrics)

    s.router.Route("/v1", func(r chi.Router) {
        r.Use(s.authMiddleware)
        // ...
    })
}

Error Response Format (SES-like)

package httpx

type ErrorBody struct {
    Error struct {
        Type    string `json:"Type"`    // "Sender" or "Receiver"
        Code    string `json:"Code"`
        Message string `json:"Message"`
        Details any    `json:"Details,omitempty"`
    } `json:"Error"`
    RequestId string `json:"RequestId"`
}

func WriteError(w http.ResponseWriter, r *http.Request, status int, code, msg string) {
    typ := "Sender"
    if status >= 500 {
        typ = "Receiver"
    }
    body := ErrorBody{RequestId: middleware.GetReqID(r.Context())}
    body.Error.Type = typ
    body.Error.Code = code
    body.Error.Message = msg
    writeJSON(w, status, body)
}

func WriteSuccess(w http.ResponseWriter, r *http.Request, status int, data map[string]any) {
    data["RequestId"] = middleware.GetReqID(r.Context())
    writeJSON(w, status, data)
}

Logging

Use log/slog (stdlib, Go 1.21+). JSON em producao, text em dev.

func newLogger(level string) *slog.Logger {
    var lvl slog.Level
    _ = lvl.UnmarshalText([]byte(level))
    h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
    return slog.New(h)
}

Sempre incluir request_id, tenant_id nos logs de request.

ID Generation

package ids

import (
    "crypto/rand"
    "encoding/hex"
)

func New(prefix string) string {
    b := make([]byte, 8)
    _, _ = rand.Read(b)
    return prefix + "_" + hex.EncodeToString(b)
}

Graceful Shutdown

Todo servico:

Testing

Dockerfile Multi-stage

FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

Makefile

.PHONY: run test migrate build

run:
	go run ./cmd/server

test:
	go test ./... -race

migrate-up:
	migrate -path ./internal/db/migrations -database "$(DATABASE_URL)" up

migrate-down:
	migrate -path ./internal/db/migrations -database "$(DATABASE_URL)" down 1

build:
	CGO_ENABLED=0 go build -o bin/server ./cmd/server

docker:
	docker build -t asender-<service> .

Endpoints comuns a todos os servicos

Service-to-Service Auth

Shared secret JWT.

// asender-shared/auth/svctoken.go
type SvcClaims struct {
    Svc string `json:"svc"`
    jwt.RegisteredClaims
}

func SignSvcToken(secret []byte, svc string) (string, error) {
    claims := SvcClaims{
        Svc: svc,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }
    tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return tok.SignedString(secret)
}

func VerifySvcToken(secret []byte, tokenStr string) (*SvcClaims, error) {
    var claims SvcClaims
    _, err := jwt.ParseWithClaims(tokenStr, &claims, func(_ *jwt.Token) (any, error) {
        return secret, nil
    })
    return &claims, err
}

Requests internas setam X-Asender-Svc-Token: <jwt>. Middleware valida.

Frontend Patterns (Next.js)

Estrutura:

asender-<frontend>/
├── app/
│   ├── (auth)/
│   │   ├── login/
│   │   └── register/
│   ├── (app)/
│   │   └── dashboard/
│   ├── api/           # Route handlers (proxy)
│   └── layout.tsx
├── components/
│   └── ui/            # shadcn
├── lib/
│   ├── api.ts         # typed client
│   └── auth.ts
├── public/
├── package.json
├── tsconfig.json
├── tailwind.config.ts
└── next.config.ts