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.
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.
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>sepublishDraft()epublishLive()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 hierarquiaCommandsealed é 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.
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
As Três Fases de Movimentação de Dados Online, Lidas Como Três Invariantes
Todo diagrama de migração online que vi se parece com o mesmo — três setas entre duas caixas — e pula a parte que importa: para que cada fase realmente serve. Estas são minhas anotações depois de reler os escritos da Stripe sobre DocDB junto com a documentação de cutover do gh-ost e do Vitess, destilados em três invariantes que posso plotar em gráficos e usar como portões de decisão.
Kotlin 2.4: As Três Mudanças que Moveram Minha Mão no Teclado
O Kotlin 2.4.0 chegou com um changelog extenso, mas apenas três recursos mudaram a forma como eu realmente digito: context parameters estáveis, explicit backing fields e (ainda atrás de uma flag) name-based destructuring. Este é o meu recorte de engenheiro backend, verificado contra o compilador 2.4.0, mais a remoção do K1 que tive que colocar no calendário.
Auditando um serviço Scala contra as quatro restrições regenerativas de Chad Fowler
Levei um serviço Scala de processamento de pedidos das minhas anotações pelas quatro restrições regenerativas de Chad Fowler. Duas passaram de graça, duas forçariam um redesign de verdade. Aqui está o que aprendi sobre onde "módulo fracamente acoplado" termina e "componente regenerativo" começa, e quais partes do redesign eu de fato pagaria.