<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Dionisio Cortés Fernández]]></title><description><![CDATA[Dionisio Cortés Fernández]]></description><link>https://blog.dionisioc.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Dionisio Cortés Fernández</title><link>https://blog.dionisioc.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 14 Sep 2026 19:55:26 GMT</lastBuildDate><atom:link href="https://blog.dionisioc.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[SOLID Without the Acronym: It's Just Cohesion and Coupling]]></title><description><![CDATA[SOLID isn't really five independent principles. It's mostly two long-standing software design ideas expressed in different ways.

High cohesion — keep the things that change together, together.

Low c]]></description><link>https://blog.dionisioc.dev/solid-cohesion-coupling</link><guid isPermaLink="true">https://blog.dionisioc.dev/solid-cohesion-coupling</guid><category><![CDATA[Kotlin]]></category><category><![CDATA[SOLID principles]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[software design]]></category><category><![CDATA[clean code]]></category><dc:creator><![CDATA[Dionisio Cortés Fernández]]></dc:creator><pubDate>Mon, 14 Sep 2026 18:18:43 GMT</pubDate><content:encoded><![CDATA[<p>SOLID isn't really five independent principles. It's mostly two long-standing software design ideas expressed in different ways.</p>
<ul>
<li><p><strong>High cohesion</strong> — keep the things that change together, together.</p>
</li>
<li><p><strong>Low coupling</strong> — depend on stable abstractions, not volatile details.</p>
</li>
</ul>
<p>Every letter in SOLID is just a <em>named consequence</em> of one of those two forces. Here's the first cut:</p>
<table>
<thead>
<tr>
<th>Force</th>
<th>Principles</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Cohesion</strong></td>
<td>SRP, ISP</td>
</tr>
<tr>
<td><strong>Coupling</strong></td>
<td>OCP, LSP, DIP</td>
</tr>
</tbody></table>
<p>A first cut is all it is — the rest of the article complicates it three times, and the complications are where the thinking is. Two are worth flagging now. ISP is the contested one: the textbook files it under coupling, and the ISP section explains why both readings are right. LSP is the odd one out: it belongs under coupling because callers couple to the <em>base contract</em>, never to your subtype, but unlike the other four letters it isn't a dial you can turn too far. It's a correctness constraint — which is what makes it a <em>detector</em> rather than a design choice.</p>
<p>Once you see that, you stop memorizing and start deriving — and you learn when <em>not</em> to apply each one, because every single one of them has a cost. Applied without judgment, SOLID produces its own kind of unmaintainable code.</p>
<p>Every example lives in one system: the checkout slice of a payments product. One use case, end to end — <code>CheckoutService.checkout(cart)</code> prices the cart, charges a payment method through a gateway, records the order, and returns a result. Every principle below shows up because the domain <em>forces</em> it, and principles that share a codebase interact: you'll watch them repair each other.</p>
<p>For each principle: what it means, where it shows up here, and what it costs when you over-apply it.</p>
<hr />
<h2>S — Single Responsibility</h2>
<p><strong>Definition.</strong> A class should have one reason to change. The version that actually helps: <strong>one reason to change means one <em>actor</em></strong> — one group of people who can ask for that change. In our system the broken version is a <code>CheckoutManager</code> with both <code>total()</code> and <code>renderReceipt()</code>. It <em>feels</em> like one thing ("checkout"), but <code>total()</code> answers to Finance and <code>renderReceipt()</code> answers to Marketing. Two actors, two reasons to change, one class — that's the smell.</p>
<pre><code class="language-kotlin">class CheckoutManager(private val cart: Cart) {

    fun total(): Money =                             // answers to Finance
        cart.items.sum() - loyaltyDiscount()

    fun renderReceipt(): LoyaltyReceipt =            // answers to Marketing
        LoyaltyReceipt(cart.id.value, total(), rewarded = rewardedItems())  // "points earned on…"

    private fun loyaltyDiscount(): Money =           // Finance's rule, same source set
        rewardedItems().sum() / 10                   // 10% back on rewarded items

    private fun rewardedItems(): List&lt;Line&gt; = ...    // shared by both — and that's the trap
}
</code></pre>
<p>Here's how it goes wrong: Finance asks you to stop counting gift-wrap fees toward the loyalty discount. A developer edits <code>rewardedItems()</code> — the obvious place — and Marketing's receipt silently drops gift wrap from its "points earned on" line. The total moves exactly as Finance asked — every item still charged, a slightly smaller discount — so the diff looks correct. Nobody saw two departments in one edit. That shared private helper is coupling between actors, and no code review flags it, because the class has one name and one obvious topic.</p>
<p><strong>The same shape in the wild.</strong> You already apply SRP without naming it: the layered split. The controller changes when the <em>API shape</em> changes, the service when a <em>business rule</em> changes, the repository when <em>storage</em> changes — three reasons, three classes. In this system the same instinct fires once more <em>inside</em> the service layer, and it's the split <code>CheckoutManager</code> refused to make: pricing math is <code>PriceCalculator</code> (Finance's), receipt copy is <code>ReceiptFormatter</code> (Marketing's), orchestration is <code>CheckoutService</code> (the product flow). In the repo the gift-wrap edit is a <code>rewardGiftWrap</code> flag, so <code>SrpTest</code> holds both versions of the rule side by side and proves the customer is still billed for every item either way — what moves is the discount, and with it Marketing's receipt. The moment "who asks for changes to this?" gets two different answers, you're looking at two classes wearing one name.</p>
<p><strong>The trade-off.</strong> SRP has two failure modes. Under-apply it and you get the god class everyone warns about. But over-apply it and you get something just as bad and harder to spot: <strong>shotgun surgery</strong> — a single logical change now forces edits across ten tiny files, because you scattered things that actually change together. The dial between the two extremes is <strong>cohesion</strong>: <em>group what changes together.</em> Splitting by "this method feels different" is how you end up with the ten-file problem; splitting by "these change for different reasons" is SRP.</p>
<blockquote>
<p>If you remember one thing: SRP is the <strong>cohesion</strong> force. Too little separation and you get the god class; too much and you get shotgun surgery. The question is never "how small can this class be," it's "do these parts change for the same reason?"</p>
</blockquote>
<hr />
<h2>O — Open/Closed</h2>
<p><strong>Definition.</strong> A class should be <em>open for extension, closed for modification.</em> In practice that means: you should be able to add new behavior by adding a new class, not by editing an existing, tested one. The enemy this principle fights is the <code>if/else</code> that grows a new branch every time a payment method lands:</p>
<pre><code class="language-kotlin">// Every new payment method = reopen this function and risk the branches already here.
fun charge(type: String, amount: Money): PaymentResult {
    if (type == "card") {
        ...
    } else if (type == "paypal") {
        ...
    } else if (type == "bizum") {
        ...
    }   // &lt;- edit working code, again
    ...
}
</code></pre>
<p>The mechanism that buys you OCP is polymorphism: depend on an abstraction, and add a new <em>implementation</em> instead of a new <em>branch</em>.</p>
<pre><code class="language-kotlin">interface PaymentMethod {
    fun charge(order: OrderId, amount: Money): PaymentResult
}   // closed

class CardPayment : PaymentMethod { ... }
class PaypalPayment : PaymentMethod { ... }
class BizumPayment : PaymentMethod { ... }        // adding one = a NEW file
</code></pre>
<p>New behavior is now a new file — <em>almost</em>. <em>Something</em> still has to decide which <code>PaymentMethod</code> to instantiate, and that dispatch point does move when Bizum arrives: somewhere there's one line saying <code>"bizum" is a BizumPayment</code>, and you will add it. In this system that somewhere is <code>PaymentMethodRegistry</code>, and you'll see the line in the composition root at the end. OCP doesn't delete the choice; it <em>concentrates</em> it — out of tested business logic, where every edit risks the branches already there, and into one registration line in a place with no logic to break. <em>Closed for modification</em> was never "zero edits anywhere"; it's "no edits where the behavior lives."</p>
<p>Notice the condition hiding in all of this: the <em>axis of variation</em> — the one direction along which you expected change to arrive — was <em>known</em>. OCP pays off exactly where variation is expected, which makes it worth looking at an axis where the opposite holds.</p>
<p><strong>The inverse case: closed variation.</strong> You've been looking at this type all article. <code>PaymentResult</code> is what <code>checkout()</code> and every <code>PaymentMethod.charge()</code> returns:</p>
<pre><code class="language-kotlin">sealed interface PaymentResult
data class Approved(val receipt: Receipt) : PaymentResult
data class Declined(val reason: String)   : PaymentResult
data object Timeout                       : PaymentResult

fun record(result: PaymentResult) = when (result) {
    is Approved -&gt; ...
    is Declined -&gt; ...
    Timeout     -&gt; ...  // add a 4th variant → every 'when' fails to compile
}
</code></pre>
<p>(Java has the same pair: <code>sealed</code> types shipped in 17 — JEP 409 — with the exhaustive pattern <code>switch</code> that completes them finalized in 21, JEP 441.) This is the <strong>deliberate inverse of OCP</strong>. OCP wants adding a variant to touch nothing; sealed wants adding a variant to <em>break every consumer at compile time</em>, because for a closed set you own — the states of an order, the outcomes of a payment — a silently unhandled case is the bug. Payment <em>methods</em> are an open set: anyone may invent one, so OCP and the registry. Payment <em>results</em> are a closed set: you decide what an outcome can be, so sealed and an exhaustive <code>when</code>. One domain, both answers. Choosing per axis is the judgment.</p>
<p><strong>The trade-off.</strong> Designing for OCP up front means adding indirection on a guess. The cost is <strong>premature abstraction (YAGNI)</strong>: an interface with exactly one implementation forever, a plugin system for plugins that never arrive — and every reader now has to chase that interface to find the one place the work happens. The rule that helps: <strong>wait for the second case.</strong> Add the abstraction when the <em>second</em> implementation shows up; that's when OCP starts paying for the indirection instead of just charging you for it.</p>
<blockquote>
<p>If you remember one thing: OCP is a <strong>coupling</strong> principle — it decouples <em>what varies</em> (the implementations) from <em>what's stable</em> (the code that uses them). Add a class, don't edit one. But don't add the interface before the second thing needs it — and when the set is closed, invert the whole idea and let a sealed type break every consumer on purpose.</p>
</blockquote>
<hr />
<h2>L — Liskov Substitution</h2>
<p><strong>Definition.</strong> A subtype must be usable anywhere its base type is expected — through a base reference, with no surprises (Liskov &amp; Wing's <em>behavioral subtyping</em>, 1994). The reframing that matters: <code>extends</code> <strong>is not a code-sharing mechanism, it's a published claim.</strong> "Every promise the parent makes, I keep." LSP is that claim taken seriously.</p>
<p>The promises are not only the ones written into method signatures. The expensive ones are the properties that hold for an object's entire lifetime — "balance is never negative", "the captured amount never exceeds the authorized amount" — because callers are entitled to assume them without ever checking. That is their whole value, and it's what makes breaking one so costly.</p>
<p>Broken promises come in two forms, and they're worth seeing side by side because they fail in opposite ways.</p>
<p><strong>Form 1 — the silent wrong answer.</strong> Our system can issue store credit (it's where gift-card refunds land, as you'll see shortly):</p>
<pre><code class="language-kotlin">open class StoreCredit {
    protected var credit: Money = Money(0)   // the promise: credit &gt;= Money(0), always
    fun balance(): Money = credit            // the promise, observable by every caller
    fun topUp(amount: Money) { credit += amount }
    open fun redeem(amount: Money) {
        if (amount &gt; credit) throw InsufficientCreditException()
        credit -= amount
    }
}

class VipStoreCredit : StoreCredit() {       // "let VIPs spend past their balance"
    override fun redeem(amount: Money) {
        credit -= amount                     // promise gone — no exception, just debt
    }
}
</code></pre>
<p>Every caller written against <code>StoreCredit</code> is entitled to assume <code>balance()</code> never comes back negative, after <em>any</em> sequence of calls — reconciliation, the balance the app displays, the liability line Finance reports (unspent store credit is a liability on someone's books) — and none of them re-check, because the promise said they didn't have to. Hand them a <code>VipStoreCredit</code> and all of them are wrong at once, with no exception, no crash, and not one changed line of <em>their</em> code. Nothing fails. Everything is quietly incorrect, which is the expensive kind of wrong.</p>
<p><strong>Form 2 — the loud refusal.</strong> Checkout eventually grows refunds, and the obvious move is to widen the strategy for everyone:</p>
<pre><code class="language-kotlin">interface PaymentMethod {
    fun charge(order: OrderId, amount: Money): PaymentResult
    fun refund(txn: TxnId)                    // widened for everyone
}

class GiftCardPayment : PaymentMethod {
    override fun charge(order: OrderId, amount: Money): PaymentResult {
        ...
    }   // fine
    override fun refund(txn: TxnId) =
        throw UnsupportedOperationException("gift cards cannot take refunds")
}

val method: PaymentMethod = registry.resolve("giftcard")
method.refund(txn)                            // boom — at runtime, in prod, on refund day
</code></pre>
<p>The type promises something the object refuses to do, and the refusal arrives at runtime instead of compile time — the opposite failure to Form 1, and the easier one, because at least it announces itself. The fix is to stop claiming the contract:</p>
<pre><code class="language-kotlin">interface PaymentMethod {
    fun charge(order: OrderId, amount: Money): PaymentResult
}
interface RefundableMethod : PaymentMethod {
    fun refund(txn: TxnId)
}
</code></pre>
<p><code>RefundableMethod</code> extends <code>PaymentMethod</code> in the only safe direction: a method that can also refund keeps every promise a charge-only view makes, never the reverse. Gift cards implement only <code>PaymentMethod</code>; the refund flow — <code>RefundFlow</code> in the repo — asks for <code>RefundableMethod</code>; a gift-card refund now fails to <em>compile</em>, and the support flow (<code>SupportCreditFlow</code>) issues store credit instead — the class whose promise you just watched a subclass break. And note <em>what</em> repaired the broken contract: <strong>segregating the interface</strong> — which happens to be the next letter. The principles aren't five separate rules; they repair each other.</p>
<p><strong>The trade-off.</strong> There isn't one, and that's worth saying explicitly: LSP is the exception. SRP, OCP, ISP, DIP are <em>dials</em> — every one of them can be over-applied. LSP is a correctness constraint: there is no such thing as "too substitutable." Its real job in your toolbox is diagnostic — LSP is the detector for bad inheritance. When a tempting IS-A can't honor the full contract, the answer is never to patch the caller: <code>if (method is GiftCardPayment) skipRefund()</code> fixes the wrong answer by breaking OCP, and now two principles are broken instead of one. The answer is to stop inheriting — narrow the contract until every implementation can keep it, or hold the object in a field instead of extending it.</p>
<blockquote>
<p>If you remember one thing: LSP is a <strong>coupling</strong> principle — callers couple to the <em>base contract</em>, and every subtype must be safe behind it. No surprises through a base reference. It's not a dial, it's a detector: when IS-A can't keep the contract, don't inherit.</p>
</blockquote>
<hr />
<h2>I — Interface Segregation</h2>
<p><strong>Definition.</strong> No client should be forced to depend on methods it doesn't use. The key word is <strong>client</strong>: you don't segregate an interface by chopping it into pieces, you segregate it by <em>role</em> — one interface per <em>kind of caller</em>. The question is "who calls this, and which slice do they actually need?", never "how many methods is too many?"</p>
<pre><code class="language-kotlin">// One implementation may serve every role...
class StripePaymentGateway : PaymentGateway, PaymentReader { ... }

// ...but each client sees only the contract its role needs.
interface PaymentGateway {                        // the role that moves money
    fun charge(req: ChargeRequest): PaymentResult
    fun refund(txn: TxnId)
}
interface PaymentReader {                         // the role that looks at it
    fun transactions(range: DateRange): List&lt;Txn&gt;
}

class StatementsScreen(private val payments: PaymentReader) { ... }  // provably cannot move money
class RefundHandler(private val payments: PaymentGateway) { ... }
</code></pre>
<p>The implementation didn't split — the <em>view</em> of it did. And the benefits are concrete, not aesthetic: the statements screen cannot move money, and the compiler proves it — in a payments system, least privilege isn't style, it's audit evidence; a change to the charge path no longer touches, recompiles, or re-tests any read-only client; and the test double for <code>StatementsScreen</code> stubs one query method instead of a whole PSP (payment service provider). Notice this system has now segregated twice, on two different questions: the <code>RefundableMethod</code> split cut by <em>the capability an implementation can truly promise</em>, this one by <em>the role a client actually plays</em>. They aren't rivals; they compose. A <code>RefundableMethod</code> <em>decides</em> a refund is allowed, then calls <code>PaymentGateway.refund</code> to <em>carry it out</em> — capability on the domain method, mechanism on the infra port, <code>RefundFlow</code> and <code>RefundHandler</code> in the repo.</p>
<p><strong>The symptom to look for.</strong> An adapter full of no-ops — a class whose entire purpose is to supply empty implementations of methods you were forced to declare — is ISP screaming. Wherever you find one, the interface above it was cut by method count instead of by role.</p>
<p><strong>The trade-off.</strong> Over-apply it and you get <strong>interface explosion</strong>: a hundred one-method interfaces, every call-site holding a different name for the same object, and nobody able to say what the thing <em>is</em> anymore. Notice this is exactly SRP's failure pair one level up — under-apply it and you get the fat interface (the god class of contracts), over-apply it and you get fragmentation (shotgun surgery of contracts) — because ISP <em>is</em> SRP applied to interfaces. Both are the cohesion force, and the dial is the same: segregate by the client roles that <em>actually exist</em>, not by method count. Two roles mean two interfaces. Five methods don't mean five interfaces.</p>
<p>The textbook files ISP under <strong>coupling</strong>, not cohesion — Robert C. Martin's own formulation, "no client should be forced to depend on methods it doesn't use," is a sentence about client coupling. Both framings are correct; they answer different questions. What segregation <em>buys</em> is decoupling — clients stop depending on methods they never call. What tells you <em>where to cut</em> is cohesion — the roles whose methods change together.</p>
<blockquote>
<p>If you remember one thing: ISP is the <strong>cohesion</strong> force applied to contracts. Split by caller, not by method. Too few cuts and you get the fat interface; too many and you get interface explosion; the dial is the roles that actually exist.</p>
</blockquote>
<hr />
<h2>D — Dependency Inversion</h2>
<p><strong>Definition.</strong> The original formulation has two halves: <em>high-level modules should not depend on low-level modules — both should depend on abstractions; and abstractions should not depend on details — details should depend on abstractions.</em> The word doing the work is <em>inversion</em>, and what gets inverted is not "now there's an interface" — it's <strong>ownership</strong>. The high-level policy <em>owns</em> the abstraction; the low-level detail <em>implements</em> it. The test is a single question: <strong>which module declares the interface?</strong></p>
<pre><code class="language-kotlin">// module: domain — the high-level policy OWNS the ports.
// (PaymentGateway and PaymentReader from the last section live here too.)
interface OrderRepository {                      // written in the domain's vocabulary,
    fun find(id: OrderId): Order?                // living in the domain's module
    fun save(order: Order)
}
fun interface Clock {
    fun now(): Instant
}

class CheckoutService(
    private val prices: PriceCalculator,
    private val methods: PaymentMethodRegistry,
    private val orders: OrderRepository,
    private val clock: Clock,
) {
    fun checkout(cart: Cart): PaymentResult {
        ...
    }   // business rules; zero infra imports
}
</code></pre>
<pre><code class="language-kotlin">// module: infrastructure — depends on domain; domain has never heard of it
class StripePaymentGateway(private val client: StripeClient) : PaymentGateway, PaymentReader { ... }
class DynamoOrderRepository(private val db: DynamoDbClient) : OrderRepository { ... }
</code></pre>
<p>Follow the compile-time arrow: <code>infrastructure</code> imports <code>domain</code>. The domain compiles alone, with no Stripe SDK and no AWS on its classpath. That inverted arrow — dependencies pointing <em>inward</em>, toward policy — <em>is</em> hexagonal architecture: "port" is the interface the domain owns, "adapter" is the implementation infra provides. Hexagonal isn't a second idea you also have to learn; it's DIP applied at the module boundary. Here is the same fact drawn as a directory — the repo's actual layout, DIP made physical:</p>
<pre><code class="language-text">checkout/
  domain/            # zero infra imports — compiles alone
    Money  Cart  Order  Receipt  PaymentResult (sealed)
    PaymentGateway  PaymentReader  OrderRepository  Clock      &lt;- ports
    PaymentMethod / RefundableMethod (Card, Paypal, Bizum; GiftCard is charge-only)
    PaymentMethodRegistry  RefundFlow  PriceCalculator  ReceiptFormatter  CheckoutService
    StoreCredit  SupportCreditFlow                             &lt;- where gift-card refunds land
  infrastructure/    # depends on domain; domain has never heard of it
    StripePaymentGateway  InMemoryOrderRepository  Meter  KeyStore
    RetryingGateway  MeteredGateway  IdempotentGateway         &lt;- decorators ('by')
  clients/           # also depends on domain — the callers, not the adapters
    StatementsScreen  RefundHandler                            &lt;- ISP's two role-views
  smells/            # the broken examples worth running, compiling — each pinned by a test
    CheckoutManager  VipStoreCredit
  app/
    Main.kt          # the composition root — wires everything; DIP with no framework
</code></pre>
<p>One deliberate swap in the runnable repo: so <code>main</code> runs anywhere with zero credentials, the shipped adapters are an <code>InMemoryOrderRepository</code> and a no-network <code>StripeClient</code> stand-in rather than the real Dynamo and Stripe SDKs. The ports can't tell the difference — that a database can become a map in one line of wiring is DIP's whole claim.</p>
<p><strong>The gotcha: DI != DIP.</strong> Dependency <em>injection</em> is a mechanism — someone hands objects their collaborators. Dependency <em>inversion</em> is a principle about who owns the abstraction, and you can have either without the other. <code>@Autowired StripePaymentGateway</code> — the concrete class — is DI with zero DIP, a framework injecting your coupling for you. Hand-wiring interfaces in <code>main</code> with no framework at all is DIP in its purest form. If your service depends on an interface its own module owns, you have DIP whether or not a container exists.</p>
<p><strong>The payoff.</strong> Testability — with cause and effect in the right order. You can hand <code>CheckoutService</code> a fake gateway and an in-memory <code>OrderRepository</code> <em>because</em> it depends on abstractions the domain owns. The mock isn't the point; the mock is the <em>evidence</em>. And if you can't test a class without booting the database, that's DIP telling you an arrow points the wrong way.</p>
<p><strong>The trade-off.</strong> The degenerate form is <strong>interface-for-everything</strong>: <code>FooService</code>/<code>FooServiceImpl</code> pairs that exist because "we always do it that way" — the premature abstraction OCP warned about, moved up a layer. Abstract at <strong>true frontiers</strong>: I/O boundaries — the database, HTTP, queues, the clock, someone else's SDK — where a second implementation genuinely exists (the real one and the test fake, at minimum). Every port in this domain sits on exactly that kind of frontier. An interface between two classes in the same package that always change together isn't low coupling; it's low cohesion disguised as low coupling.</p>
<blockquote>
<p>If you remember one thing: DIP is the <strong>coupling</strong> principle at architecture scale. The domain owns the interface, details implement it, arrows point inward. DI is a mechanism; DIP is a direction. Abstract at real frontiers, not everywhere.</p>
</blockquote>
<hr />
<h2>The Composition Root</h2>
<p>Every abstraction in this system has to become an object eventually, and there is exactly one place where that's allowed to happen. Here it is — <code>Main.kt</code>, the file where all five principles stop being prose:</p>
<pre><code class="language-kotlin">// app/Main.kt
fun main() {
    val meter = Meter()

    // Composition: cross-cutting concerns as a decorator stack. The ORDER is a
    // decision, and it lives here — in wiring — not in a class hierarchy.
    val gateway: PaymentGateway =
        MeteredGateway(
            RetryingGateway(
                IdempotentGateway(StripePaymentGateway(StripeClient()), KeyStore())
            ),
            meter,
        )

    // OCP's real cost, concentrated: one plain line per payment method.
    val methods = PaymentMethodRegistry(
        "card" to { CardPayment(gateway) },
        "paypal" to { PaypalPayment(gateway) },
        "bizum" to { BizumPayment(gateway) },
        "giftcard" to { GiftCardPayment(gateway) },   // claims no refund contract
    )

    // DIP: details handed to a domain that has never heard of them.
    val orders = InMemoryOrderRepository()            // prod: DynamoOrderRepository — same port, one line
    val checkout = CheckoutService(
        PriceCalculator(),                            // SRP: Finance's class, alone
        methods,
        orders,
        Clock { Instant.now() },                      // the domain's own port — java.time.Clock never leaks inward
    )

    // …then Main.kt builds a Cart, runs checkout.checkout(cart), and prints the sealed
    // result — plus the saved order's receipt, ReceiptFormatter getting its turn.
}
</code></pre>
<p>Read it as a checklist. The gateway is wrapped three times — <code>IdempotentGateway</code> so that charging the same order twice reaches the PSP once, <code>RetryingGateway</code> so a failed call is re-attempted, <code>MeteredGateway</code> so someone can count what happened. Each wrapper holds the <em>port</em> rather than a concrete class, which is why they stack at all, and their order is a real decision made here in wiring: <code>MeteredGateway(RetryingGateway(…))</code> counts <em>logical</em> charges — one, however many retries it takes — while <code>RetryingGateway(MeteredGateway(…))</code> counts <em>PSP calls</em>, every attempt. Neither is wrong; they're different metrics, and swapping them is a one-line diff in a code review rather than a new class. The registry is OCP's dispatch point, concentrated into the one place with no logic to break — the line the OCP section promised you. Every reference is typed as a <em>port</em> (<code>PaymentGateway</code>, not <code>StripePaymentGateway</code>), so ISP's role views and LSP's substitutability are what the rest of the system sees. The domain classes take their details from outside — DIP with no framework in sight, which is DI in its purest form, exactly as promised. And nothing in this function contains business logic, because its single reason to change is "the wiring changed" — SRP, applied to <code>main</code> itself.</p>
<hr />
<h2>Throw Away the Acronym</h2>
<p>Here's the whole article as two questions — the two to actually ask in code review:</p>
<ol>
<li><p><strong>"Do these things change for the same reason?"</strong> — the <em>cohesion</em> question. If yes, keep them together; if no, separate them. SRP asks it about classes, ISP about interfaces.</p>
</li>
<li><p><strong>"If this changes, what else is forced to move?"</strong> — the <em>coupling</em> question. OCP asks it about new features (nothing should move — add a class), LSP about subtypes (callers of the base must never notice), DIP about architecture (details move; policy doesn't).</p>
</li>
</ol>
<p>The two feed each other: group what changes together and fewer changes cross a module line, so coupling falls; cut a dependency and each side comes out more focused, so cohesion rises.</p>
<p>One bounded context was enough for all five letters, because the domain forced each one — pricing and receipt copy answer to different departments, new payment methods arrive constantly, gift cards can't refund, a statements screen has no business moving money, and checkout has to be testable without a PSP. Here is every dial in one place:</p>
<table>
<thead>
<tr>
<th>Principle</th>
<th>Under-applied</th>
<th>Over-applied</th>
<th>The dial</th>
</tr>
</thead>
<tbody><tr>
<td>SRP</td>
<td>God class</td>
<td>Shotgun surgery</td>
<td>One actor per class</td>
</tr>
<tr>
<td>OCP</td>
<td>Growing <code>if/else</code></td>
<td>Speculative interfaces</td>
<td>Wait for the second case; seal what you own</td>
</tr>
<tr>
<td>LSP</td>
<td><em>Broken:</em> <code>instanceof</code> patches in callers</td>
<td>— (constraint, not a dial)</td>
<td>Can't keep the contract → don't inherit</td>
</tr>
<tr>
<td>ISP</td>
<td>Fat interface</td>
<td>Interface explosion</td>
<td>One role per client</td>
</tr>
<tr>
<td>DIP</td>
<td>Domain imports infrastructure</td>
<td><code>FooServiceImpl</code> everywhere</td>
<td>Abstract at true frontiers only</td>
</tr>
</tbody></table>
<p>The LSP row reads differently on purpose: a constraint isn't under-applied, it's <em>broken</em> — the <code>instanceof</code> patches are the symptom you see in callers, not a sign you used too little LSP.</p>
<p>Cohesion and coupling are not SOLID's children — they're its grandparents. Stevens, Myers, and Constantine named the pair in "Structured Design" (<em>IBM Systems Journal</em>, 1974); Parnas nailed the underlying idea as <em>information hiding</em> in 1972; the acronym arrived three decades later. The letters are the most successful marketing campaign those two ideas ever had — genuinely useful as mnemonics, dangerous as a checklist. A checklist tells you to add an interface. The forces tell you whether the interface bought you anything.</p>
<hr />
<p><em>Everything in this article lives in</em> <a href="https://github.com/dionisioC/blog/tree/main/posts/2026-08-solid-cohesion-coupling/code"><em>the companion repo</em></a><em>: one Gradle project holding the clean slices and the</em> <code>smells</code> <em>package side by side, with a test pinning each claim — the VIP balance really goes negative, the same cart really charges the PSP once, the decorator order really changes the metric — and a</em> <code>main()</code> <em>you can run.</em></p>
]]></content:encoded></item></channel></rss>