Design Pattern Builder e o framework Spring AI - Java Avançado
- #Java
Este artigo adota uma abordagem de NÍVEL AVANÇADO para explorar o padrão de projeto Builder em Java, unindo os fundamentos clássicos da Orientação a Objetos a aplicações práticas e modernas. A partir de um projeto real de controle de gastos por voz construído com Spring Boot, Spring AI e Google Gemini, o texto parte da explicação teórica do padrão (abordando problemas de legibilidade e imutabilidade) e avança até cenários mais complexos de arquitetura.
Introdução
Design patterns (padrões de projeto) são soluções conhecidas para problemas recorrentes de design de software. Um dos mais visíveis no dia a dia de quem programa em Java, mesmo sem perceber, só de usar bibliotecas modernas, é o Builder.
Este artigo tem dois objetivos. Primeiro, explicar o padrão Builder do zero, de forma completa, sem depender de nenhum framework específico. Segundo, mostrar o padrão em ação num projeto real: um assistente de budgeting (controle de gastos) por voz, construído com Spring Boot e Spring AI, integrado ao Google Gemini, tanto através das abstrações do próprio Spring AI quanto através do SDK nativo do Google GenAI. O projeto é particularmente rico para esse propósito porque o Builder não aparece uma única vez: ele se repete, com variações interessantes, em pelo menos seis pontos diferentes do código.
O projeto nasceu como acompanhamento de um curso conduzido pelo instrutor Thiago Poiani, originalmente construído sobre a OpenAI, e foi depois adaptado por mim para usar o Google Gemini. Ao longo da Parte 2, sempre que um trecho de código tiver equivalente direto na aula original, o vídeo correspondente é referenciado logo abaixo do código; quando o trecho for fruto da adaptação para o Gemini (sem correspondência literal na aula), isso também fica indicado, apontando para o vídeo mais próximo em conteúdo.
Ao final, a ideia é que fique fácil reconhecer o padrão Builder sempre que aparecer uma cadeia de chamadas terminando em .build(), e entender por que essas bibliotecas foram desenhadas assim, em vez de usar um construtor comum.
Parte 1: o padrão Builder, explicado do zero
1.1. O problema que o Builder resolve
Em Java, a forma mais direta de criar um objeto é através de um construtor:
public class Pizza {
private final String tamanho;
private final boolean queijoExtra;
private final boolean bordaRecheada;
private final String tipoMassa;
public Pizza(String tamanho, boolean queijoExtra, boolean bordaRecheada, String tipoMassa) {
this.tamanho = tamanho;
this.queijoExtra = queijoExtra;
this.bordaRecheada = bordaRecheada;
this.tipoMassa = tipoMassa;
}
}
Esse desenho funciona bem quando o objeto tem poucos parâmetros, todos obrigatórios. O problema aparece quando o objeto tem muitos parâmetros, e boa parte deles é opcional. Duas dificuldades surgem nesse cenário.
A primeira é que todos os parâmetros precisam ser informados de uma vez, na ordem certa, mesmo os que você não quer configurar. Se você só quer uma pizza grande, sem mais nada de especial, ainda assim precisa escrever new Pizza("grande", false, false, "tradicional"), conhecendo a posição exata de cada boolean e String na assinatura.
A segunda é que, para cobrir combinações diferentes de parâmetros opcionais, a tentação é criar vários construtores sobrecarregados: um Pizza(tamanho), outro Pizza(tamanho, queijoExtra), outro Pizza(tamanho, queijoExtra, bordaRecheada), e assim por diante. Esse problema tem até um nome na literatura de design patterns: "telescoping constructor" (construtor telescópico). Cada novo construtor encaixa no anterior, e a lista cresce de forma insustentável à medida que mais opções são adicionadas.
O Builder resolve isso permitindo que o objeto seja configurado só com o que é necessário, na ordem que for conveniente, através de uma cadeia de chamadas de método finalizada por um método especial que efetivamente cria o objeto.
1.2. A estrutura do padrão
A ideia central do Builder é separar a construção de um objeto complexo da representação final desse objeto. Na prática, em Java, isso costuma aparecer assim:
Pizza pizza = Pizza.builder()
.tamanho("grande")
.queijoExtra(true)
.bordaRecheada(true)
.build();
Três coisas acontecem nessa linha. Pizza.builder() devolve um objeto "montador", ainda vazio ou com valores padrão. Cada chamada seguinte, .tamanho(...), .queijoExtra(...), .bordaRecheada(...), configura uma parte do objeto e devolve o próprio montador, permitindo continuar encadeando (note que tipoMassa nem foi chamado; como não é obrigatório, o Builder assume um valor padrão para ele). Por fim, .build() quebra a cadeia: em vez de devolver o montador de novo, ele constrói e devolve o objeto Pizza real, pronto e, normalmente, imutável.
A forma mais comum de implementar isso em Java usa uma classe aninhada (nested class), uma classe inteira declarada dentro do corpo de outra classe:
public class Pizza {
private final String tamanho;
private final boolean queijoExtra;
private final boolean bordaRecheada;
private final String tipoMassa;
// Construtor privado: só o próprio Builder pode chamá-lo
private Pizza(Builder builder) {
this.tamanho = builder.tamanho;
this.queijoExtra = builder.queijoExtra;
this.bordaRecheada = builder.bordaRecheada;
this.tipoMassa = builder.tipoMassa;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String tamanho;
private boolean queijoExtra = false;
private boolean bordaRecheada = false;
private String tipoMassa = "tradicional"; // valor padrão
public Builder tamanho(String tamanho) {
this.tamanho = tamanho;
return this; // devolve o próprio builder, permite encadear
}
public Builder queijoExtra(boolean queijoExtra) {
this.queijoExtra = queijoExtra;
return this;
}
public Builder bordaRecheada(boolean bordaRecheada) {
this.bordaRecheada = bordaRecheada;
return this;
}
public Builder tipoMassa(String tipoMassa) {
this.tipoMassa = tipoMassa;
return this;
}
public Pizza build() {
return new Pizza(this); // aqui o objeto real é criado
}
}
}
O detalhe mais importante desse código, para quem está lendo pela primeira vez, é que Builder pertence à classe Pizza. Por isso seu nome completo é Pizza.Builder, com um ponto separando o nome da classe externa do nome da classe interna. Esse ponto específico, no nome do tipo, não é uma chamada de método: é a sintaxe do Java para navegar até uma classe aninhada. Já os pontos na cadeia .tamanho(...).queijoExtra(...) são chamadas de método de verdade, todas rodando sobre a mesma instância de Builder.
É por isso que .tamanho(...) e .queijoExtra(...) só podem ser chamados enquanto o Builder ainda está em mãos, ou seja, antes de .build(). Depois de .build(), o que se tem em mãos é um Pizza de verdade, e Pizza não tem, nem precisa ter, métodos para configurar mais nada, porque já está pronta.
1.3. Por que projetar assim, em vez de um construtor comum
Comparando os dois desenhos lado a lado, ficam claras as vantagens do Builder para objetos complexos.

1.4. Um cuidado: nem toda cadeia de pontos é um Builder
Vale um alerta que costuma confundir quem está começando. Encontrar vários pontos seguidos em uma linha de código não significa automaticamente que aquilo é um Builder. Compare:
// Isso é o padrão Builder:
var options = ChatOptions.builder().model("gemini-3-flash").temperature(1.0).build();
// Isso não é o padrão Builder (é apenas uma API fluente / method chaining):
var resposta = chatClient.prompt().user(pergunta).call().content();
A diferença está no que cada ponto devolve. No primeiro exemplo, cada método (.model(...), .temperature(...)) devolve o mesmo tipo (Builder), acumulando configuração sobre o mesmo objeto até .build() finalmente entregar algo diferente. No segundo exemplo, cada ponto muda completamente de tipo de objeto: de uma requisição em construção, para uma resposta, para finalmente uma String. Não há um montador acumulando estado, nem um .build() final. É um estilo relacionado, já que ambos usam encadeamento fluente de métodos mas não é o mesmo padrão. A pergunta prática para diferenciar os dois é sempre a mesma: o que este pedaço da cadeia devolve, o mesmo tipo de antes ou algo novo?
Com essa base teórica fixada, vamos ao projeto real.
Parte 2: o Builder em ação no projeto de budgeting por VOZ
2.1. Contexto rápido do projeto
O projeto em questão é um assistente de controle de gastos que recebe um áudio ("gastei 50 reais no mercado"), transcreve esse áudio em texto usando um modelo de linguagem (LLM), interpreta a intenção e executa código Java real (salvar ou consultar transações no banco), devolvendo uma resposta em áudio de confirmação. Ele é construído em Spring Boot, usando o Spring AI, uma biblioteca que padroniza o acesso a diferentes provedores de IA (OpenAI, Google Gemini, Anthropic, entre outros) através de interfaces comuns, como ChatModel. Ao longo de um curso originalmente pensado para a OpenAI, o projeto foi adaptado para usar a API do Google Gemini.
É justamente essa combinação, o Spring AI de um lado e o SDK nativo do Google GenAI de outro (usado nos dois pontos em que o Spring AI ainda não cobre o Gemini: transcrição multimodal e texto para voz), que faz o Builder aparecer repetidas vezes, em contextos ligeiramente diferentes. Vamos por partes.
2.2. GoogleGenAiChatOptions.builder(): o primeiro Builder do projeto
A primeira aparição do padrão fica logo na configuração do modelo de chat:
var options = GoogleGenAiChatOptions.builder()
.model("gemini-3-flash-preview")
.temperature(1.0)
.responseMimeType("text/plain")
.build();
📺 Vídeo de referência: Vídeo 03 — Explorando o ChatModel e Modelos de Linguagem. Na aula, o mesmo padrão aparece comOpenAiChatOptions.builder(), configurandomodel("gpt-4o-mini"),temperature(0.8)eresponseFormat(...)antes de passar o resultado paradefaultOptions(options)no builder doOpenAiChatModel. A troca de provedor no projeto adaptado (GoogleGenAiChatOptionsno lugar deOpenAiChatOptions) preserva exatamente essa estrutura.
Rastrear o tipo devolvido em cada trecho da cadeia é um exercício que vale sempre repetir diante de um Builder.
TrechoO que é devolvidoGoogleGenAiChatOptions.builder()Um GoogleGenAiChatOptions.Builder, o montador ainda vazio.model("gemini-3-flash-preview")O mesmo Builder, agora com o modelo guardado.temperature(1.0)O mesmo Builder, agora também com a temperatura guardada.responseMimeType("text/plain")O mesmo Builder, com os três valores acumulados.build()Não mais o Builder: um GoogleGenAiChatOptions de verdade, imutável
O ganho prático aqui é direto. Sem o Builder, seria preciso um construtor recebendo todos os parâmetros de configuração de uma vez, inclusive os que o projeto não usa (o Gemini oferece dezenas de opções configuráveis, como topP, topK e candidateCount, entre outras). Com o Builder, o código configura só o que importa para este projeto específico, e tudo o mais recebe valores padrão definidos internamente pela própria classe do Spring AI.
2.3. UserMessage.builder() e Prompt.builder(): combinando conteúdos heterogêneos
O segundo cenário aparece na etapa de transcrição de áudio, onde a mensagem enviada ao modelo combina texto (a instrução) e mídia (o áudio em si):
var userMessage = UserMessage.builder()
.text(TRANSCRIPTION_PROMPT)
.media(List.of(audioMedia))
.build();
var prompt = Prompt.builder()
.messages(List.of(userMessage))
.build();
📺 Vídeo de referência: este trecho não tem correspondência literal na aula, pois é parte da adaptação para o Gemini. Na versão original do curso, a transcrição usa a interface própriaTranscriptionModeldo Spring AI (comAudioTranscriptionPrompt), mostrada no Vídeo 06 — Transcription API: Transformando Áudio em Texto; como o Spring AI ainda não oferece essa interface para o Gemini, a transcrição precisou ser reescrita como uma mensagem multimodal enviada aoChatModel. A estrutura de classes usada aqui (UserMessage, derivada deAbstractMessage, compondo umPrompt) é a mesma apresentada no diagrama da Message API do Vídeo 03 — Explorando o ChatModel e Modelos de Linguagem.
Esse exemplo mostra um ponto forte do Builder que um construtor comum dificilmente expressaria com a mesma clareza: conteúdos de naturezas diferentes (um String de texto, uma List<Media> de mídia) sendo acumulados na mesma cadeia, de forma legível, sem depender de sobrecargas de construtor para cada combinação possível de "só texto", "só mídia" ou "texto e mídia juntos". Prompt.builder(), por sua vez, mostra a mesma ideia em um nível acima: é uma forma alternativa ao construtor new Prompt(texto, options), mas recebendo uma lista explícita de mensagens, útil justamente porque, neste caso, a mensagem não é um texto simples, e sim o UserMessageMULTIMODAL construído no passo anterior.
2.4. ChatClient.builder(...): quando o Spring entrega o Builder já pronto
Este é o ponto mais sutil, e mais interessante, do projeto, porque mostra o Builder se encontrando com a injeção de dependências do Spring.
Em testes de integração, o projeto constrói o ChatClient manualmente, a partir de um ChatModel já injetado:
var chatClient = ChatClient.builder(chatModel)
.defaultSystem("Voce é um matematico")
.build();
📺 Vídeo de referência: Vídeo 04 — ChatClient: Fluência e Contexto no Spring AI. É exatamente este teste de integração, criado na aula comoOpenAiChatClientIT, comChatClient.builder(openAiChatModel).defaultSystem("Você é um matemático").build().
Já nos controllers de produção, o padrão muda. Em vez de chamar o método estático ChatClient.builder(...), a classe simplesmente recebe um ChatClient.Builder já pronto no construtor:
public ChatClientController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
📺 Vídeo de referência: também do Vídeo 04 — ChatClient: Fluência e Contexto no Spring AI, na etapa em que oChatClientControlleré criado a partir doChatModelControlleroriginal, recebendoChatClient.Builder builderno construtor.
A razão dessa diferença é que o ChatClient.Builder é, ele próprio, um bean gerenciado pelo Spring, mas com um detalhe importante: seu escopo é prototype, não o singleton padrão do Spring. Isso significa que, toda vez que uma classe pede um ChatClient.Builder em seu construtor, o Spring entrega uma instância nova e "limpa" desse builder, já pré-configurada internamente para usar o GoogleGenAiChatModel correto (o modelo real, singleton, com a chave de API e as opções padrão já resolvidas via application.properties), mas ainda sem nenhum prompt de sistema ou tool adicionada.
Isso é necessário porque diferentes classes do projeto precisam de ChatClients configurados de formas diferentes e independentes entre si. Uma classe pode querer .defaultSystem(...) com um determinado comportamento, e outra, .defaultTools(...) com um conjunto totalmente diferente de ferramentas. Se o Spring entregasse sempre a mesma instância de Builder, como faria um bean singleton, a configuração feita por uma classe vazaria para todas as outras: cada .defaultSystem(...) chamado em algum canto da aplicação afetaria, incorretamente, os ChatClients de todo o resto. O escopo prototype resolve esse problema, entregando um molde limpo a cada injeção. É o próprio padrão Builder, com sua natureza de montador descartável, usado uma vez e depois substituído pelo objeto final, que torna esse desenho possível.
Em outra classe do mesmo projeto, o Builder aparece configurado de forma mais completa, mostrando como as chamadas se acumulam de acordo com a necessidade de cada consumidor:
var chatClient = ChatClient.builder(chatModel)
.defaultSystem("Voce é um matematico")
.defaultTools(new MathTools())
.build();
📺 Vídeo de referência: Vídeo 05 — Tool Calling: Executando Funções Reais com IA. A aula mostra exatamente essa evolução: uma primeira tentativa passandonew MathTools()em.tools()durante o prompt falha (erroNo annotated methods found), e a correção é mover a tool para.defaultTools(new MathTools())já na construção doChatClient, junto do.defaultSystem(...).
O prefixo "default", nos métodos .defaultSystem(...) e .defaultTools(...), sinaliza que aquela configuração vale para todas as chamadas futuras feitas a partir do ChatClient resultante. Isso reforça o papel do .build(): congelar um conjunto de decisões de configuração em um objeto final pronto para uso repetido.
2.5. Builders aninhados uns dentro dos outros: o SDK nativo do Google GenAI
O ponto mais denso, e mais didático para entender composição de Builders, aparece na funcionalidade de texto para voz (text-to-speech), onde o Spring AI ainda não tem uma interface pronta para o Gemini, e o projeto precisa chamar diretamente o SDK Java nativo do Google (com.google.genai.Client):
this.geminiClient = Client.builder()
.apiKey(apiKey)
.build();
// ...
GenerateContentConfig config = GenerateContentConfig.builder()
.responseModalities("AUDIO")
.speechConfig(SpeechConfig.builder()
.voiceConfig(VoiceConfig.builder()
.prebuiltVoiceConfig(PrebuiltVoiceConfig.builder()
.voiceName("Kore")
.build())
.build())
.build())
.build();
📺 Vídeo de referência: este trecho não aparece na aula, já que é a adaptação Gemini que passei a usar no lugar da abordagem apresentada no curso. O tema equivalente, com a OpenAI, é o Vídeo 07 — Speech API: Sintetizando Voz com Text-to-Speech, onde a síntese de voz é feita através da interfaceTextToSpeechModeldo Spring AI, comTextToSpeechPrompt. Como o Spring AI só suporta OpenAI e Eleven Labs para TTS, a versão Gemini do projeto precisou recorrer ao SDK nativo do Google (com.google.genai.Client), fora do escopo direto da aula, mas resolvendo o mesmo problema (converter texto em áudio) apresentado ali.
Esse trecho reúne, em uma única expressão, quatro Builders aninhados: GenerateContentConfig, SpeechConfig, VoiceConfig e PrebuiltVoiceConfig. Cada um deles responde por um nível diferente da configuração de voz: da configuração geral da resposta (GenerateContentConfig), passando pela configuração específica de fala (SpeechConfig), pela seleção de qual tipo de voz usar (VoiceConfig), até o nome exato da voz pré-definida escolhida (PrebuiltVoiceConfig, aqui com o valor "Kore").
O detalhe estrutural que vale destacar é como cada .build() interno se torna, imediatamente, o argumento de um método do builder externo. PrebuiltVoiceConfig.builder()....build() inteiro é passado para .prebuiltVoiceConfig(...), que por sua vez faz parte da cadeia de VoiceConfig.builder(), e assim sucessivamente, até fechar com o .build() mais externo, de GenerateContentConfig. É o mesmo padrão da Parte 1 se repetindo em camadas: um objeto de configuração complexo (GenerateContentConfig) sendo montado a partir de outros objetos de configuração, também complexos, também construídos via Builder. Essa é uma das situações em que a alternativa, um construtor comum tentando receber tudo isso de uma vez, achatado em uma única lista de parâmetros, seria praticamente ilegível.
2.6. Visão consolidada: todos os Builders do projeto, lado a lado

Um padrão atravessa toda a tabela: em nenhum desses casos o Builder aparece por acaso, ou por estilo pessoal de quem escreveu a biblioteca. Em todos eles, o objeto final tem vários parâmetros, muitos opcionais, e ganha em legibilidade e segurança sendo montado incrementalmente, em vez de exigido de uma vez só em um construtor tradicional, exatamente o cenário descrito na Parte 1 deste artigo.
Conclusão
O padrão Builder resolve um problema simples de enunciar: como construir um objeto complexo, com muitos parâmetros opcionais, sem depender de um construtor gigante e de leitura difícil. A solução é igualmente simples: separar o ato de configurar do ato de finalizar, usando uma cadeia de métodos que devolve, repetidamente, o próprio montador, até um método .build() final entregar o objeto real e, normalmente, imutável.
O que este projeto de budgeting por voz deixa particularmente claro é que esse padrão não é uma curiosidade acadêmica. Ele é a espinha dorsal de como bibliotecas modernas em Java, tanto o Spring AI, ao configurar opções de modelo, mensagens multimodais e clientes de chat, quanto o SDK nativo do Google GenAI, ao compor configurações de voz em várias camadas aninhadas, expõem APIs complexas de forma legível e segura. E o encontro entre o Builder e a injeção de dependências do Spring, visto no caso do ChatClient.Builder com escopo prototype, mostra que o padrão pode ir além de facilitar a leitura do código: ele pode ser também a peça que resolve um problema real de isolamento de configuração entre diferentes partes de uma aplicação.
Da próxima vez que aparecer uma cadeia de chamadas terminando em .build(), seja em uma biblioteca conhecida, seja em código próprio, vale o hábito de rastrear, chamada por chamada, o que cada ponto devolve. É esse pequeno exercício mental, repetido algumas vezes, que transforma "decorar que é um Builder" em realmente entender por que ele foi escolhido ali.
Créditos da aula original: Thiago Poiani (Principal Engineer at Skip), curso "Desenvolvendo sua API Inteligente com Reconhecimento de Fala e Spring Boot", plataforma DIO. Os vídeos referenciados neste artigo pertencem a essa trilha; a adaptação do projeto para o Google Gemini é de autoria própria.



