Structs em Go: modelagem, tags e boas práticas de inicialização

Capítulo 7

Tempo estimado de leitura: 8 minutos

+ Exercício

O que é uma struct e quando usar

Uma struct é um tipo composto que agrupa campos (atributos) com nomes e tipos. Use structs para modelar entidades do seu domínio (por exemplo, Usuario, Pedido) e para transportar dados entre camadas do código (entrada/saída, persistência, APIs).

Em Go, structs são valores: ao atribuir uma struct para outra variável, você copia seus campos. Isso influencia como você inicializa, passa para funções e decide entre usar valor ou ponteiro.

Definindo structs: campos, tipos e composição

package dominio

type Usuario struct {
	ID    int
	Nome  string
	Email string
}

Campos podem ser de qualquer tipo, inclusive outras structs. Uma forma comum de reutilização é a composição (embutir um tipo como campo anônimo), que também afeta como os campos são promovidos (acessados diretamente).

type Endereco struct {
	Rua    string
	Cidade string
}

type Usuario struct {
	ID       int
	Nome     string
	Email    string
	Endereco Endereco
}

Visibilidade: exportado vs não exportado (impacto em APIs de pacote)

Em Go, a visibilidade é definida pela primeira letra do identificador:

  • Exportado: começa com letra maiúscula (visível fora do pacote). Ex.: Usuario, Email.
  • Não exportado: começa com letra minúscula (visível apenas dentro do pacote). Ex.: senhaHash, validar().

Isso afeta diretamente o design de APIs: se você expõe uma struct para outros pacotes, campos exportados podem ser lidos/alterados diretamente por quem usa seu pacote. Campos não exportados forçam o uso de construtores e métodos, permitindo invariantes e validações.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

Exemplo: protegendo invariantes com campos não exportados

package dominio

import "errors"

type Usuario struct {
	ID        int
	Nome      string
	Email     string
	senhaHash string
}

func NovoUsuario(nome, email, senhaHash string) (Usuario, error) {
	if nome == "" {
		return Usuario{}, errors.New("nome obrigatorio")
	}
	if email == "" {
		return Usuario{}, errors.New("email obrigatorio")
	}
	if senhaHash == "" {
		return Usuario{}, errors.New("senhaHash obrigatorio")
	}
	return Usuario{Nome: nome, Email: email, senhaHash: senhaHash}, nil
}

func (u Usuario) SenhaHash() string {
	return u.senhaHash
}

Note que quem está fora do pacote consegue ler SenhaHash(), mas não consegue alterar senhaHash diretamente.

Zero values: o estado inicial “padrão”

Quando você declara uma struct sem inicializar explicitamente, cada campo recebe seu zero value:

  • int0
  • string""
  • boolfalse
  • ponteiros, slices, maps, interfaces, funcs → nil
  • struct → todos os campos em zero value
var u Usuario
// u.ID == 0
// u.Nome == ""
// u.Email == ""

O zero value é útil para permitir que tipos tenham um estado inicial válido. Quando isso não for possível (por exemplo, um Pedido que precisa de itens), prefira construtores que garantam invariantes.

Literais de struct: nomeados vs posicionais

Literal nomeado (recomendado)

Você informa explicitamente quais campos está preenchendo. É mais legível e resistente a mudanças na ordem dos campos.

u := Usuario{
	ID:    10,
	Nome:  "Ana",
	Email: "ana@exemplo.com",
}

Também permite inicialização parcial (os demais campos ficam com zero value):

u := Usuario{Nome: "Ana"}

Literal posicional (use com cuidado)

Você fornece valores na ordem dos campos. Se a struct mudar (ex.: inserir um campo no meio), esse código pode quebrar ou, pior, compilar e ficar incorreto.

u := Usuario{10, "Ana", "ana@exemplo.com"}

Boas situações para literal posicional: structs pequenas, internas ao pacote, com poucos campos e baixa chance de mudança.

Padrões de inicialização: valor, ponteiro e construtores

Inicialização por valor

Boa para structs pequenas e imutáveis por convenção (você cria e não altera muito). Ao passar para funções, você copia.

func BoasVindas(u Usuario) string {
	return "Ola, " + u.Nome
}

Inicialização com ponteiro

Útil quando você quer evitar cópias, precisa modificar o objeto em métodos, ou quer representar “ausência” com nil.

u := &Usuario{Nome: "Ana"}

func (u *Usuario) AtualizarEmail(email string) {
	u.Email = email
}

Construtores (funções do tipo New/Novo)

Construtores ajudam a:

  • validar dados na criação
  • preencher defaults (valores padrão)
  • manter campos não exportados
type Pedido struct {
	ID     int
	Status string
	Itens  []ItemPedido
}

type ItemPedido struct {
	SKU        string
	Quantidade int
	PrecoCent  int
}

func NovoPedido(id int) Pedido {
	return Pedido{
		ID:     id,
		Status: "criado",
		Itens:  []ItemPedido{},
	}
}

Repare no padrão de inicializar Itens como slice vazia ([]ItemPedido{}) em vez de nil quando você quer garantir que iterações e serialização tenham um comportamento consistente.

Tags de struct: foco em JSON

Tags são metadados associados a campos, muito usados por bibliotecas de serialização. No pacote padrão encoding/json, a tag json controla o nome do campo no JSON e opções como omitempty.

type Usuario struct {
	ID    int    `json:"id"`
	Nome  string `json:"nome"`
	Email string `json:"email"`
}

Passo a passo: serializando e desserializando

package main

import (
	"encoding/json"
	"fmt"
)

type Usuario struct {
	ID    int    `json:"id"`
	Nome  string `json:"nome"`
	Email string `json:"email"`
}

func main() {
	u := Usuario{ID: 1, Nome: "Ana", Email: "ana@exemplo.com"}

	b, err := json.Marshal(u)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(b))

	var u2 Usuario
	if err := json.Unmarshal(b, &u2); err != nil {
		panic(err)
	}
	fmt.Printf("%+v\n", u2)
}

Cuidado com omitempty

omitempty faz o campo ser omitido do JSON quando estiver no zero value. Isso é útil para reduzir payload, mas pode ser perigoso quando o consumidor precisa distinguir “campo ausente” de “campo presente com valor zero”.

type AtualizacaoUsuario struct {
	Nome  string `json:"nome,omitempty"`
	Email string `json:"email,omitempty"`
}

Problema comum: você quer permitir atualizar Quantidade para 0, mas 0 é zero value e o campo some do JSON com omitempty. Solução típica: usar ponteiros para representar “não informado”.

type PatchItem struct {
	Quantidade *int `json:"quantidade,omitempty"`
}

Assim, nil significa “não veio”, e 0 (ponteiro para 0) significa “veio e é 0”.

Campos anônimos (embedded) e JSON

Campos anônimos promovem campos do tipo embutido. Em JSON, isso pode “achatar” a estrutura dependendo das tags e do tipo embutido.

type Auditoria struct {
	CriadoEm string `json:"criado_em"`
}

type Pedido struct {
	ID int `json:"id"`
	Auditoria
}

Ao serializar, criado_em aparece no mesmo nível de id. Isso pode ser desejável, mas exige cuidado para evitar colisão de nomes e para manter clareza do contrato JSON.

Boas práticas ao modelar structs

  • Prefira literais nomeados em código de aplicação e em APIs públicas.
  • Exponha o mínimo necessário: campos não exportados + construtores/métodos para manter invariantes.
  • Defina defaults explicitamente quando o zero value não representa um estado válido.
  • Use ponteiros em patches (atualizações parciais) para diferenciar “ausente” de “zero”.
  • Evite omitempty em campos onde o zero value é um valor significativo para o consumidor.
  • Se usar campos anônimos, verifique colisões e clareza do JSON resultante.

Exercício prático: entidades Usuario e Pedido com validações simples

Objetivo

Criar duas entidades (Usuario e Pedido), com:

  • construtores que validam dados obrigatórios
  • métodos de validação simples
  • tags JSON
  • um exemplo de serialização

Regras

  • Usuario: Nome e Email obrigatórios; Email deve conter @.
  • Pedido: deve ter ao menos 1 item; cada item deve ter SKU não vazio, Quantidade > 0 e PrecoCent > 0.

Implementação sugerida (passo a passo)

1) Defina as structs com tags JSON

package dominio

type Usuario struct {
	ID    int    `json:"id"`
	Nome  string `json:"nome"`
	Email string `json:"email"`
}

type ItemPedido struct {
	SKU        string `json:"sku"`
	Quantidade int    `json:"quantidade"`
	PrecoCent  int    `json:"preco_cent"`
}

type Pedido struct {
	ID    int          `json:"id"`
	User  Usuario      `json:"usuario"`
	Itens []ItemPedido `json:"itens"`
}

2) Crie funções de validação

package dominio

import (
	"errors"
	"strings"
)

func (u Usuario) Validar() error {
	if strings.TrimSpace(u.Nome) == "" {
		return errors.New("nome obrigatorio")
	}
	if strings.TrimSpace(u.Email) == "" {
		return errors.New("email obrigatorio")
	}
	if !strings.Contains(u.Email, "@") {
		return errors.New("email invalido")
	}
	return nil
}

func (p Pedido) Validar() error {
	if err := p.User.Validar(); err != nil {
		return err
	}
	if len(p.Itens) == 0 {
		return errors.New("pedido deve ter ao menos 1 item")
	}
	for i, it := range p.Itens {
		if strings.TrimSpace(it.SKU) == "" {
			return errors.New("item " + string(rune('0'+i)) + ": sku obrigatorio")
		}
		if it.Quantidade <= 0 {
			return errors.New("quantidade deve ser maior que zero")
		}
		if it.PrecoCent <= 0 {
			return errors.New("preco_cent deve ser maior que zero")
		}
	}
	return nil
}

Observação: para mensagens de erro por índice, você pode preferir fmt.Sprintf para formatar com clareza.

3) Crie construtores que aplicam defaults e validam

package dominio

func NovoUsuario(id int, nome, email string) (Usuario, error) {
	u := Usuario{ID: id, Nome: nome, Email: email}
	if err := u.Validar(); err != nil {
		return Usuario{}, err
	}
	return u, nil
}

func NovoPedido(id int, user Usuario, itens []ItemPedido) (Pedido, error) {
	p := Pedido{ID: id, User: user, Itens: itens}
	if err := p.Validar(); err != nil {
		return Pedido{}, err
	}
	return p, nil
}

4) Use as entidades e serialize para JSON

package main

import (
	"encoding/json"
	"fmt"
	"log"

	"seu_modulo/dominio"
)

func main() {
	u, err := dominio.NovoUsuario(1, "Ana", "ana@exemplo.com")
	if err != nil {
		log.Fatal(err)
	}

	itens := []dominio.ItemPedido{
		{SKU: "ABC-123", Quantidade: 2, PrecoCent: 1990},
		{SKU: "XYZ-999", Quantidade: 1, PrecoCent: 5000},
	}

	p, err := dominio.NovoPedido(100, u, itens)
	if err != nil {
		log.Fatal(err)
	}

	b, err := json.MarshalIndent(p, "", "  ")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(b))
}

Desafios extras

  • Adicionar Status ao Pedido com default "criado" no construtor e restringir valores válidos (ex.: criado, pago, enviado).
  • Criar um tipo PatchUsuario com ponteiros e omitempty para simular atualização parcial via JSON.
  • Transformar Pedido.User em um campo não exportado e expor apenas métodos para leitura, simulando uma API mais restrita.

Agora responda o exercício sobre o conteúdo:

Em um endpoint de atualização parcial (patch) via JSON, como diferenciar “campo não enviado” de “campo enviado com valor zero” para evitar problemas com omitempty?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Com omitempty, zero values podem ser omitidos. Em structs de patch, ponteiros permitem distinguir: nil indica ausência do campo no JSON; um ponteiro para 0 ou "" indica que o campo foi enviado com valor zero.

Próximo capitúlo

Funções em Go: múltiplos retornos, variádicas, closures e defer

Arrow Right Icon
Capa do Ebook gratuito Go (Golang) para Iniciantes: Fundamentos, Concorrência e Estrutura de Projetos
39%

Go (Golang) para Iniciantes: Fundamentos, Concorrência e Estrutura de Projetos

Novo curso

18 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.