Java OOP Arquitectura
OOP — Java

OOP: Classes & Separação de Responsabilidades

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.

Encapsulamento: Campos Privados, Contrato Público

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.

ModificadorVisível emUso 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

Construtores e o Padrão Builder

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();
Lombok @Builder Em projectos Spring Boot reais, o Lombok gera o Builder automaticamente com a anotação @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.

Records: DTOs Imutáveis Sem Boilerplate

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 vs Composição

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
}

Interfaces e Contratos

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
    // ...
}

Enums como Tipos de Domínio

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()
)

Entity vs DTO: Nunca Expor a Base de Dados

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"));
    }
}
ownerId nunca vem do request do cliente No 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.

equals, hashCode e toString

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
    }
}

A Arquitectura em Camadas: Cada Camada, Os Seus Tipos

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
    }
}
A separação como defesa em profundidade A separação Entity/DTO não é apenas uma questão de organização — é uma camada de segurança. Um DTO de resposta cuidadosamente construído garante que campos internos como 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.

Checklist OOP & Separação de Responsabilidades