Multi-Tenant - 02 - Exemplo em golang

Perfeito, vamos pro código 👇

Vou assumir:

Sinta-se à vontade pra ajustar nomes/paths.


1. domain.go (usuários, grupos, usuários_grupos)

// 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"`
  Emailstring `db:"email" json:"email"`
  SenhaHashstring `db:"senha_hash" json:"-"`
  Ativobool   `db:"ativo" json:"ativo"`
  UltimoLogin  *time.Time `db:"ultimo_login_em" json:"ultimo_login_em,omitempty"`
  CreatedAttime.Time  `db:"created_at" json:"created_at"`
  UpdatedAttime.Time  `db:"updated_at" json:"updated_at"`
}

// Grupo/role de acesso
type Grupo struct {
  IDint64 `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 {
  IDint64 `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"`
}

// View de conveniência: usuário + lista de grupos
type UsuarioComGrupos struct {
  Usuario Usuario  `json:"usuario"`
  Grupos  []Grupo  `json:"grupos"`
}

2. Repositório de Usuários (repository_usuarios.go)

// internal/identity/repository_usuarios.go
package identity

import (
  "context"
  "database/sql"

  "github.com/jmoiron/sqlx"
)

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)
  GetByEmail(ctx context.Context, tenantID int64, email string) (*Usuario, error)
  ListByTenant(ctx context.Context, tenantID int64, limit, offset int) ([]Usuario, error)
  SoftDelete(ctx context.Context, tenantID, id int64) error
}

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) GetByEmail(ctx context.Context, tenantID int64, email string) (*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 lower(email) = lower($2)
  `
  err := r.db.GetContext(ctx, &u, query, tenantID, email)
  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: atualizar último login
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
}

3. Repositório de Grupos (repository_grupos.go)

// internal/identity/repository_grupos.go
package identity

import (
  "context"

  "github.com/jmoiron/sqlx"
)

type GruposRepository interface {
  Create(ctx context.Context, g *Grupo) error
  Update(ctx context.Context, g *Grupo) error
  GetByID(ctx context.Context, tenantID, id int64) (*Grupo, error)
  GetByNome(ctx context.Context, tenantID int64, nome string) (*Grupo, error)
  ListByTenant(ctx context.Context, tenantID int64) ([]Grupo, error)
}

type gruposRepository struct {
  db *sqlx.DB
}

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

func (r *gruposRepository) Create(ctx context.Context, g *Grupo) error {
  query := `
INSERT INTO public.grupos (
  tenant_id, nome, descricao, ativo
) VALUES (
  :tenant_id, :nome, :descricao, :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, g).Scan(&g.ID, &g.CreatedAt, &g.UpdatedAt)
}

func (r *gruposRepository) Update(ctx context.Context, g *Grupo) error {
  query := `
UPDATE public.grupos
   SET nome = :nome,
   descricao = :descricao,
   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, g).Scan(&g.UpdatedAt)
}

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
}

func (r *gruposRepository) GetByNome(ctx context.Context, tenantID int64, nome string) (*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 lower(nome) = lower($2)
  `
  err := r.db.GetContext(ctx, &g, query, tenantID, nome)
  if err != nil {
return nil, err
  }
  return &g, nil
}

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
}

4. Repositório Usuários ↔ Grupos (repository_usuarios_grupos.go)

// internal/identity/repository_usuarios_grupos.go
package identity

import (
  "context"

  "github.com/jmoiron/sqlx"
)

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)
  ListarUsuariosDoGrupo(ctx context.Context, tenantID, grupoID int64) ([]Usuario, error)
}

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
}

func (r *usuariosGruposRepository) ListarUsuariosDoGrupo(ctx context.Context, tenantID, grupoID int64) ([]Usuario, error) {
  usuarios := []Usuario{}
  query := `
SELECT u.id, u.tenant_id, u.nome, u.email, u.senha_hash, u.ativo,
   u.ultimo_login_em, u.created_at, u.updated_at
  FROM public.usuarios u
  JOIN public.usuarios_grupos ug
ON ug.usuario_id = u.id
   AND ug.tenant_id = u.tenant_id
 WHERE ug.tenant_id = $1
   AND ug.grupo_id = $2
 ORDER BY u.nome;
  `
  err := r.db.SelectContext(ctx, &usuarios, query, tenantID, grupoID)
  if err != nil {
return nil, err
  }
  return usuarios, nil
}

5. Exemplo de uso no service (só um cheirinho)

// internal/identity/service.go
package identity

import "context"

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) ListarUsuarioComGrupos(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
}

Se quiser, no próximo passo a gente encaixa isso no padrão de arquitetura hexagonal que você já está usando (ex: domain.go, repository_sqlx.go, handler_http.go) com um exemplo de handler /tenants/{tenant_id}/usuarios.