Java não é C com classes. A mudança fundamental não é sintáctica — é conceptual. Em C, os dados vivem em structs e o comportamento vive em funções separadas que recebem ponteiros para esses dados. Em Java, dados e comportamento são inseparáveis: uma classe é um contrato que define o que um objecto é e o que sabe fazer. O compilador força esse contrato através de tipos, modificadores de acesso, e interfaces — não há como contorná-lo acidentalmente.
Em C, uma struct é transparente — qualquer código que tenha o ponteiro pode
ler e escrever qualquer campo directamente. Em Java, os modificadores de acesso
controlam quem pode ver e modificar o quê. A regra fundamental:
os campos são sempre private; o acesso ao estado interno
é sempre mediado pelo contrato público da classe.
| Modificador | Visível em | Uso típico |
|---|---|---|
private |
Apenas na própria classe | Todos os campos de instância. Estado interno que ninguém fora da classe deve ver ou modificar directamente. |
package-private (sem modificador) |
Classes no mesmo package | Raramente usado explicitamente. Aparece em testes quando a classe de teste está no mesmo package. |
protected |
Mesmo package + subclasses | Membros que subclasses precisam de aceder. Usar com cautela — cria acoplamento com a hierarquia de herança. |
public |
Qualquer código | Métodos que formam o contrato público da classe. Construtores. Constantes. |
// Em C — struct transparente, sem protecção
typedef struct {
int id;
char* owner_email; // qualquer código pode modificar directamente
double balance;
} Account;
Account acc = {42, "user@example.com", 1000.0};
acc.balance = -999999.0; // nada impede isto — disciplina manual
// Em Java — classe encapsulada, contrato público
public class Account {
// campos private — inacessíveis directamente de fora
private final Long id;
private final String ownerEmail;
private double balance;
public Account(Long id, String ownerEmail, double initialBalance) {
if (initialBalance < 0) {
throw new IllegalArgumentException("Initial balance cannot be negative");
}
this.id = id;
this.ownerEmail = ownerEmail;
this.balance = initialBalance;
}
// leitura permitida — sem modificação
public Long getId() { return id; }
public String getOwnerEmail(){ return ownerEmail; }
public double getBalance() { return balance; }
// modificação controlada — a lógica de negócio vive aqui
public void debit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
if (amount > balance) throw new InsufficientFundsException("Insufficient funds");
this.balance -= amount;
// aqui poderias publicar um evento de domínio, fazer auditoria, etc.
}
public void credit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
this.balance += amount;
}
}
// account.balance = -999999.0; ← erro de compilação — balance é private
// account.debit(-100); ← IllegalArgumentException — regra de negócio aplicada
Quando uma classe tem poucos campos obrigatórios, um constructor simples é suficiente.
Mas quando o número de campos cresce — especialmente com campos opcionais —
o constructor torna-se difícil de usar: a ordem dos argumentos é arbitrária,
os campos opcionais precisam de overloads ou nulls explícitos,
e o código que constrói o objecto é ilegível.
// O problema: constructor com muitos campos
// Qual é o terceiro argumento? É booleano — o que representa?
Document doc = new Document(null, "Relatório", "DRAFT", true, false, null, userId, now());
// ^id ^title ^status ^? ^? ^desc ^owner ^created
// O padrão Builder — construção legível e com campos opcionais explícitos
public class Document {
private final Long id;
private final String title;
private final DocumentStatus status;
private final boolean pinned;
private final boolean archived;
private final String description;
private final Long ownerId;
private final Instant createdAt;
private Document(Builder builder) {
this.id = builder.id;
this.title = builder.title;
this.status = builder.status;
this.pinned = builder.pinned;
this.archived = builder.archived;
this.description = builder.description;
this.ownerId = builder.ownerId;
this.createdAt = builder.createdAt;
}
// getters omitidos por brevidade
public static class Builder {
// campos obrigatórios — sem valor por omissão
private final String title;
private final Long ownerId;
// campos opcionais — com valores por omissão razoáveis
private Long id = null;
private DocumentStatus status = DocumentStatus.DRAFT;
private boolean pinned = false;
private boolean archived = false;
private String description = null;
private Instant createdAt = Instant.now();
public Builder(String title, Long ownerId) {
this.title = title;
this.ownerId = ownerId;
}
public Builder id(Long id) { this.id = id; return this; }
public Builder status(DocumentStatus status) { this.status = status; return this; }
public Builder pinned(boolean pinned) { this.pinned = pinned; return this; }
public Builder description(String desc) { this.description = desc; return this; }
public Document build() { return new Document(this); }
}
}
// Uso — legível, campos opcionais explícitos, ordem não importa
Document doc = new Document.Builder("Relatório Q2", userId)
.status(DocumentStatus.PUBLISHED)
.pinned(true)
.description("Relatório trimestral de vendas")
.build();
@Builder na classe. O resultado é funcionalmente
idêntico ao código acima, sem o boilerplate manual. O conceito é o mesmo —
o Lombok apenas elimina a escrita repetitiva.
Introduzidos no Java 16, os records são classes imutáveis
declaradas de forma compacta. O compilador gera automaticamente o constructor,
todos os getters, equals, hashCode, e toString.
São a escolha natural para DTOs — objectos que apenas transportam dados
entre camadas sem lógica de negócio.
// Class tradicional para um DTO de resposta — muito boilerplate
public class AccountResponse {
private final Long id;
private final String ownerName;
private final double balance;
private final AccountType type;
public AccountResponse(Long id, String ownerName, double balance, AccountType type) {
this.id = id; this.ownerName = ownerName;
this.balance = balance; this.type = type;
}
public Long getId() { return id; }
public String getOwnerName() { return ownerName; }
public double getBalance() { return balance; }
public AccountType getType() { return type; }
@Override public boolean equals(Object o) { /* ... 15 linhas ... */ }
@Override public int hashCode() { /* ... */ }
@Override public String toString() { /* ... */ }
}
// Record equivalente — uma linha, comportamento idêntico
public record AccountResponse(Long id, String ownerName, double balance, AccountType type) {}
// Uso idêntico
AccountResponse response = new AccountResponse(42L, "Ana Silva", 1500.0, AccountType.SAVINGS);
response.id(); // getter gerado automaticamente
response.ownerName(); // nota: sem prefixo "get" — records usam o nome do campo directamente
response.balance();
// Records com validação no constructor compacto
public record CreateAccountRequest(
@NotBlank String ownerName,
@Positive double initialBalance,
AccountType type
) {
// constructor compacto — executado antes de atribuir os campos
public CreateAccountRequest {
if (initialBalance > 1_000_000) {
throw new IllegalArgumentException("Initial balance exceeds maximum allowed");
}
}
}
// Quando usar record vs class:
// record → DTOs, respostas de API, requests de input, value objects, ErrorResponse
// class → entidades com estado mutável, classes com lógica de negócio complexa, builders
Herança é a característica mais visível de OOP mas também a mais fácil de usar incorrectamente. A regra consagrada — prefer composition over inheritance — existe porque herança cria acoplamento rígido entre classes: a subclasse depende dos detalhes de implementação da superclasse, e qualquer mudança na superclasse pode quebrar subclasses sem aviso.
// Herança faz sentido quando existe uma relação "é um" genuína
// e quando queres reutilizar comportamento do framework, não dados
// ✅ Herança correcta — especializar um contrato do framework
// JwtAuthFilter "é um" OncePerRequestFilter — herda o mecanismo de filtragem HTTP
public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(...) {
// implementas apenas a lógica específica do JWT
// o Spring chama este método uma vez por pedido — comportamento herdado
}
}
// ✅ Herança correcta — hierarquia de excepções de domínio
// ResourceNotFoundException "é uma" ApplicationException "é uma" RuntimeException
public class ResourceNotFoundException extends ApplicationException {
public ResourceNotFoundException(String message) { super(message, 404); }
}
// ❌ Herança incorrecta — reutilizar código sem relação "é um"
// AccountService não "é um" BaseService com métodos genéricos
// — está a herdar por conveniência, não por tipo
public class AccountService extends BaseService {
// herda métodos de BaseService que AccountService não deveria conhecer
// mudanças em BaseService podem quebrar AccountService silenciosamente
}
// ✅ Composição — injectar colaboradores, não herdar deles
// AccountService "tem um" repositório, "tem um" serviço de auditoria
@Service
public class AccountService {
private final AccountRepository repository; // composição
private final AuditService audit; // composição
public AccountService(AccountRepository repository, AuditService audit) {
this.repository = repository;
this.audit = audit;
}
// se AuditService mudar internamente, AccountService não é afectado
// podes substituir AuditService por um mock em testes sem alterar AccountService
}
Uma interface define um contrato — o que um objecto sabe fazer, sem dizer como. Em Spring Boot, as interfaces são o mecanismo que permite ao framework integrar com o teu código: implementas a interface que o Spring espera, e o Spring chama os teus métodos no momento certo.
// Interface do Spring Security — define o contrato de carregamento de utilizadores
// O Spring não sabe como a tua aplicação guarda utilizadores — só sabe que
// qualquer implementação de UserDetailsService tem loadUserByUsername
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
// A tua implementação cumpre o contrato
@Service
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public AppUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) {
return userRepository.findByEmail(username)
.map(AppUserDetails::new)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
}
// Definir as tuas próprias interfaces — facilita testes e substituição
public interface JwtService {
String generateToken(String username);
String extractUsername(String token);
boolean isTokenValid(String token);
}
// Implementação de produção
@Component
public class JjwtJwtService implements JwtService {
// implementação com a biblioteca JJWT
}
// Implementação de teste — sem criptografia real, previsível
public class FakeJwtService implements JwtService {
@Override public String generateToken(String username) { return "fake-token-" + username; }
@Override public String extractUsername(String token) { return token.replace("fake-token-", ""); }
@Override public boolean isTokenValid(String token) { return token.startsWith("fake-token-"); }
}
// O JwtAuthFilter depende da interface, não da implementação
// Em testes, injectas FakeJwtService sem alterar o filtro
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService; // interface — não sabe qual implementação
// ...
}
Quando um campo pode assumir apenas um conjunto fixo de valores — o estado
de um documento, o papel de um utilizador, o tipo de uma conta — a tentação
é usar Strings. Enums são superiores: o compilador verifica que só usas
valores válidos, o switch pode ser exhaustivo, e podes adicionar
comportamento a cada valor.
// ❌ Strings mágicas — sem verificação em compile-time
public class Account {
private String type; // "SAVINGS", "CHECKING", "BUSINESS" — ou "SAVIGNS" por engano
private String status; // qualquer string é válida — sem contrato
}
// ✅ Enums — contrato verificado pelo compilador
public enum AccountType {
SAVINGS, CHECKING, BUSINESS;
}
public enum AccountStatus {
ACTIVE, SUSPENDED, CLOSED;
public boolean canTransact() {
return this == ACTIVE; // comportamento associado ao valor
}
}
public enum Role {
USER, ADMIN, MODERATOR;
public boolean hasAdminPrivileges() {
return this == ADMIN;
}
}
// Uso — o compilador rejeita valores inválidos
Account account = new Account(AccountType.SAVINGS, AccountStatus.ACTIVE);
account.getStatus().canTransact(); // true
// switch exhaustivo com enums (Java 14+ switch expression)
String label = switch (account.getType()) {
case SAVINGS -> "Conta Poupança";
case CHECKING -> "Conta à Ordem";
case BUSINESS -> "Conta Empresarial";
// sem default necessário — o compilador garante que todos os casos estão cobertos
// se adicionares um novo valor ao enum, o compilador avisa de todos os switches incompletos
};
// Enums em Spring Security — roles como constantes tipadas
@Entity
public class UserEntity {
@Enumerated(EnumType.STRING) // guarda "ADMIN" na BD, não 0/1/2
private Role role;
}
// Verificação de role na security config
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole(Role.ADMIN.name())
.anyRequest().authenticated()
)
Um dos erros arquitecturais mais comuns em aplicações Spring Boot é usar a entidade JPA directamente como resposta da API. A entidade representa a estrutura da base de dados — com todos os campos, relações lazy/eager, e anotações de persistência. Expô-la directamente acopla o contrato público da API à estrutura da base de dados, vaza campos internos para o cliente, e pode causar problemas de serialização com relações JPA circulares.
// Três tipos distintos para a entidade Account — cada um com responsabilidade própria
// 1. ENTITY — representa a tabela na base de dados
// Contém todos os campos persistidos, anotações JPA, relações
// Nunca sai do repositório para além da camada de serviço
@Entity
@Table(name = "accounts")
public class AccountEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String ownerName;
@Column(nullable = false)
private String ownerEmail; // campo interno — o cliente não precisa de ver o email na resposta de conta
@Column(nullable = false)
private double balance;
@Enumerated(EnumType.STRING)
private AccountType type;
@Enumerated(EnumType.STRING)
private AccountStatus status;
@Column(nullable = false)
private Long ownerId; // chave de isolamento — nunca expor na resposta pública
@Column(nullable = false)
private Instant createdAt;
private Instant closedAt; // null se activa — detalhe de implementação interna
// getters e setters
}
// 2. RESPONSE DTO — o que o cliente recebe
// Apenas os campos que fazem sentido para o consumidor da API
// Sem campos internos, sem ownerId, sem closedAt (se activa)
public record AccountResponse(
Long id,
String ownerName,
double balance,
AccountType type,
AccountStatus status,
Instant createdAt
) {
// factory method — converte Entity → DTO no serviço
public static AccountResponse from(AccountEntity entity) {
return new AccountResponse(
entity.getId(),
entity.getOwnerName(),
entity.getBalance(),
entity.getType(),
entity.getStatus(),
entity.getCreatedAt()
);
}
}
// 3. REQUEST DTO — o que o cliente envia para criar/modificar
// Apenas os campos que o cliente pode definir — sem id, sem ownerId, sem status
public record CreateAccountRequest(
@NotBlank(message = "Owner name is required")
String ownerName,
@Positive(message = "Initial balance must be positive")
double initialBalance,
@NotNull(message = "Account type is required")
AccountType type
) {}
// O serviço orquestra a conversão — é o único lugar onde Entity e DTOs coexistem
@Service
public class AccountService {
private final AccountRepository repository;
public AccountResponse createAccount(CreateAccountRequest request, Long authenticatedUserId) {
AccountEntity entity = new AccountEntity();
entity.setOwnerName(request.ownerName());
entity.setBalance(request.initialBalance());
entity.setType(request.type());
entity.setStatus(AccountStatus.ACTIVE);
entity.setOwnerId(authenticatedUserId); // o ownerId vem do JWT, nunca do request
entity.setCreatedAt(Instant.now());
AccountEntity saved = repository.save(entity);
return AccountResponse.from(saved); // Entity → DTO antes de sair do serviço
}
public AccountResponse getAccount(Long id, Long authenticatedUserId) {
return repository.findByIdAndOwnerId(id, authenticatedUserId)
.map(AccountResponse::from) // Entity → DTO via map no Optional
.orElseThrow(() -> new ResourceNotFoundException("Account not found"));
}
}
CreateAccountRequest, o ownerId não existe —
e é intencional. O identificador do dono vem sempre do JWT autenticado,
extraído do SecurityContext pelo Spring Security. Se o cliente
pudesse enviar o ownerId no body do pedido, poderia criar
recursos em nome de qualquer utilizador. Este é o mesmo princípio
de isolamento lógico do Módulo 3: a identidade do dono nunca é confiada
ao cliente.
Em Java, equals e hashCode têm um contrato que o
compilador não verifica mas que os frameworks assumem implicitamente.
Colecções como HashSet e HashMap, e frameworks
como JPA e Spring Cache, dependem deste contrato para funcionar correctamente.
// O contrato equals/hashCode:
// 1. Se a.equals(b) é true, então a.hashCode() == b.hashCode() obrigatoriamente
// 2. Se a.hashCode() != b.hashCode(), então a.equals(b) é false (garantido)
// 3. equals deve ser reflexivo, simétrico, transitivo, e consistente
// Problema: equals por omissão em Object compara referências (==), não conteúdo
AccountResponse r1 = new AccountResponse(42L, "Ana", 1000.0, AccountType.SAVINGS);
AccountResponse r2 = new AccountResponse(42L, "Ana", 1000.0, AccountType.SAVINGS);
r1.equals(r2); // false — objectos diferentes em memória, mesmo que conteúdo idêntico
// Records resolvem isto automaticamente — equals e hashCode por conteúdo
public record AccountResponse(Long id, String ownerName, double balance, AccountType type) {}
AccountResponse r1 = new AccountResponse(42L, "Ana", 1000.0, AccountType.SAVINGS);
AccountResponse r2 = new AccountResponse(42L, "Ana", 1000.0, AccountType.SAVINGS);
r1.equals(r2); // true — records comparam por valor dos campos
// Em classes normais: implementar manualmente ou usar @EqualsAndHashCode do Lombok
// Para entidades JPA: comparar pelo id (campo de identidade de base de dados)
@Entity
public class AccountEntity {
@Id private Long id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof AccountEntity other)) return false;
return id != null && id.equals(other.id);
// entidades novas (id == null) não são iguais a nada até serem persistidas
}
@Override
public int hashCode() {
return getClass().hashCode();
// constante por classe — seguro para entidades com id mutável (antes/depois de persistir)
}
@Override
public String toString() {
return "AccountEntity{id=" + id + ", type=" + type + ", status=" + status + "}";
// nunca incluir balance ou ownerEmail no toString — pode acabar em logs
}
}
Tudo o que vimos neste módulo — encapsulamento, records, interfaces, enums, separação Entity/DTO — converge numa regra arquitectural simples: cada camada da aplicação tem os seus próprios tipos e nunca partilha tipos de implementação com camadas adjacentes.
// Fluxo de um pedido POST /api/accounts através das camadas:
//
// HTTP Request (JSON)
// ↓
// [Controller] recebe CreateAccountRequest (record validado com @Valid)
// ↓ passa CreateAccountRequest + userId do JWT
// [Service] lê CreateAccountRequest, cria AccountEntity, chama repository
// recebe AccountEntity de volta, converte para AccountResponse
// ↓ passa AccountEntity
// [Repository] persiste AccountEntity, retorna AccountEntity
// ↑ retorna AccountEntity
// [Service] AccountResponse.from(entity) — Entity → DTO
// ↑ retorna AccountResponse
// [Controller] retorna AccountResponse (serializado como JSON pelo Jackson)
// ↑
// HTTP Response (JSON)
// O que cada camada conhece:
// Controller → CreateAccountRequest, AccountResponse (nunca AccountEntity)
// Service → CreateAccountRequest, AccountResponse, AccountEntity (a ponte)
// Repository → AccountEntity (nunca DTOs)
@RestController
@RequestMapping("/api/accounts")
public class AccountController {
private final AccountService service;
public AccountController(AccountService service) { this.service = service; }
@PostMapping
public ResponseEntity<AccountResponse> create(
@Valid @RequestBody CreateAccountRequest request,
@AuthenticationPrincipal UserDetails user) {
Long userId = ((AppUserDetails) user).getId();
AccountResponse response = service.createAccount(request, userId);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
// AccountEntity nunca chega aqui — o controller só vê DTOs
}
@GetMapping("/{id}")
public AccountResponse get(@PathVariable Long id,
@AuthenticationPrincipal UserDetails user) {
Long userId = ((AppUserDetails) user).getId();
return service.getAccount(id, userId);
// se a conta não existir ou não for do userId → ResourceNotFoundException → 404
}
}
ownerId, passwordHash, ou
internalStatus nunca chegam ao cliente, mesmo que sejam adicionados
à entidade mais tarde. A API tem um contrato explícito, independente da
estrutura da base de dados.
private — acesso mediado pelo contrato público.final — imutabilidade e clareza.record para DTOs, respostas de API, e value objects — elimina boilerplate e gera equals/hashCode correctos.Entity (BD), Response (saída da API), Request (entrada da API).ownerId nunca vem do request do cliente — vem sempre do JWT autenticado.