<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dionisio Cortes Fernandez</title>
    <description>The latest articles on DEV Community by Dionisio Cortes Fernandez (@dionisioc).</description>
    <link>https://dev.to/dionisioc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F347387%2Fe722eb9b-07dc-4286-9f63-6ce9ecd49db6.jpeg</url>
      <title>DEV Community: Dionisio Cortes Fernandez</title>
      <link>https://dev.to/dionisioc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dionisioc"/>
    <language>en</language>
    <item>
      <title>SOLID Without the Acronym: It's Just Cohesion and Coupling</title>
      <dc:creator>Dionisio Cortes Fernandez</dc:creator>
      <pubDate>Mon, 14 Sep 2026 20:15:56 +0000</pubDate>
      <link>https://dev.to/dionisioc/solid-without-the-acronym-its-just-cohesion-and-coupling-4e07</link>
      <guid>https://dev.to/dionisioc/solid-without-the-acronym-its-just-cohesion-and-coupling-4e07</guid>
      <description>&lt;p&gt;SOLID isn't really five independent principles. It's mostly two long-standing design ideas, wearing five names.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;High cohesion&lt;/strong&gt; — keep the things that change together, together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low coupling&lt;/strong&gt; — depend on stable abstractions, not volatile details.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four of the five letters are &lt;em&gt;named consequences&lt;/em&gt; of those two forces, and the fifth comes with an asterisk. Here's the first cut:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Force&lt;/th&gt;
&lt;th&gt;Principles&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cohesion&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SRP, ISP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Coupling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OCP, LSP, DIP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It's only a first cut. ISP is contested: the textbook files it under coupling, and its section shows why both readings are right. LSP is the asterisk. It sits under coupling because callers couple to the &lt;em&gt;base contract&lt;/em&gt;, never to your subtype, but it isn't a dial you can turn too far. It's a correctness constraint, which makes it a &lt;em&gt;detector&lt;/em&gt; rather than a design choice.&lt;/p&gt;

&lt;p&gt;Once you see the two forces, you stop memorizing and start deriving, including when &lt;em&gt;not&lt;/em&gt; to apply each letter. Every one of them has a cost, and SOLID applied without judgment produces its own kind of unmaintainable code.&lt;/p&gt;

&lt;p&gt;Every example lives in one system, the checkout slice of a payments product: &lt;code&gt;CheckoutService.checkout(cart)&lt;/code&gt; prices the cart, charges a payment method through a gateway, records the order and returns a result. The domain &lt;em&gt;forces&lt;/em&gt; each principle, and you'll watch them repair each other. For each one: what it means, where it shows up, and what over-applying it costs.&lt;/p&gt;




&lt;h2&gt;
  
  
  S — Single Responsibility
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Definition.&lt;/strong&gt; A class should have one reason to change. The version that actually helps: &lt;strong&gt;one reason to change means one &lt;em&gt;actor&lt;/em&gt;&lt;/strong&gt;, one group of people who can ask for that change.&lt;/p&gt;

&lt;p&gt;Here the broken version is a &lt;code&gt;CheckoutManager&lt;/code&gt; with &lt;code&gt;total()&lt;/code&gt; and &lt;code&gt;renderReceipt()&lt;/code&gt;. It &lt;em&gt;feels&lt;/em&gt; like one thing ("checkout"), but &lt;code&gt;total()&lt;/code&gt; answers to Finance, which runs a 10% loyalty discount, and &lt;code&gt;renderReceipt()&lt;/code&gt; answers to Marketing, which runs a points program and prints the lines that earned points. Both programs cover the same lines today, so they share one helper. Two actors, two reasons to change, one class: that's the smell.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CheckoutManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Cart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;total&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;                             &lt;span class="c1"&gt;// answers to Finance&lt;/span&gt;
        &lt;span class="n"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;loyaltyDiscount&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;renderReceipt&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;LoyaltyReceipt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;            &lt;span class="c1"&gt;// answers to Marketing&lt;/span&gt;
        &lt;span class="nc"&gt;LoyaltyReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;total&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;rewarded&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rewardedItems&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;// "points earned on…"&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;loyaltyDiscount&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;           &lt;span class="c1"&gt;// Finance's discount...&lt;/span&gt;
        &lt;span class="nf"&gt;rewardedItems&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;                   &lt;span class="c1"&gt;// 10% back on rewarded items&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;rewardedItems&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Line&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;    &lt;span class="c1"&gt;// ...and Marketing's points: one list, the trap&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's how it goes wrong. Finance asks you to stop discounting gift wrap. A developer edits &lt;code&gt;rewardedItems()&lt;/code&gt;, the obvious place, and Marketing's receipt silently stops listing gift wrap under "points earned on," which nobody in Marketing asked for. The total moves exactly as Finance wanted (a smaller discount, every item still charged), so the diff looks correct. Review misses it: the class has one name and one obvious topic, but that private helper couples two departments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix is a split you already know.&lt;/strong&gt; In a layered app the controller changes with the &lt;em&gt;API shape&lt;/em&gt;, the service with a &lt;em&gt;business rule&lt;/em&gt;, the repository with &lt;em&gt;storage&lt;/em&gt;: three reasons, three classes. Here the same instinct fires &lt;em&gt;inside&lt;/em&gt; the service layer: &lt;code&gt;PriceCalculator&lt;/code&gt; for Finance, &lt;code&gt;ReceiptFormatter&lt;/code&gt; for Marketing, &lt;code&gt;CheckoutService&lt;/code&gt; for the flow.&lt;/p&gt;

&lt;p&gt;Each class gets &lt;em&gt;its own copy&lt;/em&gt; of "which lines count": Finance's skips gift wrap, Marketing's doesn't. DRY says merge them, but they're two actors' rules that merely agreed for a while, and merging them is what caused the bug. Martin calls this &lt;em&gt;accidental duplication&lt;/em&gt;: code that looks the same but changes for different reasons isn't really duplicated. &lt;code&gt;SrpTest&lt;/code&gt; pins both behaviors: the smell's one edit moving discount &lt;em&gt;and&lt;/em&gt; receipt, and the split's two rules disagreeing. Whenever "who asks for changes to this?" gets two answers, you're looking at two classes wearing one name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off.&lt;/strong&gt; Fowler's &lt;em&gt;Refactoring&lt;/em&gt; names both failure modes as smells. Under-apply SRP and you get the god class and its symptom, &lt;strong&gt;divergent change&lt;/strong&gt;: one class edited for many unrelated reasons. Over-apply it and you get &lt;strong&gt;shotgun surgery&lt;/strong&gt;: one logical change spread across ten tiny files, because you scattered things that change together. The dial is &lt;strong&gt;cohesion&lt;/strong&gt;. Split by "these change for different reasons," never by "this method feels different."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you remember one thing: SRP is the &lt;strong&gt;cohesion&lt;/strong&gt; 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?"&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  O — Open/Closed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Definition.&lt;/strong&gt; A class should be &lt;em&gt;open for extension, closed for modification&lt;/em&gt;: you add behavior by adding a class, not by editing an existing, tested one. The enemy is the &lt;code&gt;if/else&lt;/code&gt; that grows a branch with every new payment method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Every new payment method = reopen this function and risk the branches already here.&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"card"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"paypal"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"bizum"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;- edit working code, again&lt;/span&gt;
    &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Polymorphism buys you OCP: depend on an abstraction, and add an &lt;em&gt;implementation&lt;/em&gt; instead of a &lt;em&gt;branch&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethod&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// closed&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CardPayment&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethod&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaypalPayment&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethod&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BizumPayment&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethod&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="c1"&gt;// adding one = a NEW file&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New behavior is now a new file, &lt;em&gt;almost&lt;/em&gt;. Something still maps &lt;code&gt;"bizum"&lt;/code&gt; to &lt;code&gt;BizumPayment&lt;/code&gt;, and you'll add that line; here it's &lt;code&gt;PaymentMethodRegistry&lt;/code&gt;, wired in the composition root at the end. OCP doesn't delete the choice, it &lt;em&gt;concentrates&lt;/em&gt; it: out of tested business logic, into one registration line with no logic to break. &lt;em&gt;Closed for modification&lt;/em&gt; never meant "zero edits anywhere," only "no edits where the behavior lives."&lt;/p&gt;

&lt;p&gt;That pays off because the &lt;em&gt;axis of variation&lt;/em&gt; was known: you expected new payment methods. Where the opposite holds, you want the opposite tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inverse case: closed variation.&lt;/strong&gt; &lt;code&gt;PaymentResult&lt;/code&gt; is what &lt;code&gt;checkout()&lt;/code&gt; and every &lt;code&gt;PaymentMethod.charge()&lt;/code&gt; return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Approved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TxnId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Declined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;Timeout&lt;/span&gt;                                   &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;   &lt;span class="c1"&gt;// paid already, other terms&lt;/span&gt;

&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;Approved&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;Declined&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="nc"&gt;Timeout&lt;/span&gt;     &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;Conflict&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;  &lt;span class="c1"&gt;// add a variant → this 'when' stops compiling&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OCP wants a new variant to touch nothing. A sealed type wants a new variant to &lt;em&gt;break every exhaustive &lt;code&gt;when&lt;/code&gt; at compile time&lt;/em&gt;, because for a closed set you own, like an order's states or a payment's outcomes, a silently unhandled case is the bug. Only an exhaustive &lt;code&gt;when&lt;/code&gt; gets that protection: an &lt;code&gt;else&lt;/code&gt; branch opts out, and so does an &lt;code&gt;if (result is Approved)&lt;/code&gt;. That's why &lt;code&gt;CheckoutService&lt;/code&gt;, which decides whether an outcome leaves an order behind, decides with a &lt;code&gt;when&lt;/code&gt;. (Java has the same pair: &lt;code&gt;sealed&lt;/code&gt; types in 17, JEP 409, and the exhaustive pattern &lt;code&gt;switch&lt;/code&gt; in 21, JEP 441.)&lt;/p&gt;

&lt;p&gt;Payment &lt;em&gt;methods&lt;/em&gt; are an open set anyone may extend: OCP and a registry. Payment &lt;em&gt;results&lt;/em&gt; are a closed set you define: a sealed type. One domain, both answers; choosing per axis is the judgment. The trade even has a name, the &lt;em&gt;expression problem&lt;/em&gt; (Philip Wadler, 1998): an open interface makes a new variant cheap and a new operation expensive, because every implementation has to grow the method, and a sealed type flips both. The LSP section shows the expensive direction, when &lt;code&gt;refund&lt;/code&gt; gets bolted onto every &lt;code&gt;PaymentMethod&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off.&lt;/strong&gt; OCP up front is indirection on a guess: &lt;strong&gt;premature abstraction (YAGNI)&lt;/strong&gt;. You get an interface with one implementation forever, a plugin system for plugins that never arrive, and readers chasing the interface to find where the work happens. &lt;strong&gt;Wait for the second case&lt;/strong&gt;: that's when OCP starts paying for the indirection instead of just charging you for it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you remember one thing: OCP is a &lt;strong&gt;coupling&lt;/strong&gt; principle. It decouples &lt;em&gt;what varies&lt;/em&gt; (the implementations) from &lt;em&gt;what's stable&lt;/em&gt; (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 exhaustive &lt;code&gt;when&lt;/code&gt; on purpose.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  L — Liskov Substitution
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Definition.&lt;/strong&gt; A subtype must be usable anywhere its base type is expected, through a base reference, with no surprises (Liskov &amp;amp; Wing's &lt;em&gt;behavioral subtyping&lt;/em&gt;, 1994). The reframing that matters: &lt;strong&gt;&lt;code&gt;extends&lt;/code&gt; isn't a code-sharing mechanism, it's a published claim&lt;/strong&gt;, "every promise the parent makes, I keep."&lt;/p&gt;

&lt;p&gt;The expensive promises aren't in method signatures. They're properties that hold for an object's whole lifetime, like "balance is never negative" or "the captured amount never exceeds the authorized amount." Callers assume them without checking, which is their whole value and why breaking one costs so much. Broken promises come in two forms, and they fail in opposite ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Form 1 — the silent wrong answer.&lt;/strong&gt; Our system can issue store credit (it's where gift-card refunds land, as you'll see):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;open&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StoreCredit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// the promise: credit &amp;gt;= Money(0), always&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;            &lt;span class="c1"&gt;// the promise, observable by every caller&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;topUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="c1"&gt;// every way in guards the promise&lt;/span&gt;
        &lt;span class="n"&gt;credit&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;open&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;redeem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;InsufficientCreditException&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;credit&lt;/span&gt; &lt;span class="p"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VipStoreCredit&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StoreCredit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;       &lt;span class="c1"&gt;// "let VIPs spend past their balance"&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;redeem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="c1"&gt;// same guard as the parent...&lt;/span&gt;
        &lt;span class="n"&gt;credit&lt;/span&gt; &lt;span class="p"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;                     &lt;span class="c1"&gt;// ...but the promise is gone: no exception, just debt&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Callers written against &lt;code&gt;StoreCredit&lt;/code&gt; assume &lt;code&gt;balance()&lt;/code&gt; never goes negative, after &lt;em&gt;any&lt;/em&gt; sequence of calls: reconciliation, the balance the app shows, the liability line Finance reports. Hand them a &lt;code&gt;VipStoreCredit&lt;/code&gt; and they're all wrong at once, with no exception, no crash, and not one changed line of &lt;em&gt;their&lt;/em&gt; code. Quietly incorrect is the expensive kind of wrong.&lt;/p&gt;

&lt;p&gt;The guards matter too. &lt;code&gt;topUp&lt;/code&gt; and &lt;code&gt;redeem&lt;/code&gt; refuse negative amounts, and &lt;code&gt;Money&lt;/code&gt; throws on overflow instead of wrapping. Without them, a plain &lt;code&gt;StoreCredit&lt;/code&gt; could go negative by itself, and the example would blame inheritance for the parent's own bug. The subclass keeps the guard, so breaking the balance promise is the &lt;em&gt;only&lt;/em&gt; thing it does wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Form 2 — the loud refusal.&lt;/strong&gt; Checkout grows refunds, and in this product gift cards can't take them (a business rule of this example, not of payments in general). The obvious move widens the strategy for everyone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethod&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TxnId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;// widened for everyone&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GiftCardPayment&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethod&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// fine&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TxnId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;UnsupportedOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"gift cards cannot take refunds"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethod&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"giftcard"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                            &lt;span class="c1"&gt;// boom — at runtime, in prod, on refund day&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The type promises something the object refuses to do, and the refusal arrives at runtime instead of compile time. At least it announces itself. Throwing isn't the violation on its own: a contract that allows refusal is kept by refusing. This one promised refunds to every caller, so the refusal breaks it. The fix is to stop claiming the contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethod&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;RefundableMethod&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethod&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TxnId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RefundableMethod&lt;/code&gt; extends &lt;code&gt;PaymentMethod&lt;/code&gt; 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 &lt;code&gt;PaymentMethod&lt;/code&gt;, so there's no &lt;code&gt;refund&lt;/code&gt; on them to call and nothing to throw.&lt;/p&gt;

&lt;p&gt;In the repo, that fix carries real weight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Refunds enter by order.&lt;/strong&gt; The order knows which method took the money and which transaction to reverse, so nobody can pair a card's refund with a gift card's charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The capability is asked, not assumed.&lt;/strong&gt; Methods arrive by name, so one question stays at runtime: can this one refund? &lt;code&gt;PaymentMethod.refundable()&lt;/code&gt; promises only an answer, and &lt;code&gt;null&lt;/code&gt; keeps that promise; &lt;code&gt;RefundableMethod&lt;/code&gt; answers with itself. That names a contract, not a class, unlike the &lt;code&gt;is GiftCardPayment&lt;/code&gt; patch below, so a new refundable method touches nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It survives wrappers.&lt;/strong&gt; An &lt;code&gt;as? RefundableMethod&lt;/code&gt; stops at a &lt;code&gt;PaymentMethod by inner&lt;/code&gt; wrapper, quietly turning every card refund into store credit. &lt;code&gt;by&lt;/code&gt; forwards the question to the card inside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gift cards settle in store credit.&lt;/strong&gt; A gift-card order comes back &lt;code&gt;NotRefundable&lt;/code&gt;, and &lt;code&gt;SupportCreditFlow&lt;/code&gt; issues store credit instead: the class whose promise you just watched a subclass break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each order settles once.&lt;/strong&gt; The order records its settlement, so a second press of the refund button answers &lt;code&gt;AlreadySettled&lt;/code&gt; and moves no money.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What repaired the broken contract was &lt;strong&gt;segregating the interface&lt;/strong&gt;, which happens to be the next letter. The principles aren't five separate rules; they repair each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off.&lt;/strong&gt; LSP isn't a dial: nothing is "too substitutable," and it can only be kept or broken. Keeping it has a price, and the price lives in the contract. You can weaken the base contract until every subtype can keep it, as &lt;code&gt;java.util.Collection&lt;/code&gt; does: its Javadoc marks &lt;code&gt;add&lt;/code&gt; and &lt;code&gt;remove&lt;/code&gt; as optional operations that may throw &lt;code&gt;UnsupportedOperationException&lt;/code&gt;, so an unmodifiable list keeps the contract by refusing, and every caller handles a refusal the type allows. Or you keep the contract strong and split it, as &lt;code&gt;RefundableMethod&lt;/code&gt; did, and pay in interfaces: ISP's explosion. Weaker promises or more types: that's the real dial.&lt;/p&gt;

&lt;p&gt;What LSP rules out is the third option, patching the caller. &lt;code&gt;if (method is GiftCardPayment) skipRefund()&lt;/code&gt; fixes the wrong answer by breaking OCP, so now two principles are broken instead of one. That's LSP's real job in your toolbox: it detects bad inheritance. When a tempting IS-A can't honor the full contract, stop inheriting. Narrow the contract until every implementation can keep it, or hold the object in a field instead of extending it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you remember one thing: LSP is a &lt;strong&gt;coupling&lt;/strong&gt; principle. Callers couple to the &lt;em&gt;base contract&lt;/em&gt;, 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. The dial it leaves you is in the contract itself: weaker promises or more types.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  I — Interface Segregation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Definition.&lt;/strong&gt; No client should be forced to depend on methods it doesn't use. The key word is &lt;strong&gt;client&lt;/strong&gt;: you segregate by &lt;em&gt;role&lt;/em&gt;, one interface per &lt;em&gt;kind of caller&lt;/em&gt;, not by chopping an interface into pieces. Ask "who calls this, and which slice do they actually need?", never "how many methods is too many?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// One implementation may serve every role...&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StripePaymentGateway&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PaymentReader&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ...but each client sees only the contract its role needs.&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                        &lt;span class="c1"&gt;// the role that moves money&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ChargeRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TxnId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentReader&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                         &lt;span class="c1"&gt;// the role that looks at it&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;transactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DateRange&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Txn&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StatementsScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentReader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;                &lt;span class="c1"&gt;// can't move money&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CardPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RefundableMethod&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// can't read statements&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation didn't split; the &lt;em&gt;view&lt;/em&gt; of it did, and the benefits are concrete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The statements screen has no &lt;code&gt;charge&lt;/code&gt; or &lt;code&gt;refund&lt;/code&gt; in scope, so it can't move money by accident. That narrows access rather than proving it (a cast could still reach the other role of the same object), but least privilege by default is what a payments audit asks for.&lt;/li&gt;
&lt;li&gt;A change to a charging signature no longer touches any read-only client.&lt;/li&gt;
&lt;li&gt;The test double for &lt;code&gt;StatementsScreen&lt;/code&gt; stubs one query method instead of a whole PSP (payment service provider).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system has now segregated twice, on two different questions: &lt;code&gt;RefundableMethod&lt;/code&gt; by &lt;em&gt;what an implementation can truly promise&lt;/em&gt;, this split by &lt;em&gt;the role a client plays&lt;/em&gt;. They compose. &lt;code&gt;RefundFlow&lt;/code&gt; decides a refund is allowed (capability, on the domain method), then &lt;code&gt;CardPayment&lt;/code&gt; carries it out through &lt;code&gt;PaymentGateway.refund&lt;/code&gt; (mechanism, on the port). No client gets the raw port: &lt;code&gt;RefundHandler&lt;/code&gt;, the support desk's refund button, holds &lt;code&gt;RefundFlow&lt;/code&gt;. Handed &lt;code&gt;PaymentGateway&lt;/code&gt; instead, it could refund any transaction, gift cards included, without asking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The symptom to look for.&lt;/strong&gt; An adapter full of no-ops means the interface above it was never cut by role. The repo has a quiet version: the checkout tests' fake gateways only ever charge, yet each one stubs &lt;code&gt;refund&lt;/code&gt; as a no-op. One stub per fake is the cheap end of the dial, and splitting &lt;code&gt;PaymentGateway&lt;/code&gt; over it would be the explosion described next. When the fakes stub three or four methods, cut.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off.&lt;/strong&gt; Over-apply ISP and you get &lt;strong&gt;interface explosion&lt;/strong&gt;: a hundred one-method interfaces, every call site holding a different name for the same object, and nobody able to say what the thing &lt;em&gt;is&lt;/em&gt; anymore. That's SRP's failure pair one level up: the fat interface is the god class of contracts, and fragmentation is their shotgun surgery. ISP &lt;em&gt;is&lt;/em&gt; SRP applied to interfaces, with the same dial: segregate by the client roles that &lt;em&gt;actually exist&lt;/em&gt;, not by method count. Two roles mean two interfaces. Five methods don't mean five interfaces.&lt;/p&gt;

&lt;p&gt;The textbook files ISP under &lt;strong&gt;coupling&lt;/strong&gt;, and Robert C. Martin's own formulation backs it: "clients should not be forced to depend upon interfaces that they do not use" (&lt;em&gt;The C++ Report&lt;/em&gt;, 1996), because forcing them "results in an inadvertent coupling between all the clients." Both framings are right; they answer different questions. What segregation &lt;em&gt;buys&lt;/em&gt; is decoupling. What tells you &lt;em&gt;where to cut&lt;/em&gt; is cohesion: the roles whose methods change together.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you remember one thing: ISP is the &lt;strong&gt;cohesion&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  D — Dependency Inversion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Definition.&lt;/strong&gt; The original formulation has two halves: &lt;em&gt;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.&lt;/em&gt; What gets inverted isn't "now there's an interface." It's &lt;strong&gt;ownership&lt;/strong&gt;: the high-level policy &lt;em&gt;owns&lt;/em&gt; the abstraction, and the low-level detail &lt;em&gt;implements&lt;/em&gt; it. The test is a single question: &lt;strong&gt;which module declares the interface?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// module: domain — the high-level policy OWNS the ports.&lt;/span&gt;
&lt;span class="c1"&gt;// (PaymentGateway and PaymentReader from the last section live here too.)&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                      &lt;span class="c1"&gt;// written in the domain's vocabulary,&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;                &lt;span class="c1"&gt;// living in the domain's module&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Clock&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CheckoutService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;prices&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PriceCalculator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;methods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethodRegistry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Clock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Cart&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// business rules; zero infra imports&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// module: infrastructure — depends on domain; domain has never heard of it&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StripePaymentGateway&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StripeClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PaymentReader&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DynamoOrderRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DynamoDbClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Follow the compile-time arrow: &lt;code&gt;infrastructure&lt;/code&gt; imports &lt;code&gt;domain&lt;/code&gt;, so the domain compiles alone, with no Stripe SDK and no AWS on its classpath. That inward arrow is the dependency rule of hexagonal architecture (ports and adapters): the domain owns the port, and infrastructure provides the adapter. Hexagonal adds more than the arrow, such as an explicit application boundary and adapters on both the driving and the driven side, but the arrow itself is DIP at the module boundary. Here it is as the repo's actual layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;checkout/
  domain/            # no infra imports; a test enforces it
    Money  Cart  Order  Receipt  PaymentResult (sealed)  RefundResult (sealed)
    PaymentGateway  PaymentReader  OrderRepository  Clock      &amp;lt;- ports
    PaymentMethod / RefundableMethod (Card, Paypal, Bizum; GiftCard is charge-only)
    PaymentMethodRegistry  RefundFlow  PriceCalculator  ReceiptFormatter  CheckoutService
    StoreCredit  SupportCreditFlow                             &amp;lt;- where gift-card refunds land
  infrastructure/    # depends on domain; domain has never heard of it
    StripePaymentGateway  StripeClient  InMemoryOrderRepository  Meter  KeyStore
    RetryingGateway  MeteredGateway  IdempotentGateway         &amp;lt;- decorators ('by')
  clients/           # also depends on domain: the callers, not the adapters
    StatementsScreen                                           &amp;lt;- ISP's read-only role view
    RefundHandler                                              &amp;lt;- holds RefundFlow, never the port
  smells/            # the broken examples, compiling, each pinned by a test
    CheckoutManager  VipStoreCredit
  app/
    Main.kt          # the composition root: wires everything; DIP with no framework
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two honest caveats. First, so &lt;code&gt;main&lt;/code&gt; runs anywhere with zero credentials, the adapters are an &lt;code&gt;InMemoryOrderRepository&lt;/code&gt; and a no-network &lt;code&gt;StripeClient&lt;/code&gt; stand-in, not the real Dynamo and Stripe SDKs. The ports can't tell the difference, and that a database can become a map in one line of wiring is DIP's whole claim.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;domain&lt;/code&gt; and &lt;code&gt;infrastructure&lt;/code&gt; are packages in one Gradle project, so the compiler alone wouldn't stop a domain file from importing an adapter. &lt;code&gt;ArchitectureTest&lt;/code&gt; does, with two checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Imports.&lt;/strong&gt; A &lt;code&gt;domain/&lt;/code&gt; file may import, or name in full, only the domain, Kotlin, and the JDK's &lt;code&gt;java.time&lt;/code&gt; and &lt;code&gt;java.util&lt;/code&gt;. The JDK allowance is narrow on purpose: &lt;code&gt;java.sql&lt;/code&gt; and &lt;code&gt;java.net.http&lt;/code&gt; ship with the JDK too, and they're infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ambient reads.&lt;/strong&gt; &lt;code&gt;java.lang&lt;/code&gt; needs no import, and allowing &lt;code&gt;java.time&lt;/code&gt; for &lt;code&gt;Instant&lt;/code&gt; also allows &lt;code&gt;Instant.now()&lt;/code&gt;, exactly the read the &lt;code&gt;Clock&lt;/code&gt; port exists to replace. This check fails on the system clock, &lt;code&gt;System&lt;/code&gt;, &lt;code&gt;Runtime&lt;/code&gt;, &lt;code&gt;Thread&lt;/code&gt; or &lt;code&gt;ProcessBuilder&lt;/code&gt; anywhere in domain code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are text scans: a tripwire, not a proof. In a production codebase, make them Gradle subprojects and the build enforces the arrow for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gotcha: DI != DIP.&lt;/strong&gt; Dependency &lt;em&gt;injection&lt;/em&gt; is a mechanism: someone hands objects their collaborators. Dependency &lt;em&gt;inversion&lt;/em&gt; is a principle about who owns the abstraction, and you can have either without the other. &lt;code&gt;@Autowired StripePaymentGateway&lt;/code&gt;, the concrete class, is DI with zero DIP: a framework injecting your coupling for you. Hand-wiring in &lt;code&gt;main&lt;/code&gt; is DI in its purest form, and DIP too, because the domain owns the interfaces being wired. Depend on an interface your own module owns and you have DIP, container or not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The payoff.&lt;/strong&gt; Testability, with cause and effect in the right order. You can hand &lt;code&gt;CheckoutService&lt;/code&gt; a fake gateway and an in-memory &lt;code&gt;OrderRepository&lt;/code&gt; &lt;em&gt;because&lt;/em&gt; it depends on abstractions the domain owns. The mock isn't the point; it's the &lt;em&gt;evidence&lt;/em&gt;. If you can't test a class without booting the database, DIP is telling you an arrow points the wrong way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off.&lt;/strong&gt; The degenerate form is &lt;strong&gt;interface-for-everything&lt;/strong&gt;: &lt;code&gt;FooService&lt;/code&gt;/&lt;code&gt;FooServiceImpl&lt;/code&gt; pairs that exist because "we always do it that way," OCP's premature abstraction moved up a layer. Abstract at &lt;strong&gt;true frontiers&lt;/strong&gt;, the 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. 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.&lt;/p&gt;

&lt;p&gt;Every port here sits on such a frontier, &lt;code&gt;Clock&lt;/code&gt; included. It's deliberately narrower than &lt;code&gt;java.time.Clock&lt;/code&gt;, which is abstract, carries a time zone the domain never reads, and no lambda can implement. The JDK's &lt;code&gt;java.time.InstantSource&lt;/code&gt; (Java 17) has the right shape (one method, no zone, lambda-friendly) and would do; the domain declares its own anyway, like every other port, so the policy states its need in its own words. Three lines is the whole price.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you remember one thing: DIP is the &lt;strong&gt;coupling&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Composition Root
&lt;/h2&gt;

&lt;p&gt;Every abstraction has to become an object somewhere, and in this system exactly one place gets to do it: &lt;code&gt;Main.kt&lt;/code&gt;, the file where all five principles stop being prose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Main.kt&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;meter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Meter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;clock&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Clock&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// the domain's own port; java.time.Clock never leaks inward&lt;/span&gt;

    &lt;span class="c1"&gt;// ISP: one Stripe adapter, two roles. The money-moving role gets the decorator stack below;&lt;/span&gt;
    &lt;span class="c1"&gt;// the read-only role gets the adapter itself, since a statement needs no retries and no keys.&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;stripe&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StripePaymentGateway&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StripeClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="c1"&gt;// Composition: cross-cutting concerns as a decorator stack. The ORDER is a&lt;/span&gt;
    &lt;span class="c1"&gt;// decision, and it lives here, in wiring, not in a class hierarchy.&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;MeteredGateway&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;RetryingGateway&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nc"&gt;IdempotentGateway&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;KeyStore&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// OCP's real cost, concentrated: one plain line per payment method.&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;methods&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethodRegistry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"card"&lt;/span&gt; &lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;CardPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="s"&gt;"paypal"&lt;/span&gt; &lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;PaypalPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="s"&gt;"bizum"&lt;/span&gt; &lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;BizumPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="s"&gt;"giftcard"&lt;/span&gt; &lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;GiftCardPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// claims no refund capability&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// DIP: details handed to a domain that has never heard of them.&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InMemoryOrderRepository&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// prod: DynamoOrderRepository, same port, one line&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;checkout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CheckoutService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;PriceCalculator&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;                   &lt;span class="c1"&gt;// SRP: Finance's class, alone&lt;/span&gt;
        &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                               &lt;span class="c1"&gt;// the same clock the PSP stand-in stamps with&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// The clients, each holding only the role it plays.&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;statements&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StatementsScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="c1"&gt;// ISP: reads, can't move money&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;refundDesk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RefundHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RefundFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// LSP: refunds only through the gate&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;storeCredit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SupportCreditFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StoreCredit&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;            &lt;span class="c1"&gt;// where a gift card's refund lands&lt;/span&gt;

    &lt;span class="c1"&gt;// …then a short demo script plays a customer and the support desk: a card order and a&lt;/span&gt;
    &lt;span class="c1"&gt;// gift-card order, the statement, both refunds, and the card's refund button pressed twice.&lt;/span&gt;
    &lt;span class="nf"&gt;demo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statements&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refundDesk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;storeCredit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"logical charges metered: ${meter.count("&lt;/span&gt;&lt;span class="n"&gt;charges&lt;/span&gt;&lt;span class="s"&gt;")}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as a checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Composition.&lt;/strong&gt; The gateway is wrapped three times: &lt;code&gt;IdempotentGateway&lt;/code&gt; so an approved order isn't charged again, &lt;code&gt;RetryingGateway&lt;/code&gt; so a timed-out call gets another try, and &lt;code&gt;MeteredGateway&lt;/code&gt; so someone can count what happened. Each wrapper holds the &lt;em&gt;port&lt;/em&gt;, not a concrete class, which is why they stack at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order.&lt;/strong&gt; Where each wrapper sits is a decision made here. &lt;code&gt;MeteredGateway(RetryingGateway(…))&lt;/code&gt; counts &lt;em&gt;logical&lt;/em&gt; charges; &lt;code&gt;RetryingGateway(MeteredGateway(…))&lt;/code&gt; counts &lt;em&gt;attempts&lt;/em&gt;. Neither is wrong; they're different metrics, a one-line diff apart. Neither counts PSP calls, though: in both, idempotency sits inside the meter, so a double-click the cache answers still gets metered. To count PSP calls, wrap the Stripe adapter itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OCP.&lt;/strong&gt; The registry: the one registration line the OCP section promised you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ISP.&lt;/strong&gt; One Stripe adapter, two roles: &lt;code&gt;CardPayment&lt;/code&gt; sees a decorated &lt;code&gt;PaymentGateway&lt;/code&gt;, &lt;code&gt;StatementsScreen&lt;/code&gt; an undecorated &lt;code&gt;PaymentReader&lt;/code&gt;, since a statement needs no retries or keys. Cross-cutting concerns attach per role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LSP.&lt;/strong&gt; Details arrive typed as ports (&lt;code&gt;PaymentGateway&lt;/code&gt;, not &lt;code&gt;StripePaymentGateway&lt;/code&gt;), and refunds reach money only through &lt;code&gt;RefundFlow&lt;/code&gt;, which asks each method for the capability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DIP.&lt;/strong&gt; No framework in sight: DI in its purest form, down to one clock shared by the domain and the PSP stand-in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SRP.&lt;/strong&gt; &lt;code&gt;main&lt;/code&gt; holds no business logic, so its single reason to change is "the wiring changed." The demo script lives in a function of its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The decorators forward with Kotlin's &lt;code&gt;by inner&lt;/code&gt;: delegate the whole port to the wrapped object, then override only what you care about. That's black-box reuse with no fragile base class, at one cost: &lt;code&gt;by&lt;/code&gt; forwards whatever it isn't told about. &lt;code&gt;refund&lt;/code&gt; already passes through all three layers unmetered, unretried and without a key, and a method added to &lt;code&gt;PaymentGateway&lt;/code&gt; tomorrow would slip through the same way, silently. It's the open default, the opposite of a sealed &lt;code&gt;when&lt;/code&gt; that makes you decide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the idempotency layer can't do.&lt;/strong&gt; It's a local memory of approvals for calls made one at a time. If the PSP approves but its response never arrives, or two calls race past the cache, only the key sent to the PSP protects you, which is why the Stripe adapter forwards it. Both layers also check what a key is reused &lt;em&gt;for&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same request:&lt;/strong&gt; the original answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same order, another amount or method:&lt;/strong&gt; &lt;code&gt;Conflict&lt;/code&gt;, the way &lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;Stripe refuses a key reused with different parameters&lt;/a&gt;. Otherwise a cart edited after payment would come back "approved" at a total nobody charged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why not &lt;code&gt;Declined&lt;/code&gt;:&lt;/strong&gt; a decline means no money moved, and here some may have. If the PSP charged the first attempt but its response never arrived, a charge exists that no order records, and "declined" would tell a customer who paid to pay again. Finding that charge by its key is reconciliation, out of scope here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same price, other lines:&lt;/strong&gt; the PSP only sees amount and method, so swapping size M for L replays cleanly through both layers. Only the domain knows the lines, so &lt;code&gt;CheckoutService&lt;/code&gt; compares them with the recorded order and answers &lt;code&gt;Conflict&lt;/code&gt; too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keying the order has one more price: a real PSP replays the &lt;em&gt;first&lt;/em&gt; answer for a key, declines included, so paying another way after a decline needs a new key (order plus attempt number), which the sample leaves out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the retry layer won't do.&lt;/strong&gt; It retries only &lt;code&gt;Timeout&lt;/code&gt;, through an exhaustive &lt;code&gt;when&lt;/code&gt;: a decline is an answer, not a failure, and asking again only asks the same question. It also retries only a request that carries a key. A &lt;code&gt;Timeout&lt;/code&gt; means nobody knows whether the PSP charged, so a keyed retry is safe (the PSP dedupes it) and a keyless one could charge twice. The wired flow always has a key, because an &lt;code&gt;OrderId&lt;/code&gt; can't be blank. Add another &lt;code&gt;PaymentResult&lt;/code&gt; and that &lt;code&gt;when&lt;/code&gt; stops compiling until someone decides whether the new outcome is worth retrying.&lt;/p&gt;




&lt;h2&gt;
  
  
  Throw Away the Acronym
&lt;/h2&gt;

&lt;p&gt;Here's the whole article as two questions, the two to actually ask in code review:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"Do these things change for the same reason?"&lt;/strong&gt; — the &lt;em&gt;cohesion&lt;/em&gt; question. If yes, keep them together; if no, separate them. SRP asks it about classes, ISP about interfaces.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"If this changes, what else is forced to move?"&lt;/strong&gt; — the &lt;em&gt;coupling&lt;/em&gt; 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).&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 keep arriving, 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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Principle&lt;/th&gt;
&lt;th&gt;Under-applied&lt;/th&gt;
&lt;th&gt;Over-applied&lt;/th&gt;
&lt;th&gt;The dial&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SRP&lt;/td&gt;
&lt;td&gt;God class&lt;/td&gt;
&lt;td&gt;Shotgun surgery&lt;/td&gt;
&lt;td&gt;One actor per class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OCP&lt;/td&gt;
&lt;td&gt;Growing &lt;code&gt;if/else&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Speculative interfaces&lt;/td&gt;
&lt;td&gt;Wait for the second case; seal what you own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LSP&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Broken:&lt;/em&gt; &lt;code&gt;is&lt;/code&gt;-check patches in callers&lt;/td&gt;
&lt;td&gt;— (constraint, not a dial)&lt;/td&gt;
&lt;td&gt;Can't keep the contract → don't inherit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ISP&lt;/td&gt;
&lt;td&gt;Fat interface&lt;/td&gt;
&lt;td&gt;Interface explosion&lt;/td&gt;
&lt;td&gt;One interface per role&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DIP&lt;/td&gt;
&lt;td&gt;Domain imports infrastructure&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FooServiceImpl&lt;/code&gt; everywhere&lt;/td&gt;
&lt;td&gt;Abstract at true frontiers only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The LSP row reads differently on purpose: a constraint isn't under-applied, it's &lt;em&gt;broken&lt;/em&gt;, and the &lt;code&gt;is&lt;/code&gt;-check patches are the symptom you see in callers.&lt;/p&gt;

&lt;p&gt;Cohesion and coupling aren't SOLID's children; they're its grandparents. Stevens, Myers, and Constantine named the pair in "Structured Design" (&lt;em&gt;IBM Systems Journal&lt;/em&gt;, 1974); Parnas nailed the underlying idea as &lt;em&gt;information hiding&lt;/em&gt; in 1972; the acronym arrived three decades later. The letters are the most successful marketing campaign those two ideas ever had, though LSP also carries a correctness rule that neither force gives you on its own. Useful as mnemonics, dangerous as a checklist: a checklist tells you to add an interface, and the forces tell you whether the interface bought you anything.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Everything in this article lives in &lt;a href="https://github.com/dionisioC/blog/tree/main/posts/2026-08-solid-cohesion-coupling/code" rel="noopener noreferrer"&gt;the companion repo&lt;/a&gt;: one Gradle project holding the clean slices and the &lt;code&gt;smells&lt;/code&gt; package side by side, with a test pinning each claim (the VIP balance really goes negative, the same cart really charges the PSP once, a gift-card order really can't take a refund, the decorator order really changes the metric) and a &lt;code&gt;main()&lt;/code&gt; you can run.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>solidprinciples</category>
      <category>architecture</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
