Aula 05 - REST III: DTOs, Paginação, Versionamento e Documentação com Swagger
Nesta aula, continuaremos aprimorando nossa API REST com Spring Boot, abordando tópicos como DTOs (Data Transfer Objects), paginação, versionamento de API e documentação automática com Swagger. Vamos garantir que nossa aplicação esteja mais robusta, escalável e fácil de consumir e, para isso, vamos seguir a mesma lógica que já usamos previamente: introduzir os conceitos dessa aula por meio da resolução dos exercícios da aula anterior.
Para tanto, vamos relembrar o que havia sido pedido:
-
O primeiro exercício consiste na implementação de DTOs (Data Transfer Objects), substituindo o uso direto das entidades “Contact” e “Address” por objetos específicos para transporte de dados. Essa alteração evita a exposição desnecessária de informações internas, aumenta a segurança e resolve problemas de serialização cíclica.
-
O segundo se refere à persistência em um banco de dados relacional: em vez de utilizar o banco H2 em memória, a aplicação deve ser configurada para usar um banco real como MySQL ou PostgreSQL, o que exige alterar o arquivo application.properties com as configurações adequadas de conexão, além de adicionar dependências correspondentes no pom.xml.
-
O terceiro propõe a adoção de paginação e ordenação para os endpoints que retornam listas de contatos e endereços, implementando a interface Pageable do Spring Data JPA e permitindo que o cliente especifique parâmetros (exemplo: “page”, “size” e “sort”) para controlar como os resultados são exibidos.
-
Finalmente, o quarto exercício requer a documentação da API com Swagger, adicionando as dependências do springdoc-openapi ao pom.xml, criando uma classe de configuração e anotando os endpoints para que a documentação seja gerada de modo automático e interativo, facilitando o entendimento e o uso da API.
Vamos resolver esses exercícios de forma sequencial e abordar os conceitos envolvidos em cada um, assim como fizemos na aula anterior.
1. Implementando DTOs (Data Transfer Objects)
Vimos anteriormente, na Aula 04, alguns conceitos iniciais sobre DTOs, objetos que representam os dados que realmente precisam ser transmitidos ou recebidos por sua aplicação, sem os detalhes internos de implementação e persistência. Dessa forma, a camada de apresentação fica desacoplada das entidades, reforçando a segurança, evitando ciclos de serialização e tornando a aplicação mais robusta e flexível a mudanças. Ao expor as entidades JPA diretamente na API, podemos incorrer em uma série de inconvenientes que comprometem tanto a segurança quanto a arquitetura do sistema e, por isso, justifica-se o uso de DTOs. Vamos relembrar algumas vantagens de utilizarmos esse padrão:
Em primeiro lugar, há o risco de expor dados sensíveis: nem todos os campos internos de uma entidade devem ser acessíveis ao cliente, mas, ao enviar as entidades sem filtragem, é possível que informações privadas ou confidenciais sejam inadvertidamente reveladas. Apenas enviar objetos inteiros em formato JSON, portanto, pode não ser (e em muitos casos não é) o mais adequado. Com DTOs, escolhemos exatamente quais campos serão incluídos na resposta da API. Isso dá ao desenvolvedor controle total sobre a exposição de dados, evitando vazamentos acidentais de informações.
Outro aspecto é a fragilidade do design: se as entidades JPA forem diretamente utilizadas como formato de resposta e entrada da API, qualquer mudança na estrutura dessas entidades — seja para fins de manutenção, otimização ou adaptação às regras de negócio — pode quebrar as integrações existentes, pois os clientes dependem daquele formato exato. Com DTOs, é possível evoluir a estrutura dos dados de sua aplicação sem afetar o contrato da API. Podemos alterar as entidades livremente e adaptar os DTOs conforme necessário, mantendo compatibilidade com os consumidores da API.
Além disso, DTOs permitem aplicar validações específicas ao contexto da API, como campos obrigatórios apenas na criação (@NotBlank, @Size, etc.). Isso evita poluir as entidades com validações específicas de entrada de dados e permite uma validação contextualizada — útil quando os requisitos de entrada diferem dos requisitos de persistência.
Outro ponto importante a se considerar é que muitas vezes a resposta da API precisa incluir dados formatados, campos computados ou até mesmo combinar informações de múltiplas fontes. Com DTOs, você pode criar estruturas sob medida para a resposta da API, como incluir contadores, nomes concatenados, mensagens personalizadas, flags booleanas calculadas etc.
Em resumo:
- Entidades → representações do banco de dados (persistência)
- DTOs → representações dos dados trafegados na API (camada de apresentação)
Embora exija mais código inicialmente, o uso de DTOs é considerado boas práticas de engenharia de software, especialmente em APIs públicas, e contribui significativamente para a qualidade do projeto ao longo do tempo. Existem ao menos duas formas distintas de implementar DTOs: implementar um DTO para a request e response, ou implementar DTOs distintos para request e response.
Há situações em que o uso de um único DTO tanto para entrada (request) quanto para saída (response) é
aceitável e até recomendável, especialmente em contextos mais simples. Um exemplo são APIs extremamente
simples e estáticas, com poucos endpoints, como um GET /ping
ou um
POST /login
, e que não têm a expectativa de crescimento ou mudanças estruturais — nesse
caso, utilizar um único DTO pode economizar tempo e reduzir complexidade. Da mesma forma, em aplicações
internas ou protótipos, como APIs utilizadas apenas por desenvolvedores em ambientes controlados (por
exemplo, um painel administrativo interno), essa separação pode ser adiada sem comprometer a
manutenibilidade ou segurança. Além disso, em situações em que os dados esperados na requisição são
exatamente os mesmos que serão retornados na resposta — ou seja, quando não há campos sensíveis,
timestamps ou dados internos da aplicação — também é viável utilizar o mesmo DTO para ambos os sentidos,
já que não há riscos de exposição indevida ou inconsistência no contrato da API.
Por outro lado, existem diversos cenários em que não é recomendado utilizar o mesmo DTO para requisição e
resposta. Um desses casos ocorre quando há relacionamentos complexos entre entidades — por exemplo, em
estruturas do tipo Cliente → Pedidos → Produtos —, onde o que é enviado ao servidor pode ser bastante
diferente do que precisa ser retornado ao cliente. Outro caso comum é quando há lógica de negócios
envolvida na resposta, como o cálculo de campos derivados, a exemplo de um campo valorTotal
calculado com base na quantidade
e no preço
. Também é importante separar DTOs
quando a aplicação possui múltiplos tipos de clientes consumindo a mesma API, como administradores,
usuários públicos ou clientes mobile, que exigem visões distintas do mesmo recurso - nesse tipo de
situação o uso de outros padrões, evidentemente, também serão necessários em conjunto com os DTOs. Os
abordaremos futuramente na disciplina. Além disso, em aplicações que exigem maior controle sobre
segurança e auditoria, é necessário evitar a exposição de dados sensíveis ou internos do sistema, o que
torna o uso de DTOs separados uma prática importante para garantir a integridade e a segurança da
informação.
Nesses casos em que estamos em projetos que buscam segurança, clareza e escalabilidade, separar DTOs para
requisição (RequestDTO) e resposta (ResponseDTO) é uma prática recomendada. Uma das principais vantagens
dessa separação é evitar a exposição de dados desnecessários ou sensíveis. Com um ResponseDTO,
controlamos exatamente quais informações serão retornadas ao cliente, omitindo campos técnicos como
createdAt
, updatedAt
, chaves estrangeiras como contact_id
, ou
ainda flags de controle interno como isDeleted
, isVerified
e
passwordHash
. Esses campos fazem sentido no contexto interno da aplicação e não devem ser
expostos externamente.
Além disso, essa separação impede que o cliente envie dados que não deveria ou não poderia definir. Por
exemplo, no caso de um ContactRequestDTO, o cliente pode fornecer apenas informações como nome, email e
endereços, enquanto campos como id
ou createdAt
devem ser gerados e
controlados exclusivamente pelo servidor. Dessa forma, garantimos que o cliente não tenha acesso
indevido a propriedades que fogem do seu escopo de atuação.
Outro benefício importante está na possibilidade de aplicar validações específicas para os dados de
entrada. Os RequestDTOs geralmente utilizam anotações como @NotBlank
, @Email
,
@Size
ou @Pattern
para garantir a integridade dos dados recebidos, enquanto os
ResponseDTOs não exigem esse tipo de validação, já que os dados já passaram por todas as regras de
negócio do servidor antes de serem retornados ao cliente.
Separar os DTOs também facilita a evolução da API. Com o tempo, novos requisitos podem exigir a inclusão de campos na resposta que não precisam estar presentes na requisição, ou a descontinuação de campos que antes eram obrigatórios na entrada. Além disso, essa separação permite criar diferentes versões do ResponseDTO para públicos distintos, como administradores, usuários finais ou aplicações mobile, adaptando a resposta conforme o contexto de uso.
Por fim, ao separar os DTOs, a documentação gerada automaticamente por ferramentas como Swagger ou OpenAPI se torna mais precisa e compreensível. Com contratos distintos para entrada e saída, a API pode ser melhor documentada e mais fácil de entender por desenvolvedores que a consumirão, evitando ambiguidade e promovendo uma comunicação clara entre cliente e servidor.
📌 Em resumo
Situação | Recomendação |
---|---|
API simples, dados triviais | Pode usar o mesmo DTO |
Aplicação robusta e escalável | Separar request/response |
Dados sensíveis envolvidos | Separar request/response |
Requisitos de validação distintos | Separar request/response |
Uso de Swagger/OpenAPI | Separar para clareza |
Vamos verificar o código-fonte para criação dos DTOs da nossa aplicação.
1.1 🤔 Fazer validações no DTO ou na Entidade?
Em relação a questão de validação mencionada acima, vamos traduzir o exemplo dado nessa discussão muito relevante que ocorreu no Stack Overflow sobre onde colocar as validações no seu sistema: se na entidade JPA, se no DTO, ou em ambos. Spring REST API validation - should be in DTO or in Entity? (Stack Overflow).
Imagine que você tem uma entidade
User
com um camponame
, e sua lógica de negócio exige que esse campo nunca seja nulo. Você também tem umUserDTO
com o mesmo camponame
.Suponha que todas as suas validações, tanto na entidade quanto no DTO, são feitas utilizando a API
jakarta.validation
.Se você validar apenas no controller (ou seja, validar o DTO com
@Valid
), você estará protegido contra a persistência de dados inválidos — mas apenas para requisições recebidas. Se houver um serviço interno que manipule diretamente a entidade (sem passar por uma requisição HTTP, por exemplo), ele poderá acabar salvando uma entidade inválida no banco de dados sem que você perceba, a menos que haja uma restrição na própria coluna do banco (comoNOT NULL
).Então você pode pensar: “OK, vou mover as anotações de validação do DTO para a entidade e pronto!”. Bem, sim… e não.
Se você validar apenas na entidade, você de fato estará protegido tanto contra dados inválidos vindos de requisições externas quanto contra erros internos na camada de serviço. No entanto, isso pode trazer um problema de desempenho. Segundo Anghel Leonard, no livro Spring Boot Persistence Best Practices, toda vez que você carrega uma entidade do banco de dados, o Hibernate consome memória e CPU para manter o estado dessa entidade no contexto de persistência, mesmo que ela esteja em “modo somente leitura”.
Agora pense: se o campo
name
estiver nulo e você validar isso apenas na entidade, o que acontece?
- Você inicia uma transação.
- Carrega a entidade do banco.
- Modifica a entidade.
- Tenta persistir.
- O Hibernate valida.
- A transação é revertida.
- E todo esse esforço (bastante custoso) foi feito só para no fim dar erro e descartar tudo.
Isso poderia ter sido evitado com uma validação mais simples e imediata — por exemplo, no DTO, antes mesmo de começar qualquer interação com o banco de dados."
Ou seja, a partir dos argumentos acima fica evidente que uma boa prática é a de validação em ambas as camadas: tanto de transporte, quanto de persistência. O tradeoff é um eventual impacto de performance, mas que pode se mostrar negligente quando considerada a economia de recursos que se obtém evitando-se operações desnecessárias na camada de persistência.
1.2 🤔 Implementar os DTOs como Classes ou por meio de Records?
Ao implementar DTOs (Data Transfer Objects) em Java, podemos optar por duas abordagens principais: o uso
de classes tradicionais ou de records
, um recurso
introduzido no Java 14 e estabilizado a partir do Java 16. Ambas as formas cumprem o mesmo propósito —
transportar dados entre diferentes camadas da aplicação, como entre a camada de serviço e a camada de
apresentação (ou entre client e server) — mas apresentam diferenças significativas quanto à concisão,
imutabilidade, compatibilidade e flexibilidade. A escolha entre uma ou outra abordagem depende das
necessidades do projeto e das preferências da equipe de desenvolvimento.
DTOs como Classes
A abordagem tradicional utiliza classes Java no estilo POJO (Plain Old Java Object), com atributos
privados, métodos getters e setters, construtores e, opcionalmente, sobrescrita de métodos como
equals()
, hashCode()
e toString()
.
Exemplo:
public class ContactResponseDTO {
private String nome;
private String email;
public ContactResponseDTO(String nome, String email) {
this.nome = nome;
this.email = email;
}
public String getNome() {
return nome;
}
public String getEmail() {
return email;
}
}
A principal vantagem do uso de classes é a flexibilidade. Podemos incluir lógica interna
nos métodos, adicionar métodos auxiliares, sobrecargar construtores e até mesmo usar herança. Isso
permite, por exemplo, criar hierarquias de DTOs ou adicionar comportamentos mais elaborados ao objeto.
Além disso, essa abordagem é ideal para sistemas legados ou bibliotecas que exigem POJOs clássicos, como
alguns recursos do Jackson em versões mais antigas. Frameworks e projetos em versões antigas do Java
podem simplesmente não possuir suporte aos records
.
Por outro lado, um ponto negativo é o boilerplate: classes DTO podem se tornar longas e repetitivas, especialmente em aplicações grandes que lidam com muitos atributos. Isso pode tornar o código mais difícil de manter a longo prazo. Entretanto, é possível diminuir o boilerplate com bibliotecas como o Lombok, que veremos nas próximas seções.
Herança em DTOs? 😱
Apesar de termos citado acima que o uso de classes para implementação de DTOs é mais flexível, temos que ter em mente que a herança em DTOs é um anti-pattern na maioria dos casos ⚠️
Embora tecnicamente possível, usar herança em DTOs geralmente não é recomendado — e por um motivo muito importante: DTOs não representam entidades do domínio com uma hierarquia semântica "é-um", mas sim estruturas planas e transitórias de dados usadas para transporte entre camadas ou sistemas.
🚫 Por que herança não faz sentido em DTOs na maioria dos casos?
-
Violação do princípio de responsabilidade única
DTOs devem ter uma única função clara: transportar dados. Ao adicionar herança, começamos a introduzir um comportamento "estrutural" que remete à modelagem de domínio — e isso mistura responsabilidades que deveriam estar separadas. -
A herança pressupõe uma relação semântica forte ("é-um")
Se criamosClienteDTO
que herda dePessoaDTO
, estamos dizendo que todo Cliente é uma Pessoa, em todos os contextos. Mas e se a API de clientes for consumida por um sistema que não conhece o DTO de Pessoa? Ou pior: e se o DTO de Pessoa tiver campos que não fazem sentido para Cliente?
→ Isso quebra o princípio da substituição de Liskov e compromete o reuso. -
Aumenta o acoplamento entre componentes
A herança cria uma dependência forte entre DTOs, o que dificulta a manutenção e evolução do código — especialmente em sistemas distribuídos ou com múltiplos consumidores. Cada alteração na superclasse impacta todas as subclasses. -
Dificulta a documentação, o versionamento e a serialização
Ferramentas como Swagger/OpenAPI perdem clareza ao lidar com hierarquias de DTOs. Além disso, algumas bibliotecas de serialização (como Jackson) exigem configurações adicionais para lidar com polimorfismo, o que torna o sistema mais complexo sem necessidade.
✅ Composição é a abordagem recomendada
Composição resolve todos os problemas anteriores: ao invés de herdar de uma superclasse, o DTO declara explicitamente os campos que precisa, ou utiliza outros DTOs como campos compostos.
🧱 Exemplo com composição (boa prática):
public record PessoaDTO(String nome, String email) {}
public record ClienteDTO(PessoaDTO pessoa, String numeroCartaoFidelidade) {}
Essa abordagem:
- ✅ Separa os contextos
- ✅ Reduz acoplamento
- ✅ Torna a API mais clara
- ✅ É mais fácil de manter, testar e documentar
📌 Quando herança pode ser aceitável?
Em casos como:
- Aplicações internas, controladas e pequenas
- Frameworks que impõem herança em contratos (por exemplo, quando se usa
BaseRequestDTO
,BaseResponseDTO
com metadados comuns) - Ambientes onde a equipe consciente dos riscos opta pela herança para reuso puramente estrutural
Mesmo assim, é preciso pesar os custos, pois esses benefícios normalmente podem ser obtidos com composição, de forma mais segura e modular.
DTOs representam dados de transporte, não estruturas de domínio. E por isso, usar composição é quase sempre a melhor escolha.
🔎 Lembrem-se sempre: para fazer boas escolhas técnicas, é essencial compreender os conceitos por trás das ferramentas. Só assim conseguimos tomar decisões conscientes, alinhadas ao propósito do código e às necessidades reais do projeto.
DTOs como Records
Com a introdução dos records
no Java, a linguagem passou a oferecer uma maneira muito mais
concisa de declarar classes imutáveis que servem unicamente para armazenar dados. Um record
em Java automaticamente cria os campos, construtor, métodos getters, além de equals()
,
hashCode()
e toString()
.
Exemplo:
public record ContactResponseDTO(String nome, String email) {}
O maior benefício do uso de records
está na sua simplicidade e imutabilidade. Com poucas
linhas, temos uma estrutura de dados clara, imutável e segura, ideal para representar objetos de
transporte em APIs REST. Isso se alinha a boas práticas modernas que favorecem a imutabilidade e o uso
de objetos simples para troca de dados.
No entanto, os records
têm suas limitações. Por serem imutáveis, não permitem que seus
campos sejam alterados após a construção do objeto, o que pode ser uma barreira em cenários que exigem
mutabilidade. Além disso, records
não suportam herança (embora possam implementar
interfaces) e oferecem menos flexibilidade para incluir lógica interna elaborada. Em frameworks mais
antigos ou bibliotecas que esperam um POJO com getters e setters tradicionais, o uso de
records
pode não funcionar corretamente.
🧠 Mas e em APIs complexas? Posso usar records?
Sim! E muitas equipes fazem isso. A chave está em manter a função do DTO simples: transportar dados. Se você adota uma arquitetura bem separada (com serviços, conversores, validadores e mapeadores bem definidos), o DTO pode — e deve — continuar sendo apenas um recipiente de dados.
Exemplo: mesmo em uma API com dezenas de endpoints, como um sistema de e-commerce, é comum encontrar registros como:
public record ProductResponseDTO(
Long id,
String nome,
BigDecimal preco,
int estoqueDisponivel
) {}
Ou seja: se o DTO não precisa de lógica complexa ou mutabilidade, mesmo em APIs grandes, o record continua sendo uma excelente escolha.
Comparativo entre Classes e Records
Situação | Usar record |
Usar class |
---|---|---|
DTO simples (sem lógica) | ✅ | - |
Necessidade de lógica de transformação | - | ✅ |
Integração com bibliotecas legadas | - | ✅ |
Herança entre tipos de DTO | - | ✅ |
Aplicações modernas com arquitetura limpa | ✅ | - |
A escolha entre usar records
ou classes tradicionais para representar DTOs deve ser feita
com base nos requisitos do projeto. Em APIs simples ou aplicações modernas, records
costumam ser a escolha ideal por sua concisão e imutabilidade. Já em contextos que compatibilidade com
frameworks antigos, as classes ainda são a opção mais robusta.
1.3 ✅ (Finalmente) Implementando nossos DTOs
Como visto acima, temos duas maneiras de implementar nossos DTOs: com classes ou records. Ao longo da disciplina vamos abordar ambas as formas.
Nesse primeiro momento vamos implementar os DTOs de nossa aplicação fazendo o uso de classes e os
separaremos em DTOs de Request
e DTOs de Response
. Vamos usar o
ModelMapper
para mapear nossos records e utilizar o Lombok
para diminuir
código boilerplate.
Faremos dessa forma por um único motivo: explorar esse tipo de implementação e ferramenta no Java. Nem sempre vamos trabalhar com projetos que utilizam versões modernas da linguagem e, em muitos casos, os sistemas em produção são legados e ainda utilizam Java 8. O Java 17 também é LTS e tem suporte prolongado, mas isso não significa que todos os projetos estejam migrados para ele — e muito menos para o Java 21.
Essa abordagem nos permite aprender conceitos fundamentais do ecossistema Java de forma mais completa:
veremos como o Lombok
pode nos ajudar na redução de código repetitivo, como funcionam os
mapeamentos manuais e automáticos com ModelMapper
, e entenderemos as diferenças práticas
entre uma modelagem com classes e uma com records. Mais adiante, teremos a oportunidade de refatorar os
mesmos DTOs utilizando records
e comparar os impactos de cada abordagem, tanto na
legibilidade quanto na manutenibilidade do código.
O Lombok é uma biblioteca Java que ajuda a reduzir o código repetitivo (boilerplate) em
classes, especialmente em projetos que utilizam muitos DTOs ou modelos com getters, setters,
construtores e métodos como toString
ou equals
. Através de anotações simples o
Lombok gera automaticamente esses métodos em tempo de compilação, tornando o código mais limpo, legível
e fácil de manter. Ele é amplamente utilizado em aplicações Spring Boot e facilita o desenvolvimento sem
comprometer a estrutura da aplicação. As anotações do Lombok como @Getter
,
@Setter
, @AllArgsConstructor
, @NoArgsConstructor
e
@Data
servem para eliminar a repetição de código "boilerplate" nas classes Java.
@Getter
e@Setter
geram automaticamente os métodos get e set para todos os atributos da classe (ou para um atributo específico, se usados diretamente sobre ele).@NoArgsConstructor
cria um construtor sem argumentos (necessário, por exemplo, para frameworks como o JPA).@AllArgsConstructor
gera um construtor com todos os atributos da classe como parâmetros.@Data
combina várias anotações úteis:@Getter
,@Setter
,@ToString
,@EqualsAndHashCode
e@RequiredArgsConstructor
, cobrindo boa parte das necessidades de uma classe simples de modelo ou DTO.
Essas anotações deixam o código mais limpo, reduzindo a verbosidade típica do Java.
Para configurar a biblioteca Lombok, adicione a seguinte dependência no
pom.xml
:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
<scope>provided</scope>
</dependency>
Em nosso contexto, de uma aplicação pequena e com pouquíssimas regras de negócio, entretanto, é importante salientar que não faz sentido adotar uma implementação mais complexa a não ser por fins pedagógicos de demonstração de estruturas e ferramentas, o que é exatamente nosso caso. Poderíamos tranquilamente criar os getters e setters "na mão" sem grande prejuízo de tempo.
É preciso entender que optar por estruturas excessivamente sofisticadas em sistemas simples pode nos levar ao chamado Overengineering — ou “superengenharia”. Esse termo descreve a prática de criar soluções desnecessariamente complexas para problemas simples. Em vez de facilitar, o excesso de abstrações, padrões ou camadas técnicas pode dificultar a manutenção, tornar o código mais difícil de entender e até mesmo atrapalhar a produtividade da equipe.
Em outras palavras: só porque algo é possível tecnicamente, não significa que seja a melhor escolha para aquele momento ou projeto. Ou, como diria sua mãe: não é porque você pode, que você deve. Uma arquitetura deve ser proporcional à complexidade e às necessidades da aplicação. Por isso, ainda que exploremos ferramentas como DTOs separados, mapeamentos automáticos e uso de bibliotecas auxiliares, é essencial entender que essas decisões devem sempre ser tomadas com base no contexto, na equipe e nos objetivos do sistema — e não apenas por modismos ou pelo desejo de usar todas as tecnologias disponíveis.
Dito isso, e entendendo o contexto em que nossa aplicação está inserida, vamos organizar nossos DTOs por meio da seguinte 📁 Estrutura de Diretórios
src/
└── main/
└── java/
└── br/
└── ifsp/
└── contacts/
├── config/
│ └── MapperConfig.java
├── dto/
│ ├── contact/
│ │ ├── ContactRequestDTO.java
│ │ ├── ContactResponseDTO.java
│ │ └── ContactPatchDTO.java
│ │
│ └── address/
│ ├── AddressRequestDTO.java
│ └── AddressResponseDTO.java
✨ Separar os DTOs em Request
e Response
nos ajuda a ter mais clareza e
controle sobre o fluxo de dados que entra e sai da nossa aplicação. Perceba, também, que
iremos criar um ContactPatchDTO
, que será utilizado para atualizarmos um contato por meio
de uma requisição PATCH
. Os motivos para isso serão explorados quando abordarmos sua
implementação. 🧑🏻💻
Vamos começar a explorar as implementações pelos DTOs que transportam os endereços.
✅ DTOs de Endereço (Address
)
📥 AddressRequestDTO.java
package br.ifsp.contacts.dto.address;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AddressRequestDTO {
@NotBlank(message = "A rua não pode estar vazia")
String rua;
@NotBlank(message = "A cidade não pode estar vazia")
String cidade;
@NotBlank(message = "O estado não pode estar vazio")
@Size(min = 2, max = 2, message = "O estado deve ter exatamente 2 letras")
@Pattern(regexp = "[A-Z]{2}", message = "O estado deve ser representado por duas letras maiúsculas")
String estado;
@NotBlank(message = "O CEP não pode estar vazio")
@Pattern(regexp = "\\d{5}-\\d{3}", message = "O CEP deve estar no formato 99999-999")
String cep;
}
Esse DTO, implementado como classe com Lombok, representa o corpo da requisição para criação ou atualização de um endereço. Ele contém apenas os campos que o cliente deve fornecer, com anotações de validação para garantir a integridade dos dados enviados.
📤 AddressResponseDTO.java
package br.ifsp.contacts.dto.address;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AddressResponseDTO {
private Long id;
private String rua;
private String cidade;
private String estado;
private String cep;
}
Essa classe representa a resposta que a API retorna ao cliente. Com o
uso do Lombok, eliminamos boilerplate como getters e construtores. O campo id
é incluído
porque se trata de um dado gerado pelo sistema e importante para a leitura e
manipulação dos dados pelo consumidor da API.
✅ DTOs de Contato (Contact
)
📥 ContactRequestDTO.java
package br.ifsp.contacts.dto.contact;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ContactRequestDTO {
@NotBlank(message = "O nome não pode estar vazio")
private String nome;
@NotBlank(message = "O email não pode estar vazio")
@Email(message = "Formato de email inválido")
private String email;
@NotBlank(message = "O telefone não pode estar vazio")
@Size(min = 8, max = 15, message = "O telefone deve ter entre 8 e 15 caracteres")
@Pattern(regexp = "\\d+", message = "O telefone deve conter apenas números")
private String telefone;
@NotEmpty(message = "O contato deve ter pelo menos um endereço")
private List<AddressRequestDTO> addresses;
}
A classe ContactRequestDTO
representa os dados que o cliente envia para criar ou
atualizar um contato. A estrutura mantém as validações exigidas pela aplicação e requer
pelo menos um endereço, garantindo a integridade dos dados recebidos pela API.
📤 ContactResponseDTO.java
package br.ifsp.contacts.dto.contact;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ContactResponseDTO {
private Long id;
private String nome;
private String email;
private String telefone;
private List<AddressResponseDTO> addresses;
}
O ContactResponseDTO
representa os dados que a API retorna ao cliente ao consultar um
contato. Ele inclui o id
, informações pessoais do contato (nome, email, telefone) e a lista
de endereços associados, já convertidos para AddressResponseDTO
. É usado exclusivamente
para respostas e nunca para envio de dados ao servidor.
📤 ContactPatchDTO.java
package br.ifsp.contacts.dto.contact;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ContactPatchDTO {
private Optional<String> nome = Optional.empty();
private Optional<String> email = Optional.empty();
private Optional<String> telefone = Optional.empty();
}
O ContactPatchDTO
foi criado especificamente para atender ao endpoint
PATCH, que permite atualizações parciais de um recurso — no nosso caso, um
Contact
.
Usamos Optional<String>
em cada campo para representar claramente a presença
ou ausência de um valor na requisição. Isso nos ajuda a:
- Saber se o cliente quer ou não atualizar determinado campo.
- Evitar atualizar campos com
null
acidentalmente. - Tornar o código de atualização mais expressivo e seguro, sem precisar verificar
null
diretamente.
Criamos um DTO exclusivo para PATCH pelos seguintes motivos:
- O PATCH não exige todos os campos (como
nome
,email
etelefone
), mas sim somente os que o cliente deseja modificar. - Os DTOs de
Request
eResponse
são pensados para representar requisições completas (POST/PUT) e respostas completas (GET). - Um DTO exclusivo com
Optional
representa perfeitamente a semântica de atualização parcial, garantindo clareza no contrato da API e facilitando a manutenção e validação.
Essa abordagem torna a API mais robusta, bem documentada, e alinhada às boas práticas de desenvolvimento RESTful.
1.4 ✡️ Conversão entre Entidades e DTOs
Fazer a conversão entre entidades e DTOs é uma prática fundamental em APIs bem projetadas. Para reforçar o que vimos anteriormente: as entidades representam o modelo de domínio da aplicação e contêm toda a lógica de negócios e mapeamento com o banco de dados, incluindo relacionamentos complexos, anotações de persistência e campos internos que não devem ser expostos. Já os DTOs (Data Transfer Objects) são estruturas mais simples, voltadas exclusivamente para transportar dados entre o cliente e o servidor.
Quando recebemos uma requisição ou retornamos uma resposta, portanto, queremos converter os dados de uma
Entidade para um DTO e vice-versa. Para facilitar esse processo de conversão e evitar a escrita manual
de código repetitivo, podemos utilizar a biblioteca ModelMapper, que mapeia
automaticamente os campos entre objetos com nomes semelhantes. Ela ajuda a manter o código limpo e
padronizado, além de reduzir erros e acelerar o desenvolvimento. Por isso, configuramos um
@Bean
do ModelMapper na classe principal da aplicação, permitindo que ele seja injetado e
utilizado em qualquer parte do sistema para conversões consistentes entre entidades e DTOs.
No contexto do Spring Framework, um Bean é um objeto cuja instância é criada, configurada e
gerenciada automaticamente pelo Spring, por meio do seu container de Inversão de Controle (IoC). Quando
anotamos um método com @Bean
, estamos informando ao Spring que o objeto retornado por
aquele método deve ser registrado no contexto da aplicação como um componente gerenciado. Isso significa
que o Spring cuidará do ciclo de vida desse objeto e permitirá que ele seja injetado em outras partes do
sistema com o uso da anotação @Autowired
.
Por exemplo, ao configurarmos um método modelMapper()
anotado com @Bean
, o
Spring criará uma instância da classe ModelMapper
, armazenará essa instância em seu
contexto interno e a disponibilizará para uso em toda a aplicação. Quando uma classe precisar de um
ModelMapper
, basta declarar um campo anotado com @Autowired
, e o Spring se
encarregará de injetar a instância configurada.
Esse comportamento tem várias vantagens. Primeiro, evita a criação repetida de instâncias de objetos que poderiam ser reaproveitados, promovendo reutilização e economia de recursos. Além disso, ao centralizar a criação e configuração dos objetos, favorece a manutenção e o teste do código, já que os componentes não são fortemente acoplados às suas dependências. Em outras palavras, os beans contribuem para uma arquitetura mais flexível, coesa e desacoplada, permitindo que o desenvolvedor foque na lógica de negócio em vez de se preocupar com detalhes de instanciamento e configuração.
🛑 ESPERE! Antes de prosseguir, vamos relembrar os conceitos de Inversão de Controle e Injeção de Dependência
Os termos "container de inversão de controle" (IoC Container) e "container de injeção de dependência" (DI Container) são frequentemente utilizados como sinônimos, e essa confusão é compreensível, já que ambos os conceitos estão intimamente relacionados. No entanto, existe uma distinção sutil entre eles que ajuda a compreender melhor o funcionamento interno do Spring e de frameworks semelhantes.
Inversão de Controle (IoC) é um princípio de design que propõe uma mudança na forma como o código lida com a criação e o gerenciamento de objetos. Em vez de o próprio código instanciar e controlar seus objetos diretamente, essa responsabilidade é delegada a um container, que passa a cuidar desse processo. Esse container é o IoC Container, responsável por instanciar classes, resolver e injetar dependências, inicializar objetos e gerenciar seu ciclo de vida ao longo da execução da aplicação. O programador, portanto, apenas declara o que precisa, e o container provê as instâncias apropriadas no momento adequado.
Dentro desse processo, a injeção de dependência (DI) surge como uma técnica concreta para realizar a
inversão de controle. Por meio da injeção de dependência, o container fornece automaticamente as
dependências que uma classe necessita — geralmente serviços, repositórios ou outras estruturas — sem que
a própria classe tenha que criá-las. Isso pode ser feito de diferentes formas: via construtor, via
métodos set
, ou até diretamente nos atributos da classe, por meio de anotações como
@Autowired
.
O IoC Container, portanto, representa um conceito mais amplo, englobando todo o gerenciamento dos componentes da aplicação, enquanto o DI Container é um subconjunto especializado dessa infraestrutura, focado exclusivamente no fornecimento de dependências entre objetos. Podemos dizer que todo DI Container é um IoC Container, mas o inverso não é necessariamente verdadeiro, já que a inversão de controle vai além da simples injeção — ela pode envolver, por exemplo, a gestão do ciclo de vida dos objetos, configuração dinâmica, escopos, eventos, aspectos transversais (AOP), entre outros recursos.
No contexto do Spring Framework, essa estrutura é implementada principalmente pelas interfaces
ApplicationContext
e BeanFactory
. O Spring oferece um IoC Container completo,
com suporte robusto à injeção de dependência. Quando utilizamos anotações como @Component
,
@Autowired
ou declaramos um @Bean
em uma classe de configuração, estamos, na
prática, utilizando a funcionalidade de DI provida pelo IoC Container do Spring para automatizar a
construção e o fornecimento dos nossos objetos de forma segura, reutilizável e desacoplada.
Portanto, o IoC Container é a base sobre a qual o Spring se estrutura, e a injeção de dependência é uma das principais ferramentas disponíveis nesse modelo. Essa arquitetura nos permite construir aplicações mais limpas, testáveis e de fácil manutenção, separando claramente a lógica de negócio da infraestrutura e tornando o código mais declarativo e orientado a contratos.
Para mais informações, consulte a Introdução aos Beans no Spring Framework.
Entendidas as diferenças entre IoC e DI, vamos continuar o que estávamos fazendo anteriormente: a implementação do ModelMapper em nosso projeto.
Como configurar o ModelMapper?
Para configurar a biblioteca ModelMapper, adicione a seguinte dependência no
pom.xml
:
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>3.1.1</version>
</dependency>
Configure o bean na classe MapperConfig
, criada no pacote config:
package br.ifsp.contacts.config;
@Configuration
public class MapperConfig {
@Bean
public ModelMapper modelMapper() {
ModelMapper modelMapper = new ModelMapper();
return modelMapper;
}
}
Agora temos que atualizar nossos controllers para utilizarmos os DTOs em nossas requisições e respostas ao invés das Entidades.
Será que você consegue, a partir das configurações acima, refatorar os controllers da nossa aplicação? 🤓
Para evitar duplicação de apresentação código (e também mais uma refatoração, já que temos que adicionar a paginação no próximo exercício!), vamos apresentá-los posteriormente. De qualquer forma, dê uma pausinha e caso não tenha conseguido fazer os exercícios da última aula, tente refatorar os controllers para que trabalhem com nossos DTOs.
2. 📖 Paginação e Ordenação
A paginação é uma prática importante no desenvolvimento de APIs que lidam com grandes volumes de dados, pois permite que esses dados sejam entregues em partes menores, conhecidas como páginas. Ao invés de retornar todos os registros de uma só vez, a API responde com uma fração controlada deles, o que melhora o desempenho da aplicação, reduz o uso de memória e otimiza o tempo de resposta — tanto no servidor quanto para o usuário final. Isso tem impacto direto na experiência do usuário, pois garante que as informações sejam carregadas de forma mais rápida e fluida, mesmo em dispositivos com recursos limitados ou conexões de rede lentas.
Um dos conceitos associados a esse comportamento é o lazy loading, ou carregamento sob demanda. Em vez de carregar todos os dados logo de início, o sistema busca apenas os dados necessários naquele momento (por exemplo, os 10 primeiros itens) e carrega os próximos à medida que o usuário interage com a aplicação, como ao rolar a página. Isso evita sobrecarga no frontend e garante uma melhor percepção de velocidade por parte do usuário.
No ecossistema Spring, a paginação é implementada através das interfaces Pageable
e
Page
. A interface Pageable
representa a requisição de uma página específica e
carrega as informações sobre qual página foi solicitada, qual o tamanho da página e quais os critérios
de ordenação. O Spring monta automaticamente esse objeto com base em parâmetros da URL, como
page=0&size=10&sort=nome,asc
. Já a interface Page<T>
é a
resposta retornada quando utilizamos um método paginado no repositório. Ela contém tanto a lista de
dados quanto metadados relevantes, como o número total de elementos, total de páginas, número da página
atual e o tamanho da página.
Do ponto de vista do backend e dos acessos ao banco de dados, a paginação também é extremamente benéfica.
Consultas paginadas geram instruções SQL otimizadas com LIMIT
e OFFSET
, o que
significa que o banco só precisa retornar exatamente a quantidade solicitada de dados — e não a tabela
inteira. Isso diminui significativamente a pressão sobre o banco de dados, melhora a escalabilidade da
aplicação e torna os sistemas mais preparados para lidar com múltiplos acessos simultâneos. Ou seja, a
paginação é uma estratégia técnica e de usabilidade que traz vantagens tanto para a infraestrutura
quanto para a experiência do usuário. Ela reduz o tráfego de rede, o tempo de carregamento das
informações e o uso de recursos computacionais, enquanto aumenta a escalabilidade e a clareza da
navegação em APIs REST.
O Spring Data JPA, por padrão, utiliza a contagem zero-based (ou seja, começa na página 0). No entanto,
você pode manipular o valor do parâmetro page
recebido na controller, subtraindo 1 antes de
construir o Pageable
. Por exemplo, eventualmente em nossa implementação poderíamos fazer
algo como:
@GetMapping
public ResponseEntity<Page<ContactResponseDTO>> getPaginatedContacts(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "nome") String sort,
@RequestParam(defaultValue = "asc") String direction
) {
Sort.Direction sortDirection = Sort.Direction.fromString(direction);
Pageable pageable = PageRequest.of(page - 1, size, Sort.by(sortDirection, sort));
Page<ContactResponseDTO> resultPage = contactService.getAllPaginated(pageable);
return ResponseEntity
.status(HttpStatus.OK)
.body(resultPage);
}
Essa adaptação mantém a semântica de uso mais amigável para o usuário (paginação a partir de 1) sem alterar a lógica da infraestrutura do Spring (que continua baseada no zero). É uma prática recomendada para sistemas com UI paginada, como painéis administrativos, apps ou sites públicos com listagens.
O método acima é um exemplo interessante de onde podemos eventualmente chegar em nosso sistema. Por hora, entretanto, vamos começar da maneira mais simples
2.1 ✅ Implementação da paginação
Como vimos acima, o Spring Data JPA facilita bastante a implementação de paginação por meio das
interfaces Page
e Pageable
. Vamos implementá-las alterando, primeiro, nossos
repositórios e fazendo com que nossas queries retornem Page
.
Modificando os Repositórios
Para paginar os resultados, basta que os métodos dos repositórios retornem um objeto
Page<T>
. O Spring cuida do resto! Vamos atualizar ambos repositórios de nossa
aplicação.
AddressRepository.java
@Repository
public interface AddressRepository extends JpaRepository<Address, Long> {
Page<Address> findByContactId(Long contactId, Pageable pageable);
}
👉 Esse método agora retorna uma Page
de endereços, filtrando por contactId
e
aplicando paginação via o parâmetro Pageable
, que será montado no controller a partir dos
parâmetros da URL.
ContactRepository.java
@Repository
public interface ContactRepository extends JpaRepository<Contact, Long> {
Page<Contact> findByNomeContainingIgnoreCase(String nome, Pageable pageable);
}
👉 Aqui, o método busca contatos que contenham o nome informado (ignorando maiúsculas e minúsculas),
retornando os dados em formato paginado também. A interface Pageable
pode incluir, além da
página e quantidade de itens, a ordenação.
Atualizando os Controllers
ContactController.java
Método searchContactsByName
@GetMapping("/search")
public Page<ContactResponseDTO> searchContactsByName(@RequestParam String name, Pageable pageable) {
return contactRepository.findByNomeContainingIgnoreCase(name, pageable)
.map(contact -> modelMapper.map(contact, ContactResponseDTO.class));
}
🧠 Explicação:
Esse endpoint recebe dois parâmetros:
name
→ termo de busca (obrigatório)pageable
→ automaticamente preenchido pelo Spring com base nos parâmetros da URL (page
,size
,sort
, etc.)
O método retorna uma Page
de ContactResponseDTO
, convertendo os objetos
Contact
usando o ModelMapper
.
📌 Exemplo de requisição no Postman:
GET http://localhost:8080/api/contacts/search?name=ana&page=0&size=5&sort=nome,asc
Método getAllContacts
@GetMapping
public Page<ContactDTO> getAllContacts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "nome") String sort) {
Pageable pageable = PageRequest.of(page, size, Sort.by(sort));
Page<Contact> contacts = contactRepository.findAll(pageable);
return contacts.map(contact -> modelMapper.map(contact, ContactDTO.class));
}
🧠 Explicação:
Aqui criamos manualmente o Pageable
com:
page
→ número da página (inicia em 0)size
→ número de registros por páginasort
→ campo para ordenação (ex: nome, email)
Usamos o método findAll()
do JpaRepository
, que aceita um Pageable
e retorna um Page
. Depois, mapeamos os objetos da entidade para DTOs.
📌 Exemplo de requisição no Postman:
GET http://localhost:8080/api/contacts?page=1&size=5&sort=email
AddressController.java
Método getAddressesByContact
@GetMapping("/contacts/{contactId}")
public Page<AddressResponseDTO> getAddressesByContact(@PathVariable Long contactId, Pageable pageable) {
return addressRepository.findByContactId(contactId, pageable)
.map(address -> modelMapper.map(address, AddressResponseDTO.class));
}
🧠 Explicação:
Esse endpoint retorna os endereços de um contato específico, também de
forma paginada.
📌 Exemplo no Postman:
GET http://localhost:8080/api/addresses/contacts/2?page=0&size=3&sort=cidade,desc
Aqui, buscamos os endereços do contato com ID 2, na primeira página, com 3 endereços por página, ordenados por cidade (em ordem decrescente).
✅ Resumo prático:
- Use
Page<T>
como tipo de retorno nos repositórios. - Construa ou injete
Pageable
nos controladores. - O método
.map()
dePage
facilita a conversão de entidades para DTOs. - Parâmetros de paginação são passados via URL e entendidos automaticamente pelo Spring.
Até que não foi tão difícil implementar a paginação, né? O Spring realmente nos ajuda bastante com essa infraestrutura de requisitos que dão suporte às funcionalidades de nossa aplicação. Essa é a vantagem de utilizar um framework robusto ao invés de fazer tudo "na mão". Por outro lado, é importante não ficar muito confortável e dependente das ferramentas sem saber como elas funcionam por baixo dos panos. Tente imaginar: como poderíamos implementar uma versão rudimentar de paginação sem uso das interfaces já fornecidas pelo Spring? 💭
Reflita um pouco antes de passar à próxima seção.
3. 📖 Implementação de Banco de Dados
Até este ponto da aplicação, utilizamos um banco de dados em memória, geralmente o H2, que é configurado automaticamente pelo Spring Boot durante o desenvolvimento. Essa abordagem é útil para testes rápidos, pois dispensa instalação e configuração de servidores de banco, mas os dados são perdidos ao reiniciar a aplicação, e não é adequada para ambientes de produção ou para testes mais realistas.
Agora, vamos configurar a aplicação para utilizar um banco de dados relacional real, como o MySQL ou o PostgreSQL, garantindo persistência de dados entre execuções, maior controle sobre o ambiente e maior proximidade com cenários do mundo real.
🔧 Passo 1 – Adicionando a dependência no pom.xml
A primeira etapa é incluir a dependência do driver JDBC correspondente ao banco de dados escolhido. Exemplo com MySQL:
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
Se preferir usar PostgreSQL, a dependência seria:
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.5.1</version>
</dependency>
⚙️ Passo 2 – Configurando a conexão no application.properties
Em seguida, precisamos fornecer ao Spring os dados de conexão do banco de dados real. Supondo o uso de
MySQL, configure o arquivo src/main/resources/application.properties
com:
spring.datasource.url=jdbc:mysql://localhost:3306/contacts_db
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# Configuração do JPA
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
Vamos entender essa configuração acima linha por linha.
spring.datasource.url=jdbc:mysql://localhost:3306/contacts_db
Essa linha define a URL de conexão JDBC com o banco de dados MySQL. No exemplo, estamos
nos conectando a um banco chamado contacts_db
hospedado no próprio computador
(localhost
) na porta padrão do MySQL (3306
).
spring.datasource.username=root
spring.datasource.password=123456
Essas duas linhas especificam as credenciais de acesso ao banco de dados — no caso, o
usuário root
e a senha 123456
. Esses dados devem ser ajustados conforme a
configuração do seu ambiente.
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
Aqui, indicamos explicitamente a classe do driver JDBC que será usada para se comunicar com o banco MySQL. Essa linha nem sempre é obrigatória, pois o Spring Boot costuma inferi-la automaticamente a partir da URL de conexão, mas é uma boa prática incluí-la para evitar ambiguidades.
spring.jpa.hibernate.ddl-auto=update
Essa opção instrui o Hibernate (a implementação de JPA utilizada pelo Spring Boot) a
atualizar automaticamente o esquema do banco com base nas entidades da aplicação. Ou
seja, se você adicionar um campo ou uma nova entidade, o banco será modificado para refletir isso na
próxima inicialização. Para ambientes de produção, recomenda-se evitar update
e usar
validate
, none
ou controlar via ferramentas de versionamento de schema como
Flyway ou Liquibase.
spring.jpa.show-sql=true
Essa linha ativa o log das instruções SQL executadas pelo Hibernate no console. Isso ajuda bastante durante o desenvolvimento, pois permite verificar o que está sendo enviado ao banco de dados.
spring.jpa.properties.hibernate.format_sql=true
Aqui, ativamos a formatação das queries SQL no console, deixando-as mais legíveis (com quebras de linha e indentação).
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
Por fim, essa linha define o dialeto SQL que o Hibernate deve usar. O dialeto adapta a geração de queries de acordo com as particularidades do banco (sintaxe, funções, tipos de dados etc). No caso, estamos dizendo para o Hibernate usar o dialeto específico do MySQL 8.
Essa configuração completa conecta o Spring Boot a um banco de dados relacional real, garante persistência de dados e permite que o Hibernate cuide do mapeamento entre as classes Java e as tabelas no banco.
Atenção! Essa configuração pode mudar dependendo da versão de seu banco de dados e do SO de sua máquina‼️
No meu notebook (OS X) a configuração ficou da seguinte forma:
# MySQL Database Configuration
spring.datasource.url=jdbc:mysql://localhost:3306/contacts_db?useSSL=false&serverTimezone=UTC
spring.datasource.username=user
spring.datasource.password=password
# Hibernate JPA Configuration
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
Sem a flag useSSL=false&serverTimezone=UTC
o mariadb não permitia a conexão 🫠
Contudo, testando no PC com Linux via WSL a configuração mostrada acima deu certo. Ou seja: podem existir pequenas diferenças. Utilizem esses eventuais problemas como oportunidade de pesquisa e aprendizagem!
Feita essa ressalva, vamos falar um pouco sobre um tema que mencionamos acima: migrations!
🔄 O que são Migrations?
Quando utilizamos a configuração spring.jpa.hibernate.ddl-auto=update
, como visto
anteriormente, o Hibernate assume a responsabilidade de criar e alterar automaticamente o
esquema do banco de dados com base nas entidades da aplicação. Essa abordagem é conveniente
durante o desenvolvimento, mas pode ser arriscada em ambientes de produção, onde uma
pequena alteração no código pode gerar mudanças indesejadas (e até destrutivas) no banco.
É nesse contexto que entram as migrations — ou migrações de banco de dados.
Uma migration é, basicamente, um registro controlado e versionado de alterações estruturais no banco de dados, como criação de tabelas, adição de colunas, criação de índices, entre outros. Essas alterações são descritas em arquivos e aplicadas de forma previsível e segura a cada nova versão do sistema.
Em vez de deixar o Hibernate alterar o banco automaticamente, com migrations a equipe de desenvolvimento define exatamente o que deve ser alterado, em qual ordem, e com possibilidade de rollback se necessário. Isso traz muito mais controle, rastreabilidade e segurança para o processo de evolução do schema.
🛠️ Ferramentas populares para migrations:
- Flyway: baseia-se em scripts SQL ou Java e é amplamente utilizado em projetos Spring Boot. Integra-se facilmente ao ciclo de vida da aplicação.
- Liquibase: usa XML, YAML, JSON ou SQL para definir mudanças e também oferece ferramentas avançadas de auditoria e rollback.
Por hora nos manteremos com a implementação mais simples, mas em aulas posteriores abordaremos a configuração e integração dessas ferramentas em nossa aplicação 🤩
Agora basta seguir para o passo 3...
🧪 Passo 3 – Criando o banco de dados
Antes de executar a aplicação, certifique-se de que o banco de dados
contacts_db
já está criado no servidor MySQL (ou PostgreSQL) local. Você
pode criá-lo via terminal ou usando um cliente gráfico como MySQL Workbench ou
pgAdmin.
CREATE DATABASE contacts_db;
✅ Resultado
Ao iniciar a aplicação, o Spring Boot utilizará a nova configuração, conectando-se ao banco de dados
relacional, criando as tabelas com base nas entidades mapeadas com @Entity
, e persistindo
os dados de forma permanente.
Esse processo aproxima a aplicação do cenário de produção, permite maior controle sobre o ambiente de dados, e possibilita o uso de recursos avançados como índices, constraints e consultas otimizadas — fundamentais em aplicações reais. A configuração acima é suficiente para fazer a migração do banco H2 em memória, que estávamos utilizando, para o MySQL ou Postgres.
Isso foi possível por nos valermos do poder do ORM fornecido pelo Spring.
💪 Reforçando os conceitos de ORM
Esse é um bom momento para relembrarmos um conceito importante: a sigla ORM significa Object-Relational Mapping (Mapeamento Objeto-Relacional) e trata-se de uma técnica que permite que desenvolvedores interajam com bancos de dados relacionais usando objetos da linguagem de programação, em vez de escrever diretamente comandos SQL. ORMs abstraem a complexidade do mundo relacional e nos permitem trabalhar no mundo orientado a objetos. Em Java, o ORM é frequentemente realizado por meio da especificação JPA (Java Persistence API), sendo o Hibernate a implementação mais popular dessa especificação.
O principal objetivo do ORM é reduzir a complexidade do acesso a dados e evitar o acoplamento direto
entre o código da aplicação e o banco de dados. Com um ORM, classes Java são mapeadas para tabelas do
banco, e os atributos dessas classes representam as colunas. O desenvolvedor pode persistir, atualizar,
consultar e remover objetos com comandos simples e legíveis — como repository.save(objeto)
ou repository.findAll()
— em vez de escrever instruções SQL completas.
Esse modelo oferece diversos benefícios: melhora a produtividade, favorece o reuso de código, centraliza as regras de negócio na aplicação e, sobretudo, torna muito simples mudar de banco de dados sem alterar a lógica da aplicação — como acabamos de fazer.
A migração do banco de dados H2 (em memória) para o MySQL foi um excelente exemplo prático da
flexibilidade que o ORM proporciona. No nosso projeto, bastou alterar algumas configurações no arquivo
application.properties
— como a URL de conexão, o usuário e a senha — e adicionar a
dependência do driver do MySQL no pom.xml
. A estrutura do código, as entidades JPA, os
repositórios e os controladores permaneceram absolutamente inalterados.
Essa transição transparente só é possível porque o Hibernate é responsável por gerar e executar os comandos SQL apropriados para o banco configurado, a partir das anotações nas entidades. Isso demonstra na prática o desacoplamento entre a aplicação e o banco de dados, o que facilita muito a portabilidade, a manutenção e a evolução do sistema. Em ambientes reais, essa capacidade é extremamente valiosa: permite começar o desenvolvimento com um banco mais simples (como o H2), e migrar para uma solução mais robusta (como MySQL, PostgreSQL, etc.) sem grandes retrabalhos.
Se quisermos, podemos até mesmo trocar o banco novamente — por PostgreSQL, MariaDB, Oracle — e, desde que os dialetos e drivers estejam corretamente configurados, o restante da aplicação continuará funcionando da mesma forma. Isso é o poder do ORM aliado à padronização do JPA.
Agora falta apenas documentar nossa API com Swagger!
4. Documentação com Swagger/OpenAPI
🔍 O que é Swagger?
O Swagger (atualmente conhecido como Swagger UI ou Springdoc OpenAPI em projetos Spring modernos) é uma ferramenta que permite documentar automaticamente uma API REST com base nas anotações feitas no código. Ele gera uma interface interativa no navegador onde qualquer pessoa pode consultar os endpoints, visualizar os parâmetros, tipos de retorno e até realizar chamadas HTTP diretamente pela interface web.
A grande vantagem é que ele aumenta a transparência e a usabilidade da API, além de reduzir a necessidade de documentações manuais.
🛠️ Como integrar o Swagger com Spring Boot?
Adicione a dependência no pom.xml
:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.7.0</version>
</dependency>
Configuração Básica
Anote a classe principal da aplicação, ContactsApiApplication
, com
@OpenAPIDefinition
.
package br.ifsp.contacts;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
@OpenAPIDefinition(info = @Info(title = "Contacts API", version = "1.0", description = "Documentação da API de Contatos"))
@SpringBootApplication
public class ContactsApiApplication {
public static void main(String[] args) {
SpringApplication.run(ContactsApiApplication.class, args);
}
}
Essa anotação faz parte da especificação OpenAPI/Swagger. Ela é usada para documentar a API de forma automática, com suporte à interface gráfica do Swagger UI.
Com isso, quando a aplicação estiver rodando, o Swagger UI será carregado com as seguintes informações:
- Título: "Contacts API"
- Versão: 1.0
- Descrição: "Documentação da API de Contatos"
Esses dados aparecem na interface do Swagger (/swagger-ui.html ou /swagger-ui/index.html), facilitando o uso da API por desenvolvedores e testadores.
Tudo pronto! 🤠
Com a configuração feita, o próximo passo é refatorar nossos controllers adicionando anotações descritivas nos endpoints — essas descrições serão utilizadas pelo Swagger para gerar uma documentação rica, interativa e fácil de entender.
Vamos ver o estado atual do nosso sistema de gestão de contatos!
5. Estado atual do sistema
Ao longo dessa aula estruturamos a aplicação seguindo boas práticas de organização de código e
responsabilidades bem definidas entre os pacotes. Adotamos conceitos importantes como uso de DTOs para
entrada e saída de dados, integração com o banco de dados relacional (MySQL) via Spring Data JPA,
paginação e ordenação com Pageable
, e mapeamento automático entre entidades e DTOs com
ModelMapper. Também discutimos aspectos teóricos como a diferença entre entidades e DTOs, uso de Lombok
para redução de boilerplate, princípios de Inversão de Controle e Injeção de Dependência, além da
documentação interativa com Swagger/OpenAPI.
Nossa aplicação foi organizada em camadas: config
para configurações globais,
controller
para os endpoints, dto
para os objetos de transporte,
model
para as entidades JPA, repository
para o acesso a dados,
exception
para tratamentos personalizados de erro, e resources
para
configurações de ambiente. Essa estrutura é nada mais do que uma sequência lógica do que já havíamos
adotado nas aulas anteriores.
Nesse estágio atual, portanto, nosso sistema está com a seguinte estrutura de diretórios:
contacts-api/
├── pom.xml
├── src/
│ └── main/
│ ├── java/
│ │ └── br/
│ │ └── ifsp/
│ │ └── contacts/
│ │ ├── config/
│ │ │ └── MapperConfig.java
│ │ ├── controller/
│ │ │ ├── AddressController.java
│ │ │ └── ContactController.java
│ │ ├── dto/
│ │ │ ├── address/
│ │ │ │ ├── AddressRequestDTO.java
│ │ │ │ └── AddressResponseDTO.java
│ │ │ └── contact/
│ │ │ ├── ContactRequestDTO.java
│ │ │ ├── ContactResponseDTO.java
│ │ │ └── ContactPatchDTO.java
│ │ ├── exception/
│ │ │ └── ResourceNotFoundException.java
│ │ │ └── GlobalExceptionHandler.java
│ │ ├── model/
│ │ │ ├── Address.java
│ │ │ └── Contact.java
│ │ ├── repository/
│ │ │ ├── AddressRepository.java
│ │ │ └── ContactRepository.java
│ │ └── ContactsApiApplication.java
│ └── resources/
│ ├── application.properties
│ └── static/
└── target/
Os DTOs, Repositórios e Modelos já foram apresentados anteriormente, bem como o MapperConfig
e o ContactsApiApplication
. As exceções foram apresentadas na aula passada e não tiveram
mudança.
Vamos ver, portanto, como estão os nossos controllers.
ContactController.java
O código-fonte de nosso ContactController
ficou da seguinte maneira:
package br.ifsp.contacts.controller;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import br.ifsp.contacts.dto.contact.ContactPatchDTO;
import br.ifsp.contacts.dto.contact.ContactRequestDTO;
import br.ifsp.contacts.dto.contact.ContactResponseDTO;
import br.ifsp.contacts.exception.ResourceNotFoundException;
import br.ifsp.contacts.model.Address;
import br.ifsp.contacts.model.Contact;
import br.ifsp.contacts.repository.ContactRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@RestController
@RequestMapping("/api/contacts")
@Tag(name = "Contatos", description = "Operações relacionadas a contatos")
@Validated
public class ContactController {
@Autowired
private ContactRepository contactRepository;
@Autowired
private ModelMapper modelMapper;
@Operation(summary = "Buscar todos os contatos paginados")
@GetMapping
public Page<ContactResponseDTO> getAllContacts(Pageable pageable) {
Page<Contact> contacts = contactRepository.findAll(pageable);
return contacts.map(contact -> modelMapper.map(contact, ContactResponseDTO.class));
}
@Operation(summary = "Buscar contato por ID")
@GetMapping("{id}")
public ContactResponseDTO getContactById(@PathVariable Long id) {
Contact contact = contactRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + id));
return modelMapper.map(contact, ContactResponseDTO.class);
}
@Operation(summary = "Criar novo contato")
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ContactResponseDTO createContact(@Valid @RequestBody ContactRequestDTO dto) {
// Mapeia os campos simples
Contact contact = new Contact(dto.getNome(), dto.getEmail(), dto.getTelefone());
// Mapeia os endereços manualmente
var addresses = dto.getAddresses().stream()
.map(addrDto -> {
Address address = new Address();
address.setRua(addrDto.getRua());
address.setCidade(addrDto.getCidade());
address.setEstado(addrDto.getEstado());
address.setCep(addrDto.getCep());
address.setContact(contact);
return address;
}).toList();
contact.setAddresses(addresses);
Contact saved = contactRepository.save(contact);
return modelMapper.map(saved, ContactResponseDTO.class);
}
@Operation(summary = "Atualizar contato por ID")
@PutMapping("/{id}")
public ContactResponseDTO updateContact(@PathVariable Long id, @Valid @RequestBody ContactRequestDTO dto) {
Contact existingContact = contactRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + id));
modelMapper.map(dto, existingContact);
existingContact.getAddresses().forEach(addr -> addr.setContact(existingContact));
Contact updated = contactRepository.save(existingContact);
return modelMapper.map(updated, ContactResponseDTO.class);
}
@Operation(summary = "Atualização parcial de contato")
@PatchMapping("/{id}")
public ContactResponseDTO updateContactPartial(@PathVariable Long id, @RequestBody ContactPatchDTO dto) {
Contact existingContact = contactRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + id));
dto.getNome().ifPresent(existingContact::setNome);
dto.getEmail().ifPresent(existingContact::setEmail);
dto.getTelefone().ifPresent(existingContact::setTelefone);
Contact saved = contactRepository.save(existingContact);
return modelMapper.map(saved, ContactResponseDTO.class);
}
@Operation(summary = "Excluir contato")
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteContact(@PathVariable Long id) {
contactRepository.deleteById(id);
}
@Operation(summary = "Buscar contatos pelo nome")
@GetMapping("/search")
public Page<ContactResponseDTO> searchContactsByName(@RequestParam String name, Pageable pageable) {
return contactRepository.findByNomeContainingIgnoreCase(name, pageable)
.map(contact -> modelMapper.map(contact, ContactResponseDTO.class));
}
}
Optamos por manter os import
para que vocês não se confundam, pois há annotations com os
mesmos nomes em pacotes diferentes quando estamos falando de Page
e Pageable
.
Já o nosso AddressController
ficou como mostrado a seguir.
AddressController.java
package br.ifsp.contacts.controller;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import br.ifsp.contacts.dto.address.AddressRequestDTO;
import br.ifsp.contacts.dto.address.AddressResponseDTO;
import br.ifsp.contacts.exception.ResourceNotFoundException;
import br.ifsp.contacts.model.Address;
import br.ifsp.contacts.model.Contact;
import br.ifsp.contacts.repository.AddressRepository;
import br.ifsp.contacts.repository.ContactRepository;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@RestController
@RequestMapping("/api/addresses")
public class AddressController {
@Autowired
private ContactRepository contactRepository;
@Autowired
private AddressRepository addressRepository;
@Autowired
private ModelMapper modelMapper;
@Operation(summary = "Buscar todos os endereços de um contato")
@GetMapping("/contacts/{contactId}")
public Page<AddressResponseDTO> getAddressesByContact(@PathVariable Long contactId, Pageable pageable) {
return addressRepository.findByContactId(contactId, pageable)
.map(address -> modelMapper.map(address, AddressResponseDTO.class));
}
@Operation(summary = "Criar novo endereço para um contato")
@PostMapping("/contacts/{contactId}")
@ResponseStatus(HttpStatus.CREATED)
public AddressResponseDTO createAddress(@PathVariable Long contactId, @RequestBody @Valid AddressRequestDTO dto) {
Contact contact = contactRepository.findById(contactId)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + contactId));
Address address = modelMapper.map(dto, Address.class);
address.setContact(contact);
Address saved = addressRepository.save(address);
return modelMapper.map(saved, AddressResponseDTO.class);
}
}
📚 Conclusões
Ao longo desta aula, demos mais um passo importante na consolidação das boas práticas no desenvolvimento de APIs RESTful com Spring Boot.
Começamos discutindo a importância do uso de DTOs (Data Transfer Objects), separando os modelos internos da estrutura de dados trafegada pela API. Com isso, ganhamos maior controle sobre o que é exposto ao cliente, evitamos vazamentos de dados sensíveis, reduzimos o acoplamento entre as camadas e preparamos o terreno para uma evolução mais segura da aplicação. Vimos também as vantagens (e limitações) do uso de records e classes com Lombok, além das diferenças entre DTOs de request, response e atualizações parciais com PATCH.
Na sequência, implementamos a paginação e ordenação utilizando os recursos nativos do Spring Data JPA, o que nos permitiu trabalhar com grandes volumes de dados de forma mais performática e organizada. Discutimos a importância de oferecer ao cliente o controle sobre a quantidade e ordenação dos dados retornados, além dos impactos positivos na escalabilidade e usabilidade do sistema.
Depois, configuramos nossa aplicação para utilizar um banco de dados relacional real (MySQL/PostgreSQL) em vez do banco em memória (H2). Essa mudança nos aproximou de um ambiente mais próximo do mundo real, permitindo persistência entre execuções e maior controle sobre os dados. Também discutimos conceitos importantes como ORM, JPA e o papel das migrations no controle de versionamento do banco.
Por fim, integramos a ferramenta Swagger/OpenAPI para documentar nossa API de forma automática, interativa e acessível, promovendo uma comunicação mais clara entre o back-end e seus consumidores. Com apenas algumas anotações e configurações, conseguimos disponibilizar uma interface gráfica que facilita a exploração e o teste da nossa API — um recurso indispensável em qualquer aplicação moderna.
Além dos aspectos técnicos, a aula também reforçou princípios importantes da Engenharia de Software, como a separação de responsabilidades, a validação contextualizada, o desacoplamento entre camadas, o cuidado com overengineering e a importância de decisões técnicas conscientes, baseadas no contexto da aplicação e não apenas em modismos.
Se há uma mensagem principal que queremos reforçar aqui, é esta: não estamos apenas aprendendo frameworks ou ferramentas — estamos aprendendo a construir software com qualidade, clareza e propósito. E isso exige não só domínio técnico, mas também reflexão, criticidade e boas escolhas arquiteturais.
Continue praticando, testando e, acima de tudo, questionando o porquê de cada decisão técnica. Isso é o que transforma uma aplicação funcional em uma aplicação profissional — e um desenvolvedor iniciante em um desenvolvedor consciente.
É importante lembrar que ainda temos muito pela frente. Nas próximas aulas, abordaremos temas como segurança (autenticação e autorização com JWT), versionamento de APIs, e a construção de testes automatizados para garantir a qualidade e a confiabilidade do sistema. Esses tópicos aprofundarão ainda mais nossa aplicação e nosso conhecimento em desenvolvimento de sistemas, de forma geral.
Mas também é fundamental reconhecer o quanto já avançamos. Desde a nossa primeira aula, passamos por conceitos fundamentais de REST, criamos nossos primeiros endpoints, aprendemos a estruturar o projeto com camadas bem definidas, vimos o tratamento de exceções, adotamos boas práticas com DTOs e validações, configuramos a persistência com banco relacional, aplicamos paginação e ordenação, e finalizamos com a documentação interativa da API. Até que não estamos mal, né?! 🎉
É isso, pessoal! Nos vemos na próxima aula! E não se esqueçam do...
Exercício 🚀
Para essa semana teremos apenas um exercício.
1️⃣ - Melhoria nos Controllers
A implementação atual dos controllers funciona, mas
poderia ser aprimorada. Você sabe como? Comente o código-fonte dos dois controllers, avalie os métodos e
traga, na próxima aula, ao menos um
aprimoramento neles.
Bons estudos!