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:
- Contexto de negócio:
identity(usuários, grupos, usuários_grupos) - Camadas:
domain,repository_sqlx,service,handler_http - Router:
chi - Multi-tenant:
tenant_idvindo da rota:/tenants/{tenantID}/...
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)
}
}