---
title: "Stamp vs Control Coupling: Where Does the Write Decision Live?"
url: https://tiarebalbi.com/en/blog/stamp-vs-control-coupling-where-the-decision-lives
markdown: https://tiarebalbi.com/en/blog/stamp-vs-control-coupling-where-the-decision-lives.md
description: "I take a Kotlin applyDiscount through three signatures — flag-stacked, aggregate handoff, purpose-built snapshot — and find a heuristic for picking one."
author: "Tiarê Balbi Bonamini"
locale: en
published: 2026-09-07
updated: 2026-09-07
category: "Engineering"
tags: ["kotlin", "spring", "coupling", "ddd", "refactoring", "software-design"]
translation: https://tiarebalbi.com/pt-br/blog/stamp-vs-control-coupling-where-the-decision-lives
---
# Stamp vs Control Coupling: Where Does the Write Decision Live?

While going through a Kotlin/Spring project in my own notes, I kept coming back to a `PricingService.applyDiscount(...)` method whose signature kept mutating. One version took four booleans. A later version took the whole `Order` aggregate. Both sit in some textbook's "bad coupling" column, and the textbook was not helping me pick between them. I went back to the Constantine and Yourdon taxonomy and re-read it with that refactor in front of me.

The vocabulary calls them control and stamp coupling. Control coupling is when the caller passes a what-to-do flag and the callee branches on it. Stamp coupling is when the caller passes a composite record and the callee uses only parts of it. Data coupling, ranked better than both, is when the callee receives exactly the fields it needs and nothing more. The ranking is correct for a first read but unhelpful as a design rule — every non-trivial service lives in the middle two rows during most of its working hours.

The reframing I landed on: control coupling and stamp coupling are two answers to the same question. Where does the write decision live? In the flag version, it lives in the caller — the caller already knows whether the booking is premium, whether a coupon applies, whether the customer is corporate. In the aggregate version, the caller steps back and hands a snapshot of the world over; the callee re-derives the decision from fields on that snapshot. The textbook ranking does not tell me which answer is right for a given invariant. It tells me that "pure data" is the safest default when the two sides of the call don't trust each other's schema.

## Working one method through three signatures

Discount application is the example I keep reaching for. Small enough to fit in one file. Big enough to show what each coupling style buys and costs when a new rule lands.

```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 on the boundary. Caller owns the decision.
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 — whole aggregate plus a small context. Service re-derives the decision.
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 — purpose-built snapshot. Caller declares intent as data; service just applies.
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))))
}
```

Save as `pricing.kt` and run with `kotlinc pricing.kt -include-runtime -d pricing.jar && java -jar pricing.jar`. All three versions print `88.20`.

## What each version actually buys

V1 is control coupling. The caller decides, per invocation, which rules to apply. When a staff discount rule lands, the signature grows a fourth, then a fifth boolean, and every call site has to be audited. Fowler's bliki entry on flag arguments is the canonical write-up of that pain — his short line is "rather than use a flag argument, I prefer to define separate methods." The separate-methods fix works when the space is two shapes. It collapses when a single invocation can combine three or four rules independently. That is where control coupling with flags is not the wrong shape — it is just named wrong. A `Set<Rule>` or a sealed `Command` hierarchy is the same idea with the type system behind it, and a `when` block that will stop compiling the day a new rule lands.

V2 is stamp coupling in its most defensible form. The caller hands the aggregate to the service; the service reads the fields it cares about. It reads better than V1 on the happy path. It also quietly changes who owns the decision. The service is now reading `order.customer.tier` and `order.coupon`, which are two new transitive dependencies. In-process, that is a compile error and a one-file fix the day `tier` becomes `plan`. Across a service boundary, the same shape becomes schema coupling: caller and callee have to be deployed in lockstep, or one tolerates both shapes during a migration window.

V3 is deliberate data coupling. The caller constructs a `PricingRequest` describing what it wants — the subtotal and a set of rules — and the service applies them. This is V1's intent made explicit as data. The caller still owns the decision, but there are no booleans on the API and no passthrough of the aggregate's internals. The cost is a small translation step on the caller's side. When the caller already has an `Order` in hand, someone has to turn it into a `PricingRequest`. Usually that someone is a thin orchestrator above the service.

![](https://rso2zax703psuq0y.public.blob.vercel-storage.com/postscontent/1776807831160-qwhnx964ac.jpeg)

## The heuristic I landed on

Put the decision on the side that owns the invariant. Then let that choice pick the coupling style.

If the invariant is a property of the order itself ("orders with a valid coupon get 2%"), the aggregate knows it. Move the decision there. A method on `Order` called `priceAfterDiscounts(context)` is V2 taken all the way — no `PricingService` on that invariant's path at all. Vernon's rules in _Implementing Domain-Driven Design_ point in this direction: model true invariants in consistency boundaries and keep aggregates small. Fowler's anemic-domain-model critique says the same thing from the opposite end — if the service layer is doing all the deciding, the domain model is paying O/R mapping costs without earning them back.

If the invariant is a property of the caller ("the staff app grants 20% on behalf of an employee"), the caller owns it. Push the decision out to V1 or V3. The caller already knows it is the staff app; it should say so. Hiding that behind a field on the aggregate ("order.requestedByStaff") sneaks a workflow concern into entity state and makes the entity carry knowledge about who called it.

If the invariant lives genuinely split — the caller knows the campaign, the aggregate knows the customer tier — that is a modeling problem, not a coupling problem. No choice of coupling papers over it. The fix is to reshape the boundary, not the parameter list.

## What goes wrong across a network boundary

The in-process version of this argument is cheap to get wrong. The across-services version is not. Passing an aggregate over the wire freezes that schema into the contract. Every field the callee reads becomes a protected field in the payload. In a throwaway Spring Boot project I set up to check this, I renamed `Customer.tier: String` to `Customer.plan: String` on the sender side and the pricing service started 500-ing on every request with a Jackson deserialization error until I redeployed it. The Jackson defaults around unknown and missing properties can be tuned, but that just shifts the failure — the shape is now a contract, whether it was meant to be or not.

V3's shape survives that change. The caller builds a `PricingRequest` from whatever it has; when `tier` becomes `plan`, only the caller's translation step needs updating. That is the core of the command-versus-event argument at a service boundary. Fowler's piece on event-driven styles names event-carried state transfer as one of four distinct patterns hiding behind the same word, and the failure mode he calls out — events used as passive-aggressive commands — is the same shape as stamp coupling over the wire. Command payloads are small, purpose-built, and do not leak the sender's internal model.

## When to reach for which

* Inside a module, with the aggregate's own behavior: V2 is usually right. Pass the aggregate, let the method live on the entity when the invariant is the entity's.
* Inside a module, with a cross-cutting rule engine: V3. Make the rules first-class data, apply them in one place.
* Across a service boundary: V3 by default. Reach for V2 only when the boundary is internal to one team and schema ownership is shared on both sides of the wire.
* On a boolean with two values the space will never grow past: V1 with two methods (Fowler's fix). Don't build `Set<Rule>` if `publishDraft()` and `publishLive()` cover the entire space.

## Takeaways

* Name the invariant before picking the coupling. The parameter list is the second question, not the first.
* A `Set<Rule>` or sealed `Command` hierarchy is control coupling made type-safe, and it is often the right trade over both bare booleans and fat aggregates.
* Stamp coupling is nearly free in-process and expensive across services. The same code shape has two different prices depending on where the call lands.
* Don't refactor flag parameters on reflex. If the caller actually knows the answer, the flag is telling the truth about ownership — the fix is better names, not a different shape.
