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
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:
À 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:
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ão | O que o Kotlin oferece | Quando ainda pensar no padrão |
|---|---|---|
| Strategy | função como valor | estratégia com estado e vários métodos |
| Factory | companion object, função de nível superior | regras de criação que variam por tipo |
| Decorator | delegação com by | interface com métodos padrão que se chamam |
| Builder | parâmetros nomeados e copy | etapas dependentes e uso por Java |
| Observer | Flow e StateFlow | sistemas que só oferecem listeners |
| Singleton | object | estado 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.
| Regra | Veredito em Kotlin | Relação com SOLID |
|---|---|---|
| 1. Um nível de indentação por método | Casa bem com retorno antecipado | Responsabilidade única |
2. Sem else | Casa bem com when e sealed | Aberto e fechado |
| 3. Encapsular primitivos | Casa bem com value class | Responsabilidade única |
| 4. Um ponto por linha | Conflita com pipelines | Fora do SOLID; Lei de Demeter |
| 5. Não abreviar | Casa bem, com exceção de it em lambdas | Legibilidade |
| 6. Entidades pequenas | Casa bem; o tamanho depende do domínio | Responsabilidade única |
| 7. Até duas variáveis de instância | Exagero para data class coesa | Sinal de coesão |
| 8. Coleções de primeira classe | Casa bem com List e métodos de domínio | Responsabilidade única |
| 9. Sem getters, setters ou properties | Conflita com val; a intenção vale | Encapsulamento |
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.
- Kotlin, interfaces funcionais: https://kotlinlang.org/docs/fun-interfaces.html
- Kotlin, delegação: https://kotlinlang.org/docs/delegation.html
- Kotlin, classes seladas: https://kotlinlang.org/docs/sealed-classes.html
- Wikipedia, SOLID: https://en.wikipedia.org/wiki/SOLID
- Microsoft Learn (arquivo do blog Cellfish), “Object Calisthenics: First contact”: https://learn.microsoft.com/en-us/archive/blogs/cellfish/object-calisthenics-first-contact
- Gamma, Helm, Johnson e Vlissides, Design Patterns, 1994; Bertrand Meyer, Object-Oriented Software Construction, 1988; Jeff Bay, “Object Calisthenics”, The ThoughtWorks Anthology, 2008 (livros, não consultados diretamente).
- Erros e saídas executados com Kotlin 2.4.21, Gradle 9.7.0 e JDK 21 e 25, em 04/11/2026.