Pular para o conteúdo principal
Engineering9 min de leitura

Stamp vs Control Coupling: Onde Mora a Decisão de Escrita?

Control coupling e stamp coupling ficam lado a lado na taxonomia de Constantine, classificados piores que data coupling e pouco mais. Quando reli isso com um refactor real de applyDiscount em Kotlin à minha frente, o ranking não era a parte interessante. A pergunta que ele não responde é onde a decisão de escrita de fato mora.

Todos os Posts
2/4

Enquanto eu revisitava um projeto Kotlin/Spring nas minhas próprias anotações, eu continuava voltando a um método PricingService.applyDiscount(...) cuja assinatura vivia mutando. Uma versão recebia quatro booleans. Uma versão posterior recebia o agregado Order inteiro. Ambas ficam na coluna de "acoplamento ruim" de algum livro didático, e o livro não estava me ajudando a escolher entre elas. Voltei à taxonomia de Constantine e Yourdon e a reli com aquele refactor diante de mim.

O vocabulário chama isso de control coupling e stamp coupling. Control coupling é quando o chamador passa uma flag de o-que-fazer e o chamado ramifica com base nela. Stamp coupling é quando o chamador passa um registro composto e o chamado usa apenas partes dele. Data coupling, classificado como melhor que ambos, é quando o chamado recebe exatamente os campos que precisa e nada mais. O ranking está correto para uma primeira leitura, mas é pouco útil como regra de design — todo serviço não trivial vive nas duas linhas do meio durante a maior parte do seu horário de trabalho.

A reformulação em que acabei pousando: control coupling e stamp coupling são duas respostas para a mesma pergunta. Onde mora a decisão de escrita? Na versão com flags, ela mora no chamador — o chamador já sabe se a reserva é premium, se um cupom se aplica, se o cliente é corporativo. Na versão com o agregado, o chamador dá um passo atrás e entrega um snapshot do mundo; o chamado re-deriva a decisão a partir de campos nesse snapshot. O ranking do livro didático não me diz qual resposta é certa para um dado invariante. Ele me diz que "data puro" é o padrão mais seguro quando os dois lados da chamada não confiam no schema um do outro.

Trabalhando um método por três assinaturas

Aplicação de desconto é o exemplo ao qual eu sempre volto. Pequeno o suficiente para caber em um arquivo. Grande o suficiente para mostrar o que cada estilo de coupling compra e custa quando uma regra nova chega.

kotlin
import java.math.BigDecimal
import java.math.RoundingMode

private val COUPON = BigDecimal("0.02")
private val CORP = BigDecimal("0.10")
private val BF = BigDecimal("0.10")
private fun BigDecimal.cents() = setScale(2, RoundingMode.HALF_EVEN)

data class Customer(val id: String, val tier: String)
data class Line(val sku: String, val qty: Int, val price: BigDecimal)
data class Order(val id: String, val customer: Customer, val lines: List<Line>, val coupon: String?) {
    val subtotal: BigDecimal
        get() = lines.fold(BigDecimal.ZERO) { a, l -> a + l.price.multiply(BigDecimal(l.qty)) }
}

// V1 — flags na fronteira. Chamador é dono da decisão.
class PricingV1 {
    fun applyDiscount(o: Order, useCoupon: Boolean, corporate: Boolean, blackFriday: Boolean): BigDecimal {
        var t = o.subtotal
        if (useCoupon && o.coupon != null) t -= t.multiply(COUPON)
        if (corporate) t -= t.multiply(CORP)
        if (blackFriday) t -= t.multiply(BF)
        return t.cents()
    }
}

// V2 — agregado inteiro mais um pequeno contexto. Serviço re-deriva a decisão.
data class Context(val blackFriday: Boolean)
class PricingV2 {
    fun applyDiscount(o: Order, ctx: Context): BigDecimal {
        var t = o.subtotal
        if (o.coupon != null) t -= t.multiply(COUPON)
        if (o.customer.tier == "CORP") t -= t.multiply(CORP)
        if (ctx.blackFriday) t -= t.multiply(BF)
        return t.cents()
    }
}

// V3 — snapshot sob medida. Chamador declara intenção como dado; serviço só aplica.
data class PricingRequest(val subtotal: BigDecimal, val rules: Set<Rule>) {
    enum class Rule { COUPON, CORPORATE, BLACK_FRIDAY }
}
class PricingV3 {
    fun applyDiscount(req: PricingRequest): BigDecimal {
        var t = req.subtotal
        if (PricingRequest.Rule.COUPON in req.rules) t -= t.multiply(COUPON)
        if (PricingRequest.Rule.CORPORATE in req.rules) t -= t.multiply(CORP)
        if (PricingRequest.Rule.BLACK_FRIDAY in req.rules) t -= t.multiply(BF)
        return t.cents()
    }
}

fun main() {
    val o = Order("o1", Customer("c1", "CORP"), listOf(Line("A", 2, BigDecimal("50.00"))), "SAVE2")
    println(PricingV1().applyDiscount(o, useCoupon = true, corporate = true, blackFriday = false))
    println(PricingV2().applyDiscount(o, Context(blackFriday = false)))
    println(PricingV3().applyDiscount(PricingRequest(o.subtotal, setOf(PricingRequest.Rule.COUPON, PricingRequest.Rule.CORPORATE))))
}

Salve como pricing.kt e rode com kotlinc pricing.kt -include-runtime -d pricing.jar && java -jar pricing.jar. Todas as três versões imprimem 88.20.

O que cada versão compra, na prática

V1 é control coupling. O chamador decide, por invocação, quais regras aplicar. Quando uma regra de desconto para funcionários chega, a assinatura ganha um quarto e depois um quinto boolean, e todos os call sites têm que ser auditados. A entrada do bliki do Fowler sobre argumentos-flag é o texto canônico sobre essa dor — sua frase curta é "em vez de usar um argumento-flag, eu prefiro definir métodos separados". A correção por métodos separados funciona quando o espaço são duas formas. Ela colapsa quando uma única invocação pode combinar três ou quatro regras de forma independente. É aí que control coupling com flags não é a forma errada — ela está apenas mal nomeada. Um Set<Rule> ou uma hierarquia Command sealed são a mesma ideia com o sistema de tipos por trás, e um bloco when que vai parar de compilar no dia em que uma nova regra chegar.

V2 é stamp coupling em sua forma mais defensável. O chamador entrega o agregado ao serviço; o serviço lê os campos com que se importa. Lê melhor que V1 no caminho feliz. Também muda silenciosamente quem é dono da decisão. O serviço agora está lendo order.customer.tier e order.coupon, que são duas novas dependências transitivas. In-process, isso é um erro de compilação e uma correção de um arquivo no dia em que tier vira plan. Atravessando uma fronteira de serviço, a mesma forma vira acoplamento de schema: chamador e chamado têm que ser deployados em lockstep, ou um deles tolera ambas as formas durante uma janela de migração.

V3 é data coupling deliberado. O chamador constrói um PricingRequest descrevendo o que quer — o subtotal e um conjunto de regras — e o serviço as aplica. Isso é a intenção do V1 tornada explícita como dado. O chamador ainda é dono da decisão, mas não há booleans na API nem passagem dos internos do agregado. O custo é um pequeno passo de tradução no lado do chamador. Quando o chamador já tem um Order na mão, alguém precisa transformá-lo em um PricingRequest. Normalmente esse alguém é um orquestrador fino acima do serviço.

A heurística em que pousei

Ponha a decisão no lado que é dono do invariante. Depois deixe essa escolha decidir o estilo de coupling.

Se o invariante é uma propriedade do próprio pedido ("pedidos com um cupom válido ganham 2%"), o agregado sabe disso. Mova a decisão para lá. Um método em Order chamado priceAfterDiscounts(context) é V2 levado até o fim — nenhum PricingService no caminho daquele invariante. As regras de Vernon em Implementing Domain-Driven Design apontam nessa direção: modele invariantes verdadeiros em fronteiras de consistência e mantenha agregados pequenos. A crítica de Fowler ao anemic domain model diz a mesma coisa pela ponta oposta — se a camada de serviço está tomando todas as decisões, o modelo de domínio está pagando o custo do mapeamento O/R sem recuperá-lo.

Se o invariante é uma propriedade do chamador ("o app da equipe concede 20% em nome de um funcionário"), o chamador é dono dele. Empurre a decisão para fora, para V1 ou V3. O chamador já sabe que é o app da equipe; ele deveria dizer isso. Esconder isso atrás de um campo no agregado ("order.requestedByStaff") contrabandeia uma preocupação de workflow para o estado da entidade e faz a entidade carregar conhecimento sobre quem a chamou.

Se o invariante vive genuinamente dividido — o chamador sabe a campanha, o agregado sabe o tier do cliente — isso é um problema de modelagem, não de coupling. Nenhuma escolha de coupling encobre isso. A correção é reformatar a fronteira, não a lista de parâmetros.

O que dá errado atravessando uma fronteira de rede

A versão in-process desse argumento é barata de errar. A versão entre serviços não é. Passar um agregado pelo fio congela aquele schema no contrato. Todo campo que o chamado lê vira um campo protegido no payload. Em um projeto Spring Boot descartável que montei para verificar isso, renomeei Customer.tier: String para Customer.plan: String do lado do remetente e o serviço de pricing começou a dar 500 em toda requisição com um erro de deserialização do Jackson até eu redeployar. Os padrões do Jackson em torno de propriedades desconhecidas e ausentes podem ser ajustados, mas isso apenas desloca a falha — a forma agora é um contrato, quisesse ela ser ou não.

A forma do V3 sobrevive a essa mudança. O chamador constrói um PricingRequest a partir do que tiver; quando tier vira plan, só o passo de tradução do chamador precisa ser atualizado. Esse é o cerne do argumento command-versus-event em uma fronteira de serviço. O texto de Fowler sobre estilos orientados a eventos nomeia event-carried state transfer como um de quatro padrões distintos escondidos atrás da mesma palavra, e o modo de falha que ele aponta — eventos usados como comandos passivo-agressivos — tem a mesma forma de stamp coupling pelo fio. Payloads de comando são pequenos, sob medida, e não vazam o modelo interno do remetente.

Quando recorrer a qual

  • Dentro de um módulo, com o próprio comportamento do agregado: V2 geralmente é o certo. Passe o agregado, deixe o método viver na entidade quando o invariante é da entidade.
  • Dentro de um módulo, com um motor de regras transversal: V3. Torne as regras dados de primeira classe, aplique-as em um único lugar.
  • Atravessando uma fronteira de serviço: V3 por padrão. Só recorra a V2 quando a fronteira é interna a um time e a propriedade do schema é compartilhada dos dois lados do fio.
  • Em um boolean com dois valores cujo espaço nunca vai crescer: V1 com dois métodos (a correção do Fowler). Não construa Set<Rule> se publishDraft() e publishLive() cobrem todo o espaço.

Lições

  • Nomeie o invariante antes de escolher o coupling. A lista de parâmetros é a segunda pergunta, não a primeira.
  • Um Set<Rule> ou uma hierarquia Command sealed é control coupling tornado type-safe, e muitas vezes é o trade certo tanto sobre booleans pelados quanto sobre agregados gordos.
  • Stamp coupling é quase de graça in-process e caro entre serviços. A mesma forma de código tem dois preços diferentes dependendo de onde a chamada pousa.
  • Não refatore parâmetros-flag por reflexo. Se o chamador de fato sabe a resposta, a flag está dizendo a verdade sobre posse — a correção são nomes melhores, não uma forma diferente.
Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.