Separação de responsabilidades em sistemas Spring: o que Kotlin torna explícito

🇺🇸 Read in English
Índice

Introdução

Separação de responsabilidades é um compromisso arquitetural, não uma funcionalidade da linguagem. Ainda assim, o design da linguagem pode tornar esse compromisso mais ou menos explícito. Em sistemas baseados em Spring, fronteiras arquiteturais são frequentemente expressas por meio de convenções: camadas, anotações e injeção de dependência. Kotlin não substitui essas convenções, mas torna algumas de suas premissas explícitas no sistema de tipos e na semântica de nulabilidade, imutabilidade e construção. O resultado é uma mudança sutil, porém importante: fronteiras de responsabilidade se tornam mais visíveis e, portanto, mais aplicáveis.

Este ensaio analisa essa mudança. O foco está nos fundamentos e não nos frameworks, usando Spring como representante de uma arquitetura em camadas com injeção de dependência e Kotlin como uma linguagem que refina a semântica dessas camadas.

1) Responsabilidade como fronteira semântica

Uma fronteira de responsabilidade é uma afirmação sobre o que um componente pode conhecer e fazer. Se uma camada de serviço é responsável por invariantes de domínio, então sua interface deve carregar a informação necessária para impor esses invariantes, e suas dependências não devem contorná-los. Isso é um contrato semântico, não estrutural.

O modelo de componentes do Spring incentiva fronteiras claras por construção e ligação, mas não impõe inerentemente restrições semânticas. A interface entre camadas ainda é uma convenção não tipada, a menos que a linguagem a torne precisa. Kotlin muda isso ao tornar aspectos do contrato explícitos: nulabilidade, semântica de valor vs. referência e ordem de inicialização.

2) Nulabilidade como divulgação de responsabilidade

Nulabilidade é uma fonte frequente de responsabilidade oculta. Em Java, um parâmetro nulo é ambíguo: ele sinaliza dado ausente, uma dependência opcional ou uma falha de validação? Kotlin torna isso explícito no nível do tipo. Um parâmetro do tipo T não pode ser nulo; T? pode. Isso não é cosmético; força o autor a declarar se um componente aceita a responsabilidade por lidar com a ausência.

Essa distinção simples reduz o vazamento semântico entre camadas. Um método de repositório que retorna T? torna a ausência parte do contrato. Um método de serviço que aceita T se recusa a aceitar dados ausentes e, portanto, empurra a validação para cima na cadeia de chamadas. Essa é uma fronteira de responsabilidade concreta codificada em tipos.

// Repository acknowledges absence.
interface UserRepository {
  fun findById(id: UserId): User?
}

// Service refuses missing data; it owns the validation boundary.
class UserService(private val repo: UserRepository) {
  fun loadUser(id: UserId): User =
    repo.findById(id) ?: error("User not found: $id")
}

3) Semântica de construtores e direção de dependências

Em sistemas estilo Spring, a injeção de dependência frequentemente obscurece a direção da responsabilidade. A ênfase de Kotlin em injeção por construtor e propriedades imutáveis torna a direção das dependências mais explícita. As dependências de um componente são visíveis no momento da construção e, quando são val, não podem ser reatribuídas. Isso torna o grafo de dependências mais claro e reduz a possibilidade de religar dependências mutáveis em tempo de execução.

Do ponto de vista de primeiros princípios, isso importa porque a responsabilidade deve seguir a direção da dependência: se o componente AA depende de BB, então AA deve respeitar os contratos de BB. A semântica de construção de Kotlin reduz mutações ocultas de dependência, tornando mais difícil violar esses contratos implicitamente.

// Bad: hidden dependencies via field injection and mutation.
@Service
class BillingService {
  @Autowired lateinit var gateway: PaymentGateway
  @Autowired lateinit var repo: InvoiceRepository

  fun charge(id: InvoiceId): Receipt {
    // Dependencies can be swapped or left uninitialized in tests.
    return gateway.charge(repo.load(id))
  }
}

// Better: explicit constructor dependencies and immutability.
@Service
class BillingService(
  private val gateway: PaymentGateway,
  private val repo: InvoiceRepository
) {
  fun charge(id: InvoiceId): Receipt = gateway.charge(repo.load(id))
}

4) Data classes, semântica de valor e fronteiras de domínio

A lição de abstração do SICP se aplica aqui: abstrações de dados devem tornar invariantes explícitos. As data classes e hierarquias seladas de Kotlin incentivam representações mais próximas de tipos algébricos de dados. Isso apoia a separação em nível de domínio: invariantes podem ser empurrados para construtores e pattern matching exaustivo pode tornar estados ilegais irrepresentáveis.

Quando uma camada de domínio expõe uma hierarquia selada em vez de um grafo de objetos mutável e aberto, torna-se mais difícil para camadas superiores “contrabandear” estados inválidos. Isso não é uma funcionalidade do framework; é um reforço em nível de linguagem das fronteiras de responsabilidade.

// Domain boundary: illegal states are unrepresentable.
sealed interface PaymentState {
  data class Authorized(val id: String, val amount: Money) : PaymentState
  data class Captured(val id: String, val receipt: Receipt) : PaymentState
  data class Failed(val id: String, val reason: FailureReason) : PaymentState
}

// Exhaustive handling forces responsibility at the boundary.
fun audit(state: PaymentState): AuditRecord = when (state) {
  is PaymentState.Authorized -> AuditRecord("authorized", state.amount)
  is PaymentState.Captured -> AuditRecord("captured", state.receipt.total)
  is PaymentState.Failed -> AuditRecord("failed", state.reason.code)
}
// Bad: weak domain boundary with nullable fields and ad-hoc flags.
data class Payment(
  val id: String,
  val status: String,
  val amount: Money?,
  val receipt: Receipt?
)

fun settle(p: Payment): Money {
  if (p.status == "CAPTURED" && p.receipt != null) return p.receipt.total
  error("invalid state")
}

// Better: encode state as a sealed hierarchy and eliminate invalid states.
sealed interface Payment {
  val id: String
  data class Captured(override val id: String, val receipt: Receipt) : Payment
  data class Authorized(override val id: String, val amount: Money) : Payment
}

fun settle(p: Payment): Money = when (p) {
  is Payment.Captured -> p.receipt.total
  is Payment.Authorized -> error("not captured")
}

5) Separação de preocupações na presença de reflexão

O Spring depende de reflexão para descoberta e configuração de componentes. A reflexão pode enfraquecer fronteiras de responsabilidade porque permite acesso em tempo de execução a membros que a linguagem de outra forma ocultaria ou restringiria.

Kotlin não pode impedir a reflexão, mas tende a tornar o acesso reflexivo mais deliberado. A indireção adicional (e.g., KClass, metadados Kotlin, nulabilidade explícita) significa que a fronteira reflexiva é mais explícita e menos acidental. Isso não é uma garantia de segurança, mas reduz a chance de que uma fronteira seja cruzada sem intenção consciente.

6) O ângulo de confiabilidade e segurança

Fronteiras de responsabilidade não são apenas cortesias arquiteturais; são restrições de confiabilidade e segurança. Quando uma fronteira é fraca, falhas se propagam e vulnerabilidades cruzam camadas.

A explicitação de Kotlin reduz certas classes de violações de fronteira: dereferências nulas que cruzam camadas, mutação não intencional de estado compartilhado ou controle ambíguo sobre inicialização. Isso reduz o risco de confiabilidade e estreita a superfície para falhas latentes. No entanto, não elimina problemas sistêmicos como verificações incorretas de autorização, falhas de lógica de negócio ou composição insegura de serviços. A linguagem torna algumas responsabilidades explícitas, mas a arquitetura ainda precisa defini-las e impô-las.

// Bad: authorization implicit and scattered across layers.
class DocumentService(private val repo: DocumentRepository) {
  fun get(id: DocId): Document = repo.load(id)
}

// Better: authorization made explicit in the service boundary.
class DocumentService(
  private val repo: DocumentRepository,
  private val policy: AccessPolicy
) {
  fun get(id: DocId, actor: Actor): Document {
    val doc = repo.load(id)
    require(policy.canRead(actor, doc)) { "unauthorized" }
    return doc
  }
}

7) Concepções equivocadas

Equívoco 1: “Kotlin impõe separação de preocupações.” Não impõe. Apenas torna algumas responsabilidades mais explícitas e algumas violações mais visíveis. A separação arquitetural ainda requer disciplina.

Equívoco 2: “Injeção de dependência garante camadas corretas.” A injeção impõe um padrão de ligação, não uma fronteira semântica. Você pode ligar dependências incorretamente e ainda satisfazer o container.

// Bad: web layer reaches into persistence details.
@RestController
class UserController(private val jdbc: JdbcTemplate) {
  @GetMapping("/users/{id}")
  fun get(@PathVariable id: String): UserRow =
    jdbc.queryForObject("select * from users where id = ?", id)
}

// Better: controller depends on a service boundary.
@RestController
class UserController(private val service: UserService) {
  @GetMapping("/users/{id}")
  fun get(@PathVariable id: String): UserView = service.getUser(id)
}

Equívoco 3: “Segurança de tipos implica corretude.” Segurança de tipos é necessária, mas insuficiente. Ela previne certas classes de estados inválidos, mas não pode garantir que os estados permitidos sejam semanticamente válidos.

8) Uma visão principiada da contribuição de Kotlin

De uma perspectiva teórica, Kotlin ajuda ao fortalecer os contratos de interface entre componentes. Ele estreita a lacuna semântica entre uma fronteira como documentada e uma fronteira como imposta. Em outras palavras, aumenta a fidelidade da abstração.

Se modelarmos a interface de um componente como um conjunto de entradas permitidas II e invariantes C\mathcal{C}, o sistema de tipos de Kotlin pode reduzir II para excluir valores inválidos (e.g., nulos) e pode tornar C\mathcal{C} mais explícito por meio de tipos selados e construção imutável. Isso não altera a arquitetura, mas aumenta a precisão de seus contratos.

// Bad: optional parameters silently broaden the input set.
class TransferService {
  fun transfer(from: Account?, to: Account?, amount: Money?) {
    if (from == null || to == null || amount == null) return
    // silently no-op, responsibility unclear
  }
}

// Better: narrow the input set and fail fast at the boundary.
class TransferService {
  fun transfer(from: Account, to: Account, amount: Money) {
    require(amount > Money.zero) { "amount must be positive" }
    // explicit responsibility for validation
  }
}

Conclusão

Separação de responsabilidades em sistemas baseados em Spring é, em última análise, uma disciplina arquitetural. Kotlin não substitui essa disciplina, mas expõe muitas de suas premissas e torna violações de fronteira mais difíceis de ignorar. Nulabilidade, semântica de construtores e modelagem de dados no estilo algébrico fornecem contratos mais nítidos entre camadas, reduzindo ambiguidade e acoplamento acidental.

A lição mais ampla é que a semântica da linguagem pode tornar a intenção arquitetural mais explícita, mas não pode criar essa intenção. Fronteiras de responsabilidade são escolhidas, não inferidas. Kotlin simplesmente torna a escolha mais difícil de evadir — e, portanto, quando bem utilizado, torna o sistema mais honesto sobre o que espera e o que garante.