Java para backend: frameworks, estrutura e projeto
Introdução
Java completou mais de 28 anos de existência e continua sendo uma das linguagens mais utilizadas em backend no mundo. Segundo o relatório do Stack Overflow Developer Survey de 2024, Java permanece no top 10 das linguagens mais populares, com presença massiva em sistemas bancários, plataformas de e-commerce e microsserviços de alta escala. Se você trabalha com desenvolvimento há mais de dois anos, provavelmente já cruzou com Java em algum projeto corporativo ou em uma integração entre sistemas.
Este guia não é um "Hello World". O objetivo aqui é mostrar, de forma prática e direta, por que Java continua relevante para backend, quais frameworks e ferramentas compõem o ecossistema moderno, como estruturar um projeto do zero até a execução em container e quais erros vão te custar horas de debug se você não souber evitá-los.
Se você vem de um background em JavaScript ou TypeScript (temos outros guias que aprofundam as mecânicas do JavaScript que você usa sem entender), vai notar que muitos padrões de arquitetura backend são universais. A diferença é que Java oferece um ecossistema maduro, com tipagem forte estática, tooling robusto e uma JVM que otimiza performance em tempo de execução.
Por Que Usar Java no Backend
Maturidade e ecossistema corporativo
Java não sobrevive por acaso há quase três décadas. O ecossistema é vasto: Spring Boot, Quarkus, Micronaut, Jakarta EE (antigo Java EE), Hibernate, Maven, Gradle. Cada uma dessas ferramentas resolve problemas reais de produção.
Três razões concretas para escolher Java no backend:
- Tipagem forte e verificação em tempo de compilação: diferente de linguagens dinâmicas, erros de tipo são capturados antes do deploy. Isso reduz drasticamente bugs em produção em sistemas grandes.
- JVM e performance: o Just-In-Time Compiler (JIT) da JVM otimiza código em tempo de execução. Aplicações Java que rodam por horas ou dias ficam progressivamente mais rápidas conforme o JIT identifica hot paths.
- Talent pool e longevidade: encontrar desenvolvedores Java é mais fácil do que em linguagens nicho. Sistemas construídos em Java tendem a ser mantidos por décadas (bancos, seguradoras, governos).
Casos de uso reais onde Java brilha
Em sistemas de pagamento e bancos digitais, Java é predominante. O Nubank, apesar de usar Clojure (que roda na JVM), demonstra a confiança do mercado na plataforma JVM. Mercado Livre roda boa parte do seu backend em Java. Netflix usa Java extensivamente em seus microsserviços. LinkedIn, que processa milhões de requisições por segundo, tem Java como linguagem central.
Abordagem moderna versus legada
A percepção de que "Java é verboso e lento para desenvolver" vem da era do Java EE com servidores de aplicação pesados (WebSphere, JBoss com XMLs intermináveis). A realidade moderna é outra:
- Abordagem atual: Spring Boot 3.x com auto-configuration, GraalVM Native Image para startup em milissegundos, records do Java 21 para DTOs imutáveis, virtual threads (Project Loom) para concorrência massiva sem callback hell.
- Abordagem legada (evite): Java EE com deployment em WAR dentro de servidores de aplicação standalone, configuração via XML extensivo, EJBs pesados, threads tradicionais para cada requisição.
Setup do Ambiente de Desenvolvimento
Instalando o JDK moderno
A recomendação atual é usar o JDK 21 (LTS) via SDKMAN, que é o gerenciador de versões mais prático para o ecossistema Java:
# Instalar SDKMAN
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
# Listar versões disponíveis do Java
sdk list java
# Instalar JDK 21 (Temurin, a distribuição recomendada da Eclipse)
sdk install java 21.0.4-tem
# Verificar instalação
java --version
# Instalar Maven (build tool)
sdk install maven
# Alternativa: instalar Gradle
sdk install gradle
# Verificar Maven
mvn --versionCriando o projeto com Spring Boot
O Spring Initializr é a forma padrão de criar projetos Spring Boot. Você pode usar a interface web (start.spring.io) ou o CLI:
# Criar projeto via curl no Spring Initializr
curl https://start.spring.io/starter.tgz \
-d type=maven-project \
-d language=java \
-d bootVersion=3.3.1 \
-d baseDir=api-produtos \
-d groupId=com.vivodecodigo \
-d artifactId=api-produtos \
-d name=api-produtos \
-d packageName=com.vivodecodigo.apiprodutos \
-d javaVersion=21 \
-d dependencies=web,data-jpa,postgresql,validation,actuator \
| tar -xzvf -
cd api-produtos
# Estrutura gerada
tree src/main/java
# src/main/java
# └── com
# └── vivodecodigo
# └── apiprodutos
# └── ApiProdutosApplication.javaExemplo Prático: API REST Completa com Spring Boot
Vamos construir uma API de produtos com operações CRUD, validação, tratamento de erros e padrões de arquitetura em camadas.
Configuração do banco de dados
# Subir PostgreSQL com Docker
docker run -d \
--name postgres-dev \
-e POSTGRES_USER=appuser \
-e POSTGRES_PASSWORD=s3nh4Segur4 \
-e POSTGRES_DB=produtos_db \
-p 5432:5432 \
postgres:16-alpine
# Verificar se o container está rodando
docker ps --filter name=postgres-dev
# Testar conexão
docker exec -it postgres-dev psql -U appuser -d produtos_db -c "SELECT 1;"Configuração do application.yml (abordagem moderna: YAML ao invés de .properties):
# src/main/resources/application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/produtos_db
username: ${DB_USERNAME:appuser}
password: ${DB_PASSWORD:s3nh4Segur4}
hikari:
maximum-pool-size: 10
minimum-idle: 5
jpa:
hibernate:
ddl-auto: validate
open-in-view: false # Desabilitar OSIV: boa prática de performance
properties:
hibernate:
format_sql: true
flyway:
enabled: true
locations: classpath:db/migration
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,metricsEntidade, repositório e serviço
Este é o código principal da aplicação, seguindo a arquitetura em camadas (Controller, Service, Repository):
// src/main/java/com/vivodecodigo/apiprodutos/domain/Produto.java
package com.vivodecodigo.apiprodutos.domain;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "produtos")
public class Produto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String nome;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal preco;
@Column(name = "criado_em", nullable = false, updatable = false)
private LocalDateTime criadoEm;
@Column(name = "atualizado_em")
private LocalDateTime atualizadoEm;
@PrePersist
protected void onCreate() {
this.criadoEm = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
this.atualizadoEm = LocalDateTime.now();
}
// Getters e setters omitidos por brevidade.
// Em Java 21, considere usar records para DTOs.
public Long getId() { return id; }
public String getNome() { return nome; }
public void setNome(String nome) { this.nome = nome; }
public BigDecimal getPreco() { return preco; }
public void setPreco(BigDecimal preco) { this.preco = preco; }
public LocalDateTime getCriadoEm() { return criadoEm; }
public LocalDateTime getAtualizadoEm() { return atualizadoEm; }
}
// src/main/java/com/vivodecodigo/apiprodutos/dto/ProdutoRequest.java
package com.vivodecodigo.apiprodutos.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
// Record do Java 21: imutável, conciso, ideal para DTOs
public record ProdutoRequest(
@NotBlank(message = "Nome é obrigatório")
String nome,
@Positive(message = "Preço deve ser maior que zero")
BigDecimal preco
) {}
// src/main/java/com/vivodecodigo/apiprodutos/dto/ProdutoResponse.java
package com.vivodecodigo.apiprodutos.dto;
import com.vivodecodigo.apiprodutos.domain.Produto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public record ProdutoResponse(
Long id,
String nome,
BigDecimal preco,
LocalDateTime criadoEm
) {
public static ProdutoResponse fromEntity(Produto produto) {
return new ProdutoResponse(
produto.getId(),
produto.getNome(),
produto.getPreco(),
produto.getCriadoEm()
);
}
}
// src/main/java/com/vivodecodigo/apiprodutos/repository/ProdutoRepository.java
package com.vivodecodigo.apiprodutos.repository;
import com.vivodecodigo.apiprodutos.domain.Produto;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ProdutoRepository extends JpaRepository<Produto, Long> {
List<Produto> findByNomeContainingIgnoreCase(String nome);
}
// src/main/java/com/vivodecodigo/apiprodutos/service/ProdutoService.java
package com.vivodecodigo.apiprodutos.service;
import com.vivodecodigo.apiprodutos.domain.Produto;
import com.vivodecodigo.apiprodutos.dto.ProdutoRequest;
import com.vivodecodigo.apiprodutos.dto.ProdutoResponse;
import com.vivodecodigo.apiprodutos.repository.ProdutoRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class ProdutoService {
private final ProdutoRepository repository;
public ProdutoService(ProdutoRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public List<ProdutoResponse> listarTodos() {
return repository.findAll()
.stream()
.map(ProdutoResponse::fromEntity)
.toList();
}
@Transactional(readOnly = true)
public ProdutoResponse buscarPorId(Long id) {
Produto produto = repository.findById(id)
.orElseThrow(() -> new ProdutoNaoEncontradoException(id));
return ProdutoResponse.fromEntity(produto);
}
@Transactional
public ProdutoResponse criar(ProdutoRequest request) {
Produto produto = new Produto();
produto.setNome(request.nome());
produto.setPreco(request.preco());
Produto salvo = repository.save(produto);
return ProdutoResponse.fromEntity(salvo);
}
@Transactional
public void deletar(Long id) {
if (!repository.existsById(id)) {
throw new ProdutoNaoEncontradoException(id);
}
repository.deleteById(id);
}
}
// src/main/java/com/vivodecodigo/apiprodutos/service/ProdutoNaoEncontradoException.java
package com.vivodecodigo.apiprodutos.service;
public class ProdutoNaoEncontradoException extends RuntimeException {
public ProdutoNaoEncontradoException(Long id) {
super("Produto com id " + id + " não encontrado");
}
}Controller e tratamento global de erros
// src/main/java/com/vivodecodigo/apiprodutos/controller/ProdutoController.java
package com.vivodecodigo.apiprodutos.controller;
import com.vivodecodigo.apiprodutos.dto.ProdutoRequest;
import com.vivodecodigo.apiprodutos.dto.ProdutoResponse;
import com.vivodecodigo.apiprodutos.service.ProdutoService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/produtos")
public class ProdutoController {
private final ProdutoService service;
public ProdutoController(ProdutoService service) {
this.service = service;
}
@GetMapping
public List<ProdutoResponse> listar() {
return service.listarTodos();
}
@GetMapping("/{id}")
public ProdutoResponse buscar(@PathVariable Long id) {
return service.buscarPorId(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProdutoResponse criar(@Valid @RequestBody ProdutoRequest request) {
return service.criar(request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deletar(@PathVariable Long id) {
service.deletar(id);
}
}
// src/main/java/com/vivodecodigo/apiprodutos/controller/GlobalExceptionHandler.java
package com.vivodecodigo.apiprodutos.controller;
import com.vivodecodigo.apiprodutos.service.ProdutoNaoEncontradoException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.net.URI;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProdutoNaoEncontradoException.class)
public ProblemDetail handleNotFound(ProdutoNaoEncontradoException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage()
);
problem.setTitle("Recurso não encontrado");
problem.setType(URI.create("https://vivodecodigo.com/erros/recurso-nao-encontrado"));
return problem;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Erro de validação"
);
problem.setTitle("Dados inválidos");
var erros = ex.getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.toList();
problem.setProperty("campos_invalidos", erros);
return problem;
}
}Executando e testando o ciclo completo
# Compilar e executar
./mvnw spring-boot:run
# Testar criação de produto
curl -X POST http://localhost:8080/api/v1/produtos \
-H "Content-Type: application/json" \
-d '{"nome": "Teclado Mecânico", "preco": 349.90}'
# Listar produtos
curl http://localhost:8080/api/v1/produtos
# Buscar por ID
curl http://localhost:8080/api/v1/produtos/1
# Deletar produto
curl -X DELETE http://localhost:8080/api/v1/produtos/1
# Health check (Spring Actuator)
curl http://localhost:8080/actuator/health
# Parar a aplicação (Ctrl+C) e fazer cleanup completo
./mvnw clean
# Remover container do PostgreSQL
docker stop postgres-dev
docker rm postgres-dev
# Remover imagem (se não for mais necessária)
docker rmi postgres:16-alpineBoas Práticas para Java no Backend
Segurança e princípio do menor privilégio
Use roles com menor privilégio em todos os níveis: nunca use credenciais de root no banco de dados, crie usuários específicos com apenas as permissões que cada serviço precisa (SELECT, INSERT, UPDATE em tabelas específicas, jamais GRANT ALL). No Spring Security, configure roles granulares para cada endpoint.
Padrões de arquitetura recomendados
Para projetos pequenos e médios, a arquitetura em camadas (Controller, Service, Repository) que demonstramos é suficiente e pragmática. Para projetos grandes e complexos, considere Hexagonal Architecture (Ports and Adapters), que desacopla regras de negócio de frameworks e infraestrutura.
Práticas modernas essenciais
- Use
recordsdo Java 21 para DTOs ao invés de classes com Lombok. Records são nativos, imutáveis e mais legíveis. - Prefira injeção de dependência via construtor (como fizemos no exemplo) ao invés de
@Autowiredno campo. Isso facilita testes unitários. - Configure
spring.jpa.open-in-view=falsesempre. O padrão Open Session in View é um anti-pattern que mascara queries N+1. - Use Flyway ou Liquibase para migrations. Nunca confie em
ddl-auto=updateem produção. - Utilize Virtual Threads (Java 21) para workloads com alta concorrência e I/O bloqueante, adicionando
spring.threads.virtual.enabled=trueno application.yml.
Se você quer entender melhor os fundamentos de tipagem e coerção que diferenciam linguagens fortemente tipadas como Java das dinâmicas, vale aprofundar esse conceito no guia sobre lacunas ocultas do JavaScript avançado.
Erros Comuns e Soluções
java.lang.OutOfMemoryError: Java heap space
Diagnóstico: a aplicação está consumindo mais memória heap do que o configurado. Comum em processamento de listas grandes sem paginação ou vazamento de referências. Solução: ajuste os parâmetros da JVM e implemente paginação no repositório.
# Ajustar heap ao iniciar a aplicação
java -Xms256m -Xmx512m -jar target/api-produtos-0.0.1-SNAPSHOT.jarNo repositório, use Page<Produto> com Pageable ao invés de findAll() sem limites.
LazyInitializationException: could not initialize proxy
Diagnóstico: tentativa de acessar um relacionamento lazy fora do contexto de transação. Isso é frequente quando open-in-view está desabilitado (que é o correto).
Solução: use @EntityGraph na query do repositório ou faça JOIN FETCH na consulta JPQL. Nunca resolva isso reativando open-in-view.
HikariPool-1: Connection is not available, request timed out
Diagnóstico: todas as conexões do pool estão ocupadas. Normalmente causado por queries lentas ou transações abertas por tempo excessivo.
Solução: revise queries lentas com o Actuator (/actuator/metrics/hikaricp.connections.active), reduza o escopo de @Transactional e considere aumentar maximum-pool-size temporariamente enquanto investiga.
No qualifying bean of type found for dependency
Diagnóstico: o Spring não consegue encontrar um bean para injetar. Comum quando a classe não está anotada com @Service, @Repository ou @Component, ou quando o pacote não está sendo escaneado.
Solução: verifique se a classe tem a anotação correta e se está em um subpacote do pacote principal (onde fica @SpringBootApplication). Se estiver em outro pacote, adicione @ComponentScan(basePackages = "com.vivodecodigo").
Column 'x' cannot be null em produção mas funciona local
Diagnóstico: diferença entre o schema gerenciado por ddl-auto=update no ambiente local e o schema real de produção controlado por migrations. O Hibernate gera colunas nullable por padrão, mas a migration pode ter constraint NOT NULL.
Solução: nunca use ddl-auto=update ou create em produção. Mantenha ddl-auto=validate e controle todas as alterações via Flyway:
# Criar nova migration
touch src/main/resources/db/migration/V2__add_descricao_produtos.sql-- V2__add_descricao_produtos.sql
ALTER TABLE produtos ADD COLUMN descricao TEXT DEFAULT '' NOT NULL;Failed to configure a DataSource: 'url' attribute is not specified
Diagnóstico: o Spring Boot detectou dependência de JPA no classpath mas não encontrou configuração de datasource. Acontece quando o arquivo de configuração está com nome errado ou as variáveis de ambiente não estão setadas.
Solução: confirme que o arquivo é application.yml (não .yaml dentro de subpasta errada) e que as variáveis de ambiente estão definidas. Para testes, crie um application-test.yml com H2 in-memory.
Conclusão
Java no backend em 2024 não é a mesma experiência de d
Guias Relacionados
Explore mais guias técnicos
Mais de 100 guias práticos de integração entre frameworks, ferramentas e plataformas.
Ver todos os guias