Pular para o conteúdo

Trilha de Kotlin · parte 54 de 59

SOLID e padrões de projeto em Kotlin

Cinco princípios SOLID com código Kotlin que viola e depois corrige, padrões que a linguagem já resolve e o que sobra das regras de Object Calisthenics.

intermediárioPublicada em · 14 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

e padrões de projeto em Kotlin

Quase todo projeto tem uma gaveta de cozinha: tesoura, pilha, cola quente, o controle de uma TV que já não existe. Ela funciona até você precisar da tesoura. Código desorganizado faz o mesmo com quem precisa mudar uma regra. SOLID são cinco princípios para cada parte ter seu lugar. Padrões de projeto são soluções com nome para problemas recorrentes. Esta aula trata dos dois e mostra o que sobra de cada um em Kotlin.

o mapa da aula

As seções 1 a 6 mostram o problema e as letras S, O, L, I e D, cada uma com um código que sofre e a versão corrigida. A seção 7 traz os padrões que ainda valem em Kotlin, a seção 8 o que a linguagem dispensa, e a seção 9 aplica as regras de Object Calisthenics. Pré-requisitos: Interfaces, delegação e classes open, sealed e when exaustivo e Encapsulamento, visibilidade, object e companion.

Os trechos foram compilados com os toolchains 21 e 25 (Kotlin 2.4.21, Gradle 9.7.0). O bytecode 21 rodou nos dois JDKs, e o 25 só no 25, com a mesma saída.

1. o problema que os princípios atacam

Quando uma mudança numa classe quebra outra, há : partes que dependem demais dos detalhes umas das outras. Robert C. Martin reuniu os princípios por volta de 2000, e Michael Feathers deu o nome SOLID por volta de 2004, segundo a Wikipedia (datas aproximadas). Cada princípio responde a uma dor concreta, e só vale aplicar quando ela existe.

2. S: responsabilidade única

Uma classe deve ter um único motivo para mudar. Pense no caixa de uma padaria que também sova a massa e escreve o cardápio. Esta Pedido calcula o total, grava no banco e monta o e-mail:

class Pedido(val itens: List<Int>, val cliente: String) {
    fun total(): Int = itens.sum()

    fun salvarNoBanco() {
        println("INSERT pedido de $cliente, total ${total()}")
    }

    fun enviarRecibo() {
        println("e-mail para $cliente: total ${total()}")
    }
}

fun main() {
    val pedido = Pedido(listOf(30, 45), "Ana")
    println("total: ${pedido.total()}")
    pedido.salvarNoBanco()
    pedido.enviarRecibo()
}
total: 75
INSERT pedido de Ana, total 75
e-mail para Ana: total 75

Trocar o texto do e-mail obriga a reabrir a classe da regra do total. Separando os papéis, cada classe muda por um motivo só:

class Pedido(val itens: List<Int>, val cliente: String) {
    fun total(): Int = itens.sum()
}

class PedidosRepositorio {
    fun salvar(pedido: Pedido) {
        println("INSERT pedido de ${pedido.cliente}, total ${pedido.total()}")
    }
}

class Recibo {
    fun montar(pedido: Pedido): String =
        "e-mail para ${pedido.cliente}: total ${pedido.total()}"
}

fun main() {
    val pedido = Pedido(listOf(30, 45), "Ana")
    println("total: ${pedido.total()}")
    PedidosRepositorio().salvar(pedido)
    println(Recibo().montar(pedido))
}

A saída continua a mesma.

3. O: aberto para extensão, fechado para alteração

O princípio pede que você acrescente comportamento sem editar o código que já funciona. A formulação é de Bertrand Meyer, de 1988. Esta função cresce a cada campanha:

fun desconto(campanha: String, valor: Int): Int = when (campanha) {
    "NATAL" -> valor * 10 / 100
    "FIDELIDADE" -> valor * 5 / 100
    else -> 0
}

fun main() {
    for (campanha in listOf("NATAL", "FIDELIDADE", "BLACK", "NATAL ")) {
        println("$campanha: ${desconto(campanha, 200)}")
    }
}
NATAL: 20
FIDELIDADE: 10
BLACK: 0
NATAL : 0

O último resultado é o problema: "NATAL ", com espaço, cai no else e vira desconto zero, sem erro. Com sealed interface, cada campanha leva a própria regra, e o total não muda quando surge uma nova:

sealed interface Campanha {
    fun desconto(valor: Int): Int
}

data object Natal : Campanha {
    override fun desconto(valor: Int): Int = valor * 10 / 100
}

data object Fidelidade : Campanha {
    override fun desconto(valor: Int): Int = valor * 5 / 100
}

data object Black : Campanha {
    override fun desconto(valor: Int): Int = valor * 30 / 100
}

fun totalComDesconto(valor: Int, campanha: Campanha): Int =
    valor - campanha.desconto(valor)

fun main() {
    for (campanha in listOf(Natal, Fidelidade, Black)) {
        println("$campanha: ${totalComDesconto(200, campanha)}")
    }
}
Natal: 180
Fidelidade: 190
Black: 140

O sealed não cumpre o princípio à risca, porque a documentação exige que as subclasses diretas fiquem no mesmo pacote e módulo. Para que terceiros estendam o comportamento, use uma interface aberta, como na seção 7.

4. L: substituição de Liskov

Um subtipo deve poder ocupar o lugar do tipo base sem surpreender quem o usa. O pinguim herdando de ave é o clássico:

open class Ave {
    open fun voar(): String = "voando"
}

class Pinguim : Ave() {
    override fun voar(): String = throw UnsupportedOperationException("pinguim não voa")
}

fun fazerVoar(ave: Ave) {
    println(ave.voar())
}

fun main() {
    fazerVoar(Ave())
    fazerVoar(Pinguim())
}
voando
Exception in thread "main" java.lang.UnsupportedOperationException: pinguim não voa

fazerVoar confiava no contrato de Ave, e o pinguim o quebra. Separando a capacidade de voar:

interface Voador {
    fun voar(): String
}

open class Ave(val nome: String)

class Pardal(nome: String) : Ave(nome), Voador {
    override fun voar(): String = "$nome voa"
}

class Pinguim(nome: String) : Ave(nome)

fun fazerVoar(voador: Voador) {
    println(voador.voar())
}

fun main() {
    fazerVoar(Pardal("Pardal"))
    println("${Pinguim("Pingu").nome} anda")
}

Agora passar um pinguim para quem espera um voador não compila. Se a main chamar fazerVoar(Pinguim("Pingu")), o compilador 2.4.21 recusa a chamada com Argument type mismatch: actual type is 'Pinguim', but 'Voador' was expected.. Essa chamada não está no trecho acima, para que ele compile.

5. I: segregação de interfaces

Ninguém deve depender de métodos que não usa. Esta Equipamento obriga toda impressora a digitalizar:

interface Equipamento {
    fun imprimir(texto: String)
    fun digitalizar(): String
}

class ImpressoraSimples : Equipamento {
    override fun imprimir(texto: String) = println("impresso: $texto")

    override fun digitalizar(): String = throw UnsupportedOperationException("sem scanner")
}

fun main() {
    val equipamento: Equipamento = ImpressoraSimples()
    equipamento.imprimir("relatório")
    println(equipamento.digitalizar())
}
impresso: relatório
Exception in thread "main" java.lang.UnsupportedOperationException: sem scanner

Quem chama digitalizar recebe a exceção, e o compilador não tinha como saber. Separando as capacidades:

interface Impressora {
    fun imprimir(texto: String)
}

interface Digitalizadora {
    fun digitalizar(): String
}

class ImpressoraSimples : Impressora {
    override fun imprimir(texto: String) = println("impresso: $texto")
}

class Multifuncional : Impressora, Digitalizadora {
    override fun imprimir(texto: String) = println("impresso (multi): $texto")

    override fun digitalizar(): String = "documento digitalizado"
}

fun relatorio(impressora: Impressora) {
    impressora.imprimir("relatório")
}

fun main() {
    relatorio(ImpressoraSimples())
    relatorio(Multifuncional())
    val scanner: Digitalizadora = Multifuncional()
    println(scanner.digitalizar())
}

6. D: inversão de dependência

Módulos de alto nível devem depender de abstrações, não de implementações concretas. Aqui ServicoPedido cria o enviador sozinho, e por isso testá-lo sempre dispara o envio SMTP:

class SmtpEnviador {
    fun enviar(destino: String, texto: String) {
        println("SMTP para $destino: $texto")
    }
}

class ServicoPedido {
    private val email = SmtpEnviador()

    fun finalizar(cliente: String, total: Int) {
        email.enviar(cliente, "pedido de total $total confirmado")
    }
}

fun main() {
    ServicoPedido().finalizar("ana@exemplo.com", 75)
}

A versão corrigida recebe a abstração pelo construtor, e o teste usa um notificador que só guarda o que receberia:

interface Notificador {
    fun enviar(destino: String, texto: String)
}

class SmtpEnviador : Notificador {
    override fun enviar(destino: String, texto: String) {
        println("SMTP para $destino: $texto")
    }
}

class NotificadorFalso : Notificador {
    val enviados = mutableListOf<String>()

    override fun enviar(destino: String, texto: String) {
        enviados += "$destino|$texto"
    }
}

class ServicoPedido(private val notificador: Notificador) {
    fun finalizar(cliente: String, total: Int) {
        notificador.enviar(cliente, "pedido de total $total confirmado")
    }
}

fun main() {
    val falso = NotificadorFalso()
    ServicoPedido(falso).finalizar("ana@exemplo.com", 75)
    println("capturado: ${falso.enviados}")
    ServicoPedido(SmtpEnviador()).finalizar("bia@exemplo.com", 40)
}
capturado: [ana@exemplo.com|pedido de total 75 confirmado]
SMTP para bia@exemplo.com: pedido de total 40 confirmado

A injeção pelo construtor é a forma mais comum de cumprir DIP, como mostra Injeção de dependência e IoC no Spring. A figura compara as duas formas:

Antes, ServicoPedido cria o SmtpEnviador e fica preso a ele; depois, main injeta um Notificador, interface que SmtpEnviador e NotificadorFalso implementam.
figura 1 · Dependência criada dentro da classe e dependência injetada pelo construtor

À esquerda, a dependência é concreta, em rosa. À direita, ServicoPedido depende só da interface, em verde. A seta laranja mostra main entregando a implementação.

7. padrões de projeto

Em cada padrão abaixo, a pergunta é o que Kotlin já oferece.

strategy

Em Kotlin, a função é um valor, então o parâmetro recebe a lambda:

class Pedido(val peso: Int, private val calcularFrete: (Int) -> Int) {
    fun totalFrete(): Int = calcularFrete(peso)
}

fun freteEconomico(peso: Int): Int = peso + 4

fun main() {
    val expresso = Pedido(5) { it * 3 }
    val economico = Pedido(5, ::freteEconomico)
    println("expresso: ${expresso.totalFrete()}")
    println("economico: ${economico.totalFrete()}")
}

Uma função de nível superior também entra com ::. Uma interface comum vale quando a estratégia tem estado ou vários métodos.

factory

O companion object guarda a fábrica, que pode usar o construtor privado. Este exemplo recusa cupons fora da faixa:

class Cupom private constructor(val codigo: String, val percentual: Int) {
    companion object {
        fun criar(codigo: String, percentual: Int): Cupom {
            require(percentual in 1..50) { "percentual fora da faixa: $percentual" }
            return Cupom(codigo.uppercase(), percentual)
        }
    }
}

fun main() {
    val cupom = Cupom.criar("natal", 10)
    println("${cupom.codigo} ${cupom.percentual}%")
    try {
        Cupom.criar("erro", 90)
    } catch (e: IllegalArgumentException) {
        println(e.message)
    }
}

Sem estado nem construtor privado, uma função de nível superior basta.

decorator

Decorator envolve um objeto com outro da mesma interface. Com by, o decorador escreve só o que muda:

interface Notificador {
    fun enviar(mensagem: String): String
}

class EmailNotificador : Notificador {
    override fun enviar(mensagem: String): String = "email: $mensagem"
}

class ComLog(private val inner: Notificador) : Notificador by inner {
    override fun enviar(mensagem: String): String {
        println("log: enviando '$mensagem'")
        return inner.enviar(mensagem)
    }
}

class ComLimite(private val inner: Notificador, private val max: Int) : Notificador by inner {
    override fun enviar(mensagem: String): String = inner.enviar(mensagem.take(max))
}

fun main() {
    val notificador: Notificador = ComLog(ComLimite(EmailNotificador(), 10))
    println(notificador.enviar("Seu pedido saiu para entrega hoje"))
}
log: enviando 'Seu pedido saiu para entrega hoje'
email: Seu pedido

O log aparece antes do envio porque ComLog é o envoltório de fora. A figura mostra a ida e a volta pela cadeia:

Cadeia de decoradores: ComLog e ComLimite chamam o próximo objeto com a mesma interface, e a resposta volta pelo mesmo caminho até o main.
figura 2 · Cadeia de decoradores: cada envoltório chama o próximo e a resposta volta

Atenção: se a interface tiver método padrão que chama outro método dela, o by não intercepta a chamada interna. É o caso de Interfaces, delegação e classes open.

builder

Construtores com muitos parâmetros opcionais viram uma fila de null. Parâmetros nomeados resolvem:

data class Relatorio(
    val titulo: String = "sem título",
    val colunas: List<String> = emptyList(),
    val formato: String = "pdf",
)

fun main() {
    val vendas = Relatorio(titulo = "Vendas", colunas = listOf("data", "total"))
    println(vendas)
    println(vendas.copy(formato = "csv"))
}

O builder ainda se justifica com etapas dependentes que precisam ser validadas em conjunto, ou para uso em Java, que não tem parâmetros nomeados.

observer

Observer avisa quem se interessa quando um valor muda. Em Kotlin, o caminho é um fluxo coletado por uma corrotina:

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield

class Estoque {
    private val _nivel = MutableStateFlow(10)
    val nivel: StateFlow<Int> = _nivel.asStateFlow()

    fun retirar(quantidade: Int) {
        _nivel.value -= quantidade
    }
}

fun main() = runBlocking {
    val estoque = Estoque()
    val assinante = launch {
        estoque.nivel.collect { println("nível: $it") }
    }
    yield()
    estoque.retirar(3)
    yield()
    estoque.retirar(2)
    yield()
    assinante.cancel()
}
nível: 10
nível: 7
nível: 5

O primeiro valor é 10 porque o assinante recebe o estado atual. Os yield() deixam a coleta rodar entre as retiradas. Sem eles, o 7 some, porque o StateFlow guarda só o valor mais recente. Não rodei a versão sem os yield(), então essa parte é uma explicação, não um resultado medido.

singleton

object cria uma instância única sem código extra, mas vira variável global com outro nome:

object Contador {
    var total = 0
}

fun registrarVenda(valor: Int) {
    Contador.total += valor
}

fun main() {
    registrarVenda(50)
    registrarVenda(30)
    println("total global: ${Contador.total}")
}
total global: 80

O total 80 vale para o programa inteiro, e um teste no mesmo processo herda esse valor. Em Spring, um bean singleton injetado costuma ser melhor, como explica Encapsulamento, visibilidade, object e companion ao tratar de object com var.

8. quando o Kotlin dispensa o padrão, e quando SOLID vira exagero

A tabela resume o que substitui cada padrão e quando ele ainda merece discussão:

PadrãoO que o Kotlin ofereceQuando ainda pensar no padrão
Strategyfunção como valorestratégia com estado e vários métodos
Factorycompanion object, função de nível superiorregras de criação que variam por tipo
Decoratordelegação com byinterface com métodos padrão que se chamam
Builderparâmetros nomeados e copyetapas dependentes e uso por Java
ObserverFlow e StateFlowsistemas que só oferecem listeners
Singletonobjectestado que precisa ser trocado em teste

SOLID vira exagero quando a abstração não tem segunda implementação. Uma interface PedidoService com uma única PedidoServiceImpl só obriga a pular entre dois arquivos. Abstraia quando a segunda variação aparecer, ou quando for preciso trocar a dependência num teste.

9. object calisthenics

Em 2008, Jeff Bay publicou em The ThoughtWorks Anthology nove regras de object calisthenics, uma ginástica de objetos. O post do Microsoft Cellfish, que as testou num kata, diz que elas não se destinam ao trabalho do dia a dia, e sim a um problema pequeno usado como treino (tradução livre).

Este código viola três regras: quatro níveis de indentação num método, else em todos os ramos e primitivos para valor e cupom:

fun cobrar(valor: Int, cupom: String?, saldo: Int): String {
    if (valor > 0) {
        if (cupom != null) {
            val comDesconto = valor * 90 / 100
            if (comDesconto <= saldo) {
                return "cobrado $comDesconto"
            } else {
                return "saldo insuficiente"
            }
        } else {
            if (valor <= saldo) {
                return "cobrado $valor"
            } else {
                return "saldo insuficiente"
            }
        }
    } else {
        return "valor inválido"
    }
}

fun main() {
    println(cobrar(100, "NATAL", 50))
    println(cobrar(100, null, 200))
    println(cobrar(-5, null, 200))
}
saldo insuficiente
cobrado 100
valor inválido

Com retorno antecipado e tipos próprios:

@JvmInline
value class Valor(val centavos: Int) {
    init {
        require(centavos > 0) { "valor inválido: $centavos" }
    }
}

data class Cupom(val codigo: String, val percentual: Int)

fun cobrar(valor: Valor, cupom: Cupom?, saldo: Int): String {
    val aPagar = cupom?.let { valor.centavos * (100 - it.percentual) / 100 } ?: valor.centavos
    if (aPagar > saldo) return "saldo insuficiente"
    return "cobrado $aPagar"
}

fun main() {
    println(cobrar(Valor(100), Cupom("NATAL", 10), 50))
    println(cobrar(Valor(100), null, 200))
    println(runCatching { Valor(-5) }.exceptionOrNull()?.message)
}
saldo insuficiente
cobrado 100
valor inválido: -5

O value class rejeita valor não positivo na criação, e não no meio da cobrança. O ?: com let faz o papel do else.

A regra 8 pede coleção de primeira classe. Em Kotlin, List já é somente leitura, e a classe ganha valor ao reunir comportamento:

@JvmInline
value class Valor(val centavos: Int)

class Itens(private val lista: List<Valor>) {
    fun adicionar(valor: Valor): Itens = Itens(lista + valor)

    fun total(): Int = lista.sumOf { it.centavos }

    fun maiorQue(limite: Int): Itens = Itens(lista.filter { it.centavos > limite })
}

fun main() {
    val carrinho = Itens(emptyList()).adicionar(Valor(50)).adicionar(Valor(100))
    println("total: ${carrinho.total()}")
    println("acima de 60: ${carrinho.maiorQue(60).total()}")
}

A regra 4 colide com pipelines. pesos.filter { it > 0 }.map { it * 2 }.sum() tem três pontos e não atravessa objetos. A regra combate a navegação como pedido.cliente.endereco.cidade, e nesse caso convém perguntar ao objeto: pedido.cidadeDeEntrega().

A tabela dá o veredito para Kotlin e o princípio SOLID mais próximo. A correspondência é aproximada, porque as regras tratam da forma do código, e não de dependências.

RegraVeredito em KotlinRelação com SOLID
1. Um nível de indentação por métodoCasa bem com retorno antecipadoResponsabilidade única
2. Sem elseCasa bem com when e sealedAberto e fechado
3. Encapsular primitivosCasa bem com value classResponsabilidade única
4. Um ponto por linhaConflita com pipelinesFora do SOLID; Lei de Demeter
5. Não abreviarCasa bem, com exceção de it em lambdasLegibilidade
6. Entidades pequenasCasa bem; o tamanho depende do domínioResponsabilidade única
7. Até duas variáveis de instânciaExagero para data class coesaSinal de coesão
8. Coleções de primeira classeCasa bem com List e métodos de domínioResponsabilidade única
9. Sem getters, setters ou propertiesConflita com val; a intenção valeEncapsulamento

Duas pedem atenção. A de duas variáveis de instância é exagero para dados: uma data class coesa de cinco campos não ganha nada com divisão. A de sem properties conflita com o idioma: val expõe leitura, e private set com funções de domínio cumpre a intenção de Bay, como em Encapsulamento, visibilidade, object e companion.

Calisthenics não substitui os princípios. Ele mostra, em exercício pequeno, sinais de responsabilidade demais.

Pergunta de entrevistaQuando SOLID vira exagero?

Quando a abstração não tem segunda implementação. Nesse caso, a interface só adiciona um arquivo para navegar, como um PedidoService com uma única PedidoServiceImpl. Os princípios valem quando há mudança provável ou quando um teste precisa trocar a dependência. Eu aplico cada princípio quando a dor aparece, e não antes, porque código sobre-abstraído também é difícil de mudar.

10. erros comuns e boas práticas

Aplicar os cinco princípios de uma vez é o erro mais frequente. Usar else para calar o compilador engole casos novos. Transformar object em depósito de estado assombra o teste.

11. exercícios com resposta

exercício 1: contar os envios

Escreva um decorator ComContagem, sem alterar EmailNotificador. Depois de dois envios, o contador deve valer 2.

interface Notificador {
    fun enviar(mensagem: String): String
}

class EmailNotificador : Notificador {
    override fun enviar(mensagem: String): String = "email: $mensagem"
}

class ComContagem(private val inner: Notificador) : Notificador by inner {
    var enviadas = 0
        private set

    override fun enviar(mensagem: String): String {
        enviadas++
        return inner.enviar(mensagem)
    }
}

fun main() {
    val notificador = ComContagem(EmailNotificador())
    notificador.enviar("oi")
    notificador.enviar("tchau")
    println("enviadas: ${notificador.enviadas}")
}
enviadas: 2

12. glossário

  • SOLID: sigla dos cinco princípios: responsabilidade única, aberto e fechado, substituição de Liskov, segregação de interfaces e inversão de dependência.
  • Acoplamento: grau em que uma parte do código depende dos detalhes de outra.
  • fun interface: interface com um único membro abstrato, que pode ser implementada com lambda.
  • StateFlow: fluxo que guarda o valor atual e o entrega a quem coleta.

o que não foi confirmado ou medido

A aula não mede desempenho. Algumas saídas não foram copiadas para a nota: o trecho ISP corrigido, Strategy, Factory, Builder e Itens. Os trechos foram compilados, mas essas saídas não estão aqui. A regra de subclasses do sealed não foi testada entre pacotes. As datas de origem são aproximadas. Os livros não foram consultados diretamente, e a lista de calisthenics vem do post do Cellfish. Não confirmei os limites numéricos que alguns textos atribuem às regras 6 e 7.

Fontes

Consultadas em 04/11/2026.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?