Multi-Tenant - 03 - Exemplo em golang com Arquitetura Hexagonal

Bora fechar o ciclo com a cara de hexagonal que você curte 😄

Vou montar um exemplo completo, mas enxuto, usando:

Obs.: vou focar no CRUD de usuários + vincular grupos, mas o padrão serve pro resto.


1. Estrutura de pastas sugerida

internal/
  identity/
    domain.go
    repository_sqlx.go
    service.go
    handler_http.go
pkg/
  httpcontext/
    tenant.go
cmd/
  api/
    main.go

2. domain.go (contexto identity)

// internal/identity/domain.go
package identity

import "time"

// Usuário do sistema (multi-tenant)
type Usuario struct {
	ID           int64      `db:"id" json:"id"`
	TenantID     int64      `db:"tenant_id" json:"tenant_id"`
	Nome         string     `db:"nome" json:"nome"`
	Email        string     `db:"email" json:"email"`
	SenhaHash    string     `db:"senha_hash" json:"-"`
	Ativo        bool       `db:"ativo" json:"ativo"`
	UltimoLogin  *time.Time `db:"ultimo_login_em" json:"ultimo_login_em,omitempty"`
	CreatedAt    time.Time  `db:"created_at" json:"created_at"`
	UpdatedAt    time.Time  `db:"updated_at" json:"updated_at"`
}

// Grupo/role de acesso
type Grupo struct {
	ID        int64     `db:"id" json:"id"`
	TenantID  int64     `db:"tenant_id" json:"tenant_id"`
	Nome      string    `db:"nome" json:"nome"`
	Descricao *string   `db:"descricao" json:"descricao,omitempty"`
	Ativo     bool      `db:"ativo" json:"ativo"`
	CreatedAt time.Time `db:"created_at" json:"created_at"`
	UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

// Junção usuário x grupo
type UsuarioGrupo struct {
	ID        int64     `db:"id" json:"id"`
	TenantID  int64     `db:"tenant_id" json:"tenant_id"`
	UsuarioID int64     `db:"usuario_id" json:"usuario_id"`
	GrupoID   int64     `db:"grupo_id" json:"grupo_id"`
	CreatedAt time.Time `db:"created_at" json:"created_at"`
}

type UsuarioComGrupos struct {
	Usuario Usuario `json:"usuario"`
	Grupos  []Grupo `json:"grupos"`
}

3. repository_sqlx.go

Agrupando os três repositórios num arquivo (pode separar depois se quiser).

// internal/identity/repository_sqlx.go
package identity

import (
	"context"
	"database/sql"

	"github.com/jmoiron/sqlx"
)

// ---------- Interfaces ----------

type UsuariosRepository interface {
	Create(ctx context.Context, u *Usuario) error
	Update(ctx context.Context, u *Usuario) error
	GetByID(ctx context.Context, tenantID, id int64) (*Usuario, error)
	ListByTenant(ctx context.Context, tenantID int64, limit, offset int) ([]Usuario, error)
	SoftDelete(ctx context.Context, tenantID, id int64) error
}

type GruposRepository interface {
	ListByTenant(ctx context.Context, tenantID int64) ([]Grupo, error)
	GetByID(ctx context.Context, tenantID, id int64) (*Grupo, error)
}

type UsuariosGruposRepository interface {
	AdicionarGrupoAoUsuario(ctx context.Context, tenantID, usuarioID, grupoID int64) error
	RemoverGrupoDoUsuario(ctx context.Context, tenantID, usuarioID, grupoID int64) error
	ListarGruposDoUsuario(ctx context.Context, tenantID, usuarioID int64) ([]Grupo, error)
}

// ---------- Implementações ----------

type usuariosRepository struct {
	db *sqlx.DB
}

func NewUsuariosRepository(db *sqlx.DB) UsuariosRepository {
	return &usuariosRepository{db: db}
}

func (r *usuariosRepository) Create(ctx context.Context, u *Usuario) error {
	query := `
		INSERT INTO public.usuarios (
			tenant_id, nome, email, senha_hash, ativo
		) VALUES (
			:tenant_id, :nome, :email, :senha_hash, :ativo
		)
		RETURNING id, created_at, updated_at
	`
	stmt, err := r.db.PrepareNamedContext(ctx, query)
	if err != nil {
		return err
	}
	defer stmt.Close()

	return stmt.QueryRowxContext(ctx, u).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
}

func (r *usuariosRepository) Update(ctx context.Context, u *Usuario) error {
	query := `
		UPDATE public.usuarios
		   SET nome = :nome,
		       email = :email,
		       ativo = :ativo
		 WHERE tenant_id = :tenant_id
		   AND id = :id
		RETURNING updated_at
	`
	stmt, err := r.db.PrepareNamedContext(ctx, query)
	if err != nil {
		return err
	}
	defer stmt.Close()

	return stmt.QueryRowxContext(ctx, u).Scan(&u.UpdatedAt)
}

func (r *usuariosRepository) GetByID(ctx context.Context, tenantID, id int64) (*Usuario, error) {
	var u Usuario
	query := `
		SELECT id, tenant_id, nome, email, senha_hash, ativo,
		       ultimo_login_em, created_at, updated_at
		  FROM public.usuarios
		 WHERE tenant_id = $1
		   AND id = $2
	`
	err := r.db.GetContext(ctx, &u, query, tenantID, id)
	if err != nil {
		return nil, err
	}
	return &u, nil
}

func (r *usuariosRepository) ListByTenant(ctx context.Context, tenantID int64, limit, offset int) ([]Usuario, error) {
	usuarios := []Usuario{}
	query := `
		SELECT id, tenant_id, nome, email, senha_hash, ativo,
		       ultimo_login_em, created_at, updated_at
		  FROM public.usuarios
		 WHERE tenant_id = $1
		 ORDER BY id
		 LIMIT $2 OFFSET $3
	`
	err := r.db.SelectContext(ctx, &usuarios, query, tenantID, limit, offset)
	if err != nil {
		return nil, err
	}
	return usuarios, nil
}

func (r *usuariosRepository) SoftDelete(ctx context.Context, tenantID, id int64) error {
	query := `
		UPDATE public.usuarios
		   SET ativo = FALSE
		 WHERE tenant_id = $1
		   AND id = $2
	`
	_, err := r.db.ExecContext(ctx, query, tenantID, id)
	return err
}

// opcional
func (r *usuariosRepository) UpdateUltimoLogin(ctx context.Context, tenantID, id int64, ts sql.NullTime) error {
	query := `
		UPDATE public.usuarios
		   SET ultimo_login_em = $3
		 WHERE tenant_id = $1
		   AND id = $2
	`
	_, err := r.db.ExecContext(ctx, query, tenantID, id, ts)
	return err
}

// ---------- Grupos ----------

type gruposRepository struct {
	db *sqlx.DB
}

func NewGruposRepository(db *sqlx.DB) GruposRepository {
	return &gruposRepository{db: db}
}

func (r *gruposRepository) ListByTenant(ctx context.Context, tenantID int64) ([]Grupo, error) {
	grupos := []Grupo{}
	query := `
		SELECT id, tenant_id, nome, descricao, ativo,
		       created_at, updated_at
		  FROM public.grupos
		 WHERE tenant_id = $1
		 ORDER BY nome
	`
	err := r.db.SelectContext(ctx, &grupos, query, tenantID)
	if err != nil {
		return nil, err
	}
	return grupos, nil
}

func (r *gruposRepository) GetByID(ctx context.Context, tenantID, id int64) (*Grupo, error) {
	var g Grupo
	query := `
		SELECT id, tenant_id, nome, descricao, ativo,
		       created_at, updated_at
		  FROM public.grupos
		 WHERE tenant_id = $1
		   AND id = $2
	`
	err := r.db.GetContext(ctx, &g, query, tenantID, id)
	if err != nil {
		return nil, err
	}
	return &g, nil
}

// ---------- Usuários x Grupos ----------

type usuariosGruposRepository struct {
	db *sqlx.DB
}

func NewUsuariosGruposRepository(db *sqlx.DB) UsuariosGruposRepository {
	return &usuariosGruposRepository{db: db}
}

func (r *usuariosGruposRepository) AdicionarGrupoAoUsuario(ctx context.Context, tenantID, usuarioID, grupoID int64) error {
	query := `
		INSERT INTO public.usuarios_grupos (
			tenant_id, usuario_id, grupo_id
		) VALUES ($1, $2, $3)
		ON CONFLICT (tenant_id, usuario_id, grupo_id) DO NOTHING
	`
	_, err := r.db.ExecContext(ctx, query, tenantID, usuarioID, grupoID)
	return err
}

func (r *usuariosGruposRepository) RemoverGrupoDoUsuario(ctx context.Context, tenantID, usuarioID, grupoID int64) error {
	query := `
		DELETE FROM public.usuarios_grupos
		 WHERE tenant_id = $1
		   AND usuario_id = $2
		   AND grupo_id = $3
	`
	_, err := r.db.ExecContext(ctx, query, tenantID, usuarioID, grupoID)
	return err
}

func (r *usuariosGruposRepository) ListarGruposDoUsuario(ctx context.Context, tenantID, usuarioID int64) ([]Grupo, error) {
	grupos := []Grupo{}
	query := `
		SELECT g.id, g.tenant_id, g.nome, g.descricao, g.ativo,
		       g.created_at, g.updated_at
		  FROM public.grupos g
		  JOIN public.usuarios_grupos ug
		    ON ug.grupo_id = g.id
		   AND ug.tenant_id = g.tenant_id
		 WHERE ug.tenant_id = $1
		   AND ug.usuario_id = $2
		 ORDER BY g.nome;
	`
	err := r.db.SelectContext(ctx, &grupos, query, tenantID, usuarioID)
	if err != nil {
		return nil, err
	}
	return grupos, nil
}

4. service.go (regras de negócio / orquestração)

// internal/identity/service.go
package identity

import (
	"context"
	"errors"
)

var (
	ErrUsuarioNaoEncontrado = errors.New("usuário não encontrado")
	ErrGrupoNaoEncontrado   = errors.New("grupo não encontrado")
)

type Service struct {
	usuariosRepo       UsuariosRepository
	gruposRepo         GruposRepository
	usuariosGruposRepo UsuariosGruposRepository
}

func NewService(
	uRepo UsuariosRepository,
	gRepo GruposRepository,
	ugRepo UsuariosGruposRepository,
) *Service {
	return &Service{
		usuariosRepo:       uRepo,
		gruposRepo:         gRepo,
		usuariosGruposRepo: ugRepo,
	}
}

func (s *Service) CriarUsuario(ctx context.Context, tenantID int64, nome, email, senhaHash string) (*Usuario, error) {
	u := &Usuario{
		TenantID:  tenantID,
		Nome:      nome,
		Email:     email,
		SenhaHash: senhaHash,
		Ativo:     true,
	}
	if err := s.usuariosRepo.Create(ctx, u); err != nil {
		return nil, err
	}
	return u, nil
}

func (s *Service) AtualizarUsuario(ctx context.Context, tenantID, id int64, nome, email string, ativo bool) (*Usuario, error) {
	u, err := s.usuariosRepo.GetByID(ctx, tenantID, id)
	if err != nil {
		return nil, err
	}
	u.Nome = nome
	u.Email = email
	u.Ativo = ativo

	if err := s.usuariosRepo.Update(ctx, u); err != nil {
		return nil, err
	}
	return u, nil
}

func (s *Service) ListarUsuarios(ctx context.Context, tenantID int64, limit, offset int) ([]Usuario, error) {
	return s.usuariosRepo.ListByTenant(ctx, tenantID, limit, offset)
}

func (s *Service) ObterUsuarioComGrupos(ctx context.Context, tenantID, usuarioID int64) (*UsuarioComGrupos, error) {
	u, err := s.usuariosRepo.GetByID(ctx, tenantID, usuarioID)
	if err != nil {
		return nil, err
	}
	grupos, err := s.usuariosGruposRepo.ListarGruposDoUsuario(ctx, tenantID, usuarioID)
	if err != nil {
		return nil, err
	}
	return &UsuarioComGrupos{
		Usuario: *u,
		Grupos:  grupos,
	}, nil
}

func (s *Service) AtribuirGrupoAoUsuario(ctx context.Context, tenantID, usuarioID, grupoID int64) error {
	// garante existência de usuário e grupo dentro do tenant
	if _, err := s.usuariosRepo.GetByID(ctx, tenantID, usuarioID); err != nil {
		return err
	}
	if _, err := s.gruposRepo.GetByID(ctx, tenantID, grupoID); err != nil {
		return err
	}
	return s.usuariosGruposRepo.AdicionarGrupoAoUsuario(ctx, tenantID, usuarioID, grupoID)
}

func (s *Service) RemoverGrupoDoUsuario(ctx context.Context, tenantID, usuarioID, grupoID int64) error {
	return s.usuariosGruposRepo.RemoverGrupoDoUsuario(ctx, tenantID, usuarioID, grupoID)
}

5. Contexto HTTP: pegar tenant_id da rota

Crio um helper simples em pkg/httpcontext/tenant.go:

// pkg/httpcontext/tenant.go
package httpcontext

import (
	"context"
)

type ctxKey string

const tenantKey ctxKey = "tenant_id"

func WithTenantID(ctx context.Context, tenantID int64) context.Context {
	return context.WithValue(ctx, tenantKey, tenantID)
}

func TenantIDFromContext(ctx context.Context) (int64, bool) {
	v := ctx.Value(tenantKey)
	if v == nil {
		return 0, false
	}
	id, ok := v.(int64)
	return id, ok
}

6. handler_http.go (chi + service)

// internal/identity/handler_http.go
package identity

import (
	"encoding/json"
	"net/http"
	"strconv"

	"fsgo/pkg/httpcontext"

	"github.com/go-chi/chi/v5"
)

type Handler struct {
	svc *Service
}

func NewHandler(svc *Service) *Handler {
	return &Handler{svc: svc}
}

// Monta as rotas desse bounded context
func (h *Handler) Routes(r chi.Router) {
	r.Route("/tenants/{tenantID}", func(r chi.Router) {
		// middlewarezinho para injetar tenantID no contexto
		r.Use(h.tenantMiddleware)

		r.Route("/usuarios", func(r chi.Router) {
			r.Get("/", h.ListarUsuarios)
			r.Post("/", h.CriarUsuario)

			r.Route("/{usuarioID}", func(r chi.Router) {
				r.Get("/", h.ObterUsuarioComGrupos)
				r.Put("/", h.AtualizarUsuario)
				// vincular grupo
				r.Post("/grupos/{grupoID}", h.AtribuirGrupoAoUsuario)
				r.Delete("/grupos/{grupoID}", h.RemoverGrupoDoUsuario)
			})
		})
	})
}

// ------------- Middleware para resolver tenantID -------------

func (h *Handler) tenantMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		tenantStr := chi.URLParam(r, "tenantID")
		tenantID, err := strconv.ParseInt(tenantStr, 10, 64)
		if err != nil || tenantID <= 0 {
			http.Error(w, "tenant_id inválido", http.StatusBadRequest)
			return
		}

		ctx := httpcontext.WithTenantID(r.Context(), tenantID)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// helper para extrair tenantID do contexto
func (h *Handler) getTenantID(r *http.Request) (int64, error) {
	tenantID, ok := httpcontext.TenantIDFromContext(r.Context())
	if !ok {
		return 0, http.ErrNoCookie // só pra ter um erro qualquer, você pode criar o seu
	}
	return tenantID, nil
}

// ------------- Handlers -------------

type criarUsuarioRequest struct {
	Nome      string `json:"nome"`
	Email     string `json:"email"`
	SenhaHash string `json:"senha_hash"` // num mundo real você receberia a senha e faria o hash aqui
}

func (h *Handler) CriarUsuario(w http.ResponseWriter, r *http.Request) {
	tenantID, err := h.getTenantID(r)
	if err != nil {
		http.Error(w, "tenant não encontrado", http.StatusBadRequest)
		return
	}

	var req criarUsuarioRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "json inválido", http.StatusBadRequest)
		return
	}

	u, err := h.svc.CriarUsuario(r.Context(), tenantID, req.Nome, req.Email, req.SenhaHash)
	if err != nil {
		http.Error(w, "erro ao criar usuário: "+err.Error(), http.StatusInternalServerError)
		return
	}

	writeJSON(w, http.StatusCreated, u)
}

func (h *Handler) ListarUsuarios(w http.ResponseWriter, r *http.Request) {
	tenantID, err := h.getTenantID(r)
	if err != nil {
		http.Error(w, "tenant não encontrado", http.StatusBadRequest)
		return
	}

	// pra simplificar: limit e offset fixos
	usuarios, err := h.svc.ListarUsuarios(r.Context(), tenantID, 100, 0)
	if err != nil {
		http.Error(w, "erro ao listar usuários: "+err.Error(), http.StatusInternalServerError)
		return
	}

	writeJSON(w, http.StatusOK, usuarios)
}

func (h *Handler) ObterUsuarioComGrupos(w http.ResponseWriter, r *http.Request) {
	tenantID, err := h.getTenantID(r)
	if err != nil {
		http.Error(w, "tenant não encontrado", http.StatusBadRequest)
		return
	}

	usuarioIDStr := chi.URLParam(r, "usuarioID")
	usuarioID, err := strconv.ParseInt(usuarioIDStr, 10, 64)
	if err != nil {
		http.Error(w, "usuarioID inválido", http.StatusBadRequest)
		return
	}

	resp, err := h.svc.ObterUsuarioComGrupos(r.Context(), tenantID, usuarioID)
	if err != nil {
		http.Error(w, "erro ao buscar usuário: "+err.Error(), http.StatusInternalServerError)
		return
	}

	writeJSON(w, http.StatusOK, resp)
}

type atualizarUsuarioRequest struct {
	Nome  string `json:"nome"`
	Email string `json:"email"`
	Ativo bool   `json:"ativo"`
}

func (h *Handler) AtualizarUsuario(w http.ResponseWriter, r *http.Request) {
	tenantID, err := h.getTenantID(r)
	if err != nil {
		http.Error(w, "tenant não encontrado", http.StatusBadRequest)
		return
	}

	usuarioIDStr := chi.URLParam(r, "usuarioID")
	usuarioID, err := strconv.ParseInt(usuarioIDStr, 10, 64)
	if err != nil {
		http.Error(w, "usuarioID inválido", http.StatusBadRequest)
		return
	}

	var req atualizarUsuarioRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "json inválido", http.StatusBadRequest)
		return
	}

	u, err := h.svc.AtualizarUsuario(r.Context(), tenantID, usuarioID, req.Nome, req.Email, req.Ativo)
	if err != nil {
		http.Error(w, "erro ao atualizar usuário: "+err.Error(), http.StatusInternalServerError)
		return
	}

	writeJSON(w, http.StatusOK, u)
}

func (h *Handler) AtribuirGrupoAoUsuario(w http.ResponseWriter, r *http.Request) {
	tenantID, err := h.getTenantID(r)
	if err != nil {
		http.Error(w, "tenant não encontrado", http.StatusBadRequest)
		return
	}

	usuarioIDStr := chi.URLParam(r, "usuarioID")
	grupoIDStr := chi.URLParam(r, "grupoID")

	usuarioID, err := strconv.ParseInt(usuarioIDStr, 10, 64)
	if err != nil {
		http.Error(w, "usuarioID inválido", http.StatusBadRequest)
		return
	}
	grupoID, err := strconv.ParseInt(grupoIDStr, 10, 64)
	if err != nil {
		http.Error(w, "grupoID inválido", http.StatusBadRequest)
		return
	}

	if err := h.svc.AtribuirGrupoAoUsuario(r.Context(), tenantID, usuarioID, grupoID); err != nil {
		http.Error(w, "erro ao vincular grupo: "+err.Error(), http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) RemoverGrupoDoUsuario(w http.ResponseWriter, r *http.Request) {
	tenantID, err := h.getTenantID(r)
	if err != nil {
		http.Error(w, "tenant não encontrado", http.StatusBadRequest)
		return
	}

	usuarioIDStr := chi.URLParam(r, "usuarioID")
	grupoIDStr := chi.URLParam(r, "grupoID")

	usuarioID, err := strconv.ParseInt(usuarioIDStr, 10, 64)
	if err != nil {
		http.Error(w, "usuarioID inválido", http.StatusBadRequest)
		return
	}
	grupoID, err := strconv.ParseInt(grupoIDStr, 10, 64)
	if err != nil {
		http.Error(w, "grupoID inválido", http.StatusBadRequest)
		return
	}

	if err := h.svc.RemoverGrupoDoUsuario(r.Context(), tenantID, usuarioID, grupoID); err != nil {
		http.Error(w, "erro ao desvincular grupo: "+err.Error(), http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// helper genérico
func writeJSON(w http.ResponseWriter, status int, payload any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(payload)
}

7. main.go (chi + sqlx + wiring)

Só pra fechar o fio da meada:

// cmd/api/main.go
package main

import (
	"log"
	"net/http"
	"os"

	"fsgo/internal/identity"

	"github.com/go-chi/chi/v5"
	"github.com/jmoiron/sqlx"
	_ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
	dsn := os.Getenv("DATABASE_URL")
	if dsn == "" {
		log.Fatal("DATABASE_URL não configurada")
	}

	db, err := sqlx.Open("pgx", dsn)
	if err != nil {
		log.Fatal("erro ao conectar no banco: ", err)
	}
	defer db.Close()

	if err := db.Ping(); err != nil {
		log.Fatal("erro no ping do banco: ", err)
	}

	// wiring
	usuariosRepo := identity.NewUsuariosRepository(db)
	gruposRepo := identity.NewGruposRepository(db)
	usuariosGruposRepo := identity.NewUsuariosGruposRepository(db)
	svc := identity.NewService(usuariosRepo, gruposRepo, usuariosGruposRepo)
	handler := identity.NewHandler(svc)

	r := chi.NewRouter()
	handler.Routes(r)

	addr := ":8080"
	log.Println("API rodando em", addr)
	if err := http.ListenAndServe(addr, r); err != nil {
		log.Fatal(err)
	}
}