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:

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:

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 campo name, e sua lógica de negócio exige que esse campo nunca seja nulo. Você também tem um UserDTO com o mesmo campo name.

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 (como NOT 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?

  1. Você inicia uma transação.
  2. Carrega a entidade do banco.
  3. Modifica a entidade.
  4. Tenta persistir.
  5. O Hibernate valida.
  6. A transação é revertida.
  7. 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?
  1. 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.

  2. A herança pressupõe uma relação semântica forte ("é-um")
    Se criamos ClienteDTO que herda de PessoaDTO, 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.

  3. 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.

  4. 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:

📌 Quando herança pode ser aceitável?

Em casos como:

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.

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:

Criamos um DTO exclusivo para PATCH pelos seguintes motivos:

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:

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:

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:

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:

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:

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!