From d5a811d2b575845b23f2d6835ea18ebe46459148 Mon Sep 17 00:00:00 2001 From: Camgineer Date: Mon, 14 Sep 2026 12:28:04 -0400 Subject: [PATCH] feat(cstack): add oop-best-practices Park this general engineering skill in C-Stack for later review. --- skills/oop-best-practices/SKILL.md | 150 ++ .../references/advanced-modeling-concepts.md | 164 +++ .../references/book-influences.md | 60 + .../references/change-driven-design.md | 46 + .../references/core-principles.md | 46 + .../references/csharp-examples.md | 1300 +++++++++++++++++ .../references/dependency-management.md | 94 ++ .../domain-language-and-modeling.md | 50 + .../fran-iglesias-practical-guidance.md | 244 ++++ .../references/go-examples.md | 756 ++++++++++ .../references/gradual-abstraction.md | 106 ++ .../references/java-examples.md | 993 +++++++++++++ .../references/language-examples.md | 60 + .../references/message-based-design.md | 85 ++ .../references/method-design.md | 71 + .../references/naming-and-abstractions.md | 161 ++ .../references/object-calisthenics.md | 66 + .../references/oop-good-practices-examples.md | 68 + .../references/php-examples.md | 1184 +++++++++++++++ .../references/python-examples.md | 832 +++++++++++ .../references/ruby-examples.md | 902 ++++++++++++ .../references/rust-examples.md | 770 ++++++++++ .../references/simple-design-rules.md | 96 ++ .../references/solid-principles.md | 137 ++ .../references/typescript-examples.md | 843 +++++++++++ .../references/value-objects-advanced.md | 355 +++++ tests/inventory.test.mjs | 3 +- 27 files changed, 9641 insertions(+), 1 deletion(-) create mode 100644 skills/oop-best-practices/SKILL.md create mode 100644 skills/oop-best-practices/references/advanced-modeling-concepts.md create mode 100644 skills/oop-best-practices/references/book-influences.md create mode 100644 skills/oop-best-practices/references/change-driven-design.md create mode 100644 skills/oop-best-practices/references/core-principles.md create mode 100644 skills/oop-best-practices/references/csharp-examples.md create mode 100644 skills/oop-best-practices/references/dependency-management.md create mode 100644 skills/oop-best-practices/references/domain-language-and-modeling.md create mode 100644 skills/oop-best-practices/references/fran-iglesias-practical-guidance.md create mode 100644 skills/oop-best-practices/references/go-examples.md create mode 100644 skills/oop-best-practices/references/gradual-abstraction.md create mode 100644 skills/oop-best-practices/references/java-examples.md create mode 100644 skills/oop-best-practices/references/language-examples.md create mode 100644 skills/oop-best-practices/references/message-based-design.md create mode 100644 skills/oop-best-practices/references/method-design.md create mode 100644 skills/oop-best-practices/references/naming-and-abstractions.md create mode 100644 skills/oop-best-practices/references/object-calisthenics.md create mode 100644 skills/oop-best-practices/references/oop-good-practices-examples.md create mode 100644 skills/oop-best-practices/references/php-examples.md create mode 100644 skills/oop-best-practices/references/python-examples.md create mode 100644 skills/oop-best-practices/references/ruby-examples.md create mode 100644 skills/oop-best-practices/references/rust-examples.md create mode 100644 skills/oop-best-practices/references/simple-design-rules.md create mode 100644 skills/oop-best-practices/references/solid-principles.md create mode 100644 skills/oop-best-practices/references/typescript-examples.md create mode 100644 skills/oop-best-practices/references/value-objects-advanced.md diff --git a/skills/oop-best-practices/SKILL.md b/skills/oop-best-practices/SKILL.md new file mode 100644 index 0000000..2f027a6 --- /dev/null +++ b/skills/oop-best-practices/SKILL.md @@ -0,0 +1,150 @@ +--- +name: oop-best-practices +description: Day-to-day OOP guidance for writing and reviewing clean, maintainable code. Use when naming classes and methods, defining object boundaries, introducing or reviewing Value Objects (equality, hashing, immutability, parsing, normalization, optionality), designing first-class collections, applying Tell Don't Ask or Law of Demeter, enforcing Object Calisthenics, reducing cohesion problems, choosing between inheritance and composition, or reviewing SOLID violations in TypeScript, Java, C#, Python, Ruby, PHP, Go, or Rust. +license: MIT +metadata: + author: luckys + version: "1.0.0" +--- + +# OOP Best Practices + +Use this skill for everyday coding decisions that shape readability, cohesion, and long-term maintainability. + +Use it especially when the task benefits from: + +- stronger naming and more explicit abstractions +- clearer object responsibilities and encapsulated invariants +- message-based collaboration and role-oriented objects +- composition over inheritance in everyday design choices + +## Working Style + +1. Write code for the next reader, not just for the compiler. +2. Keep behavior close to the concept that owns it. +3. Prefer simple collaborations over clever object graphs. +4. Use names, boundaries, and APIs to reveal intent. +5. Let objects carry their own rules when they can. + +## Review Workflow + +1. Identify the concept. + - What concept is this code modeling? + - What rules or invariants belong to that concept? + +2. Check the boundary. + - Is the object exposing raw data or meaningful behavior? + - Are callers forced to know too much about internal structure? + +3. Check the shape. + - Are methods mixing multiple abstraction levels? + - Is the class carrying more than one reason to change? + - Are names explicit enough to understand intent quickly? + +4. Apply the lightest useful improvement. + - rename for clarity + - extract method + - extract value object + - extract first-class collection + - move behavior to the object that owns the data + - split the class by responsibility + +## Additional Review Lenses + +### Naming and abstraction discipline + +- If a name is weak, question the abstraction before polishing the wording. +- Avoid premature abstractions that erase the concept or guess too much future reuse. +- Remove duplicated knowledge, not merely duplicated syntax. + +### Encapsulation and object responsibility + +- Move rules to the concept that owns them. +- Prefer rich objects over data carriers when the concept has meaningful behavior. +- Let services orchestrate when objects can own the rule. + +### Message-based design + +- Prefer asking collaborators for meaningful behavior over pulling data out. +- Depend on roles and messages rather than concrete internal structure. +- Keep public interfaces small, explicit, and intention revealing. + +### Structural simplicity + +- Prefer composition when behavior changes independently. +- Keep inheritance shallow and honest. +- Avoid object graphs that force train-wreck navigation. + +## Day-to-Day Rules + +- Prefer intention-revealing names over short names. +- Keep methods shallow and centered on one level of abstraction. +- Use early returns when they reduce branching noise. +- Keep classes cohesive instead of merely small. +- Introduce a Value Object when identity does not matter and semantic guarantees, type safety, or behavior justify a domain type. +- Prefer telling collaborators what to do over asking for their data and deciding elsewhere. +- Introduce first-class collections when collections have their own invariants. +- Give Value Objects semantic equality, matching hash behavior, and deeply immutable observation; do not rely on object-reference equality or shallow `readonly`. +- Depend on small roles instead of volatile concrete details. +- Prefer composition when behavior changes independently. +- Keep public APIs smaller than internal implementation detail. + +## Good Signals + +- The class name matches the behavior it owns. +- Invalid states are rejected early. +- Most methods can be understood without reading unrelated helpers. +- Callers depend on a small surface area. +- The same concept is named consistently across the codebase. + +## Warning Signs + +- A method needs several comments to be readable. +- A class mostly exposes getters and setters. +- Many callers repeat the same validation or branching logic. +- A change in one concept forces edits across many unrelated files. +- The object model looks like data transport with behavior bolted on elsewhere. + +## References + +- Read `references/core-principles.md` for condensed coding heuristics. +- Read `references/book-influences.md` for a source-oriented map of the key books behind this skill. +- Read `references/naming-and-abstractions.md` when naming or abstraction quality is the main issue. +- Read `references/message-based-design.md` when object collaboration and roles matter most. +- Read `references/advanced-modeling-concepts.md` for richer object choices that still stay within everyday OO design. +- Read `references/fran-iglesias-practical-guidance.md` for practical OO heuristics distilled from Fran Iglesias. +- Read `references/solid-principles.md` when SOLID violations or design pressure around single responsibility, open-closed, or dependency inversion are the main issue. +- Read `references/object-calisthenics.md` when applying strict OO discipline rules to clean up a class or method. +- Read `references/dependency-management.md` when coupling, dependency direction, or collaborator injection decisions are the focus. +- Read `references/method-design.md` when method length, abstraction level, or intention-revealing structure is the problem. +- Read `references/gradual-abstraction.md` when the right moment to introduce abstraction is unclear or the design is being over-engineered too early. +- Read `references/language-examples.md` for an index of language-specific example files (TypeScript, Java, Python, C#, Ruby, PHP, Go, Rust). +- Read `references/go-examples.md` for OOP concepts in Go (structs, implicit interfaces, composition, no inheritance). +- Read `references/rust-examples.md` for OOP concepts in Rust (structs, traits, newtype pattern, ownership as immutability). +- Read `references/simple-design-rules.md` for Kent Beck's 4 Rules of Simple Design: passes tests, reveals intention, no duplication, fewest elements — with CodelyTV examples. +- Read `references/oop-good-practices-examples.md` for corrected cross-language lessons on Demeter, Tell Don't Ask, named construction, collection identity, dependency roles, and course counterexamples. +- Read `references/value-objects-advanced.md` as the canonical Value Object guide: selection criteria, invariant ownership, construction/parsing, equality and hashing, deep immutability, behavior, optionality, first-class collections, persistence, testing, and safe evolution. + +## Related Skills + +- Use `ddd-best-practices` when object ownership also defines a consistency, lifecycle, repository, or transaction boundary. +- Use `tdd-best-practices` for Value Object contract tests, boundary analysis, property-based tests, and deterministic fixtures. +- Use `refactoring-best-practices` for risky or legacy code changes. +- Use `design-patterns-best-practices` when the main question is pattern selection. +- Use `rest-api-best-practices` when designing the HTTP API surface that exposes these objects. + +## Source Influences + +This skill is synthesized from ideas emphasized in: + +- `Codigo Sostenible` by Carlos Blé +- `Implementation Patterns` by Kent Beck +- `Practical Object-Oriented Design in Ruby` by Sandi Metz +- `99 Bottles of OOP` by Sandi Metz +- Fran Iglesias's `design-principles` articles +- Fran Iglesias's `good-practices` articles +- Fran Iglesias's `Object Calisthenics` series +- [CodelyTV OOP Good Practices course](https://github.com/CodelyTV/object_oriented_programming-good_practices-course) (including progressive and overwritten educational counterexamples) +- [CodelyTV Aggregates course](https://github.com/CodelyTV/aggregates-course) +- [CodelyTV Value Objects course](https://github.com/CodelyTV/value_objects-course) +- [CodelyTV Four Rules of Simple Design course](https://github.com/CodelyTV/four_rules_of_simple_design-course) (including intentional naming, YAGNI, interface, duplication, and testing counterexamples) diff --git a/skills/oop-best-practices/references/advanced-modeling-concepts.md b/skills/oop-best-practices/references/advanced-modeling-concepts.md new file mode 100644 index 0000000..7019465 --- /dev/null +++ b/skills/oop-best-practices/references/advanced-modeling-concepts.md @@ -0,0 +1,164 @@ +# Advanced Modeling Concepts + +Use this reference when the design problem is no longer only about basic object boundaries, but about stronger object choices that still belong to everyday OO design. + +Topics that are mainly about safe refactoring or pattern selection belong in the corresponding skills. + +## Immutable Objects + +Immutability works well when: + +- the concept is a value, not an identity +- replacing an object is cheaper than coordinating mutable state +- you want simpler reasoning and fewer hidden side effects + +Typical candidates: + +- value objects +- first-class collections +- small configuration objects +- result objects + +A useful rule: + +- prefer returning a new object when the concept represents a value transformation +- keep mutable state only where identity and lifecycle truly matter + +## Null Object + +Use a null object when absence is common and callers should not branch on it constantly. + +It helps when: + +- the missing behavior still has a valid neutral response +- conditionals checking for missing collaborators repeat everywhere +- you want the same role to exist in all code paths + +Do not use it when absence is exceptional and deserves explicit handling. + +## Anemic Models versus Rich Models + +An anemic model stores data while behavior and rules live elsewhere. +A rich model keeps important rules close to the concept that owns them. + +Warning signs of anemia: + +- state is read and changed from outside repeatedly +- services know too much about entity internals +- duplicated rule logic appears across use cases +- tests become fragile because callers must assemble too much internal state + +A useful rule: + +- services should orchestrate +- entities and value objects should own their rules + +## Rename as a Modeling Tool + +Rename is not cosmetic. +Use it to move knowledge into the code. + +A useful rename: + +- makes a concept explicit +- reduces the need for comments +- clarifies responsibility +- reveals when an abstraction is wrong or premature + +## Fit for Purpose over Theoretical Purity + +Not every system needs every advanced modeling move. +Choose the lightest concept that makes the code easier to explain and cheaper to evolve. + +## When a Concept Deserves Its Own Object + +A concept earns its own class when it carries more than a plain value. + +Signs that a primitive or raw data field should become its own type: + +- the same validation logic appears in multiple places before using the value +- several fields always travel together and must stay consistent (Data Clump) +- the concept has its own rules, constraints, or derived computations +- callers cannot trust the value without context because the type alone gives no guarantees + +Practical triggers (from Refactor Cotidiano — Fran Iglesias): + +- you are repeating `isValidEmail(string $email)` checks everywhere: introduce an `Email` type that validates on construction +- a `firstName` and `lastName` always appear together: introduce a `PersonName` type +- a numeric value has business meaning (a tax rate, a threshold): promote it to a named type or constant + +A concept does not yet deserve its own object when: + +- it is genuinely a one-off helper with no reuse or rule +- encapsulating it would add indirection without adding clarity +- the domain does not yet use it as a stable idea + +## Recognizing When a Model Has Grown Wrong + +A class that started as one concept and silently became two is one of the most costly modeling mistakes. + +Warning pattern (from Refactor Cotidiano): + +- a `Book` class gains an `issue` field to also represent magazines +- later it gains `dvd`, `ebook`, and `cd` fields +- the class now requires inspecting each instance to know what kind of thing it really is + +This is the point at which the model forces the reader to think in order to understand — the opposite of what a good model should do. + +Rules for detecting a concept that has outgrown its boundary: + +- you need to inspect internal state to know what the object is +- null-checking or flag-checking replaces polymorphism +- a new requirement breaks existing cases because the class was never meant to cover them + +When this happens, split: each distinct real-world concept becomes its own class. Hierarchy or composition can be introduced once the separation is clear. + +## Where Knowledge Belongs: Information Expert and Creator + +Two GRASP patterns (Craig Larman, cited in Refactor Cotidiano) answer the question "who should do this?": + +**Information Expert**: assign responsibility to the object that already has the information needed to fulfill it. An object should not expose its internals so that an external service can operate on them; the operation belongs inside. + +**Creator**: the object that groups or aggregates smaller objects is the right one to create them. Invoice lines do not exist outside an invoice, so `Invoice` should be the one that creates `InvoiceLine` objects — not a service that builds both separately. + +These two patterns together reduce the pattern of services that reach into entities, extract data, make decisions, and then push results back in. + +## Tell, Don't Ask + +Querying an object's state, making a decision outside it, and then setting a result back is a symptom of knowledge in the wrong place. + +The Tell, Don't Ask principle (from Refactor Cotidiano — Fran Iglesias): + +- each object is responsible for its own state +- callers should tell objects what to do, not ask what they contain and compute the answer externally +- moving the computation inside the object removes the duplication of knowing its internals from outside + +A practical test: if a service method reads several fields from an entity to compute one result that concerns only that entity, the method belongs on the entity. + +## Delaying Abstractions until Structure Is Stable + +Generalizing too early is harder to undo than it looks (from Codigo Sostenible — Carlos Blé): + +- ten lines of duplicated code are easy to turn into a loop; the reverse is harder +- a generalized component hides the domain concept it was derived from +- future readers must understand the abstraction before they can understand the domain + +The preferred moment to introduce a new abstraction: + +- after a requirement is finished and all tests pass +- when reviewing the code reveals obvious duplication of the same business rule +- not while implementing the first occurrence + +Avoid introducing abstractions to handle future scenarios that have not been requested yet. + +## Intentionality as a Modeling Signal + +Code without explicit intentionality forces readers to reconstruct the author's reasoning (from Codigo Sostenible — Carlos Blé). + +A model has explicit intentionality when: + +- method and type names communicate the purpose, not just the mechanism +- the choice of types themselves documents constraints (`Email` instead of `string`) +- the structure of the code matches the structure of the domain concept + +When you inherit code and must modify it, you pay the full cost of missing intentionality immediately. The modeling investment that would have made it cheap to understand is now owed by the new reader. diff --git a/skills/oop-best-practices/references/book-influences.md b/skills/oop-best-practices/references/book-influences.md new file mode 100644 index 0000000..64c753f --- /dev/null +++ b/skills/oop-best-practices/references/book-influences.md @@ -0,0 +1,60 @@ +# Book Influences + +This reference maps the most useful ideas from the core books behind this skill into practical object-oriented heuristics. + +## `99 Bottles of OOP` by Sandi Metz + +Main ideas to preserve in daily work: + +- Start with the simplest understandable solution that is good enough now. +- Let stable variation earn a better abstraction instead of guessing it too early. +- Notice when conditionals are really hiding a missing collaborator or role. +- Prefer designs that make the next change local and obvious. + +Actionable takeaways: + +- Reach a clear first solution before chasing elegance. +- Prefer simple object boundaries over speculative extension points. +- Introduce role-based collaborators when variation is stable enough to deserve a name. + +## `Codigo Sostenible` by Carlos Blé + +Main ideas to preserve in daily work: + +- Names are abstractions, so naming quality directly shapes design quality. +- Generality can damage comprehension when it erases real concepts. +- Premature abstractions create accidental complexity. +- DRY is about duplicated knowledge, not every repeated line that merely looks similar. + +Actionable takeaways: + +- Prefer concrete and pronounceable names from the problem space. +- If you cannot find a good name for an abstraction, question whether the abstraction is ready to exist. +- Remove duplicated rules and concepts, not just duplicated syntax. + +## `Practical Object-Oriented Design in Ruby` by Sandi Metz + +Main ideas to preserve in daily work: + +- Single responsibility keeps classes understandable and cheap to change. +- Depend on behavior, not on data structure. +- Inject and isolate dependencies to reduce coupling. +- Ask collaborators for what you need instead of telling them how to do it. +- Duck typing reveals roles that transcend concrete classes. +- Message-based design leads to better object boundaries than class-first thinking. + +Actionable takeaways: + +- Let each class have one clear reason to change. +- Prefer explicit public interfaces and smaller contexts. +- Trust collaborators to honor their roles. +- Reach for inheritance only when the abstraction and substitution are genuinely stable. + +## How to Use These Influences + +When reviewing or writing object-oriented code, ask: + +- Is the code only as abstract as current knowledge justifies? +- Are names helping the reader see the design intent? +- Are responsibilities, interfaces, and roles clearer after the change? +- Does the object model own its own rules instead of leaking them outward? diff --git a/skills/oop-best-practices/references/change-driven-design.md b/skills/oop-best-practices/references/change-driven-design.md new file mode 100644 index 0000000..c2d9af0 --- /dev/null +++ b/skills/oop-best-practices/references/change-driven-design.md @@ -0,0 +1,46 @@ +# Avoid Speculative Abstraction + +Use this reference when everyday object-oriented design starts drifting toward abstractions that current knowledge does not justify. + +## Start with Understandable Code + +A first version does not need to be highly extensible if there is no confirmed variation yet. + +Prefer: + +- straightforward code +- explicit intent +- minimal abstractions +- collaborators with obvious responsibilities + +Avoid: + +- speculative hierarchies +- abstract base classes created for future guesses +- factories that hide no meaningful construction choice + +## Let Stable Variation Earn Structure + +When new variation appears, ask: + +- Is this variation likely to persist? +- Does the current object model make the variation noisy or scattered? +- Would naming the role make the design easier to explain? + +If the answer is no, keep the code simple. +If the answer is yes, introduce the lightest abstraction that makes the model clearer. + +## Treat Growing Conditionals as a Signal + +Conditionals are not always wrong, but they deserve attention when: + +- they keep growing with each new case +- they branch on roles or types +- they spread the same decision across many callers + +At that point, a better object boundary or role may be more expressive than another branch. + +## Decision Rule + +Do not design all variation up front. +Design just enough structure so that the current model remains readable and honest. diff --git a/skills/oop-best-practices/references/core-principles.md b/skills/oop-best-practices/references/core-principles.md new file mode 100644 index 0000000..48677c0 --- /dev/null +++ b/skills/oop-best-practices/references/core-principles.md @@ -0,0 +1,46 @@ +# Core Principles + +Use these principles to guide normal coding work. They are meant to improve readability, flexibility, and locality of change. + +## Write for Change + +- Prefer designs that make the next likely change local. +- Keep public interfaces narrow. +- Delay abstraction until it removes real duplication or stabilizes recurring variation. + +## Model Concepts, Not Containers + +- Keep behavior close to the data that gives it meaning. +- Avoid turning objects into passive records manipulated elsewhere. +- Let meaningful concepts protect their own invariants when practical. + +## Optimize for Cohesion + +- Split classes when different methods change for different reasons. +- Split methods when they mix setup, decisions, and side effects. +- Keep each method at one main level of abstraction. + +## Use Meaningful Names + +- Prefer full names instead of abbreviations. +- Name classes by responsibility and methods by intent. +- Keep terms consistent across the model. + +## Prefer Telling Over Asking + +- Send messages to collaborators instead of navigating through their internals. +- Avoid train wrecks and deep object graph traversal. +- Keep decisions with the object that has the relevant knowledge. + +## Make Values Explicit + +- Introduce Value Objects when a cohesive value needs semantic type safety, intrinsic rules, normalization, comparison, formatting, or behavior. +- Base equality and hashing on all defining values, and protect invariants with deeply immutable observation and defensive copies. +- Introduce first-class collections when the collection has rules of its own. +- Keep intrinsic validation and formatting close to the concept; pass contextual policy explicitly. + +## Use Composition Carefully + +- Reach for composition when behavior varies independently. +- Keep inheritance narrow and honest. +- Prefer small roles over broad concrete dependencies. diff --git a/skills/oop-best-practices/references/csharp-examples.md b/skills/oop-best-practices/references/csharp-examples.md new file mode 100644 index 0000000..493665a --- /dev/null +++ b/skills/oop-best-practices/references/csharp-examples.md @@ -0,0 +1,1300 @@ +# C# Examples + +These examples cover the same core concepts as the other language-specific example files. + +## Concepts Covered + +- Value Objects and Invariants +- First-Class Collections +- Tell, Don't Ask +- Role-Based Collaboration +- Dependency Injection +- Explicit Interfaces +- Duck Typing / Protocol-Style Roles +- Composition over Inheritance +- Message-Based Design +- Law of Demeter Violation and Fix +- Immutable Objects +- Null Object +- Anemic versus Rich Model + +## Value Objects and Invariants + +```csharp +public sealed class Money +{ + private readonly int cents; + + public Money(int cents) + { + if (cents < 0) + { + throw new ArgumentException("Money cannot be negative"); + } + + this.cents = cents; + } + + public Money Add(Money other) + { + return new Money(cents + other.cents); + } + + public Money MultiplyBy(int percent) + { + return new Money((int)Math.Round(cents * percent / 100.0)); + } + + public int Value() + { + return cents; + } +} +``` + +## First-Class Collections + +```csharp +public sealed class OrderLine +{ + private readonly Money subtotalAmount; + + public OrderLine(Money subtotalAmount) + { + this.subtotalAmount = subtotalAmount; + } + + public Money Subtotal() + { + return subtotalAmount; + } +} + +public sealed class OrderLines +{ + private readonly IReadOnlyCollection items; + + public OrderLines(IReadOnlyCollection items) + { + this.items = items; + } + + public Money Total() + { + var total = new Money(0); + foreach (var item in items) + { + total = total.Add(item.Subtotal()); + } + return total; + } + + public bool IsEmpty() + { + return items.Count == 0; + } +} +``` + +## Tell, Don't Ask + +```csharp +public sealed class Address +{ + private readonly string countryCode; + + public Address(string countryCode) + { + this.countryCode = countryCode; + } + + public bool IsDomestic() + { + return countryCode == "ES"; + } +} + +public sealed class Shipment +{ + private readonly Address address; + + public Shipment(Address address) + { + this.address = address; + } + + public int DispatchWindowInDays() + { + return address.IsDomestic() ? 2 : 5; + } +} +``` + +## Role-Based Collaboration + +```csharp +public interface ICurrencyFormatter +{ + string Format(Money amount); +} + +public sealed class OrderSummary +{ + private readonly ICurrencyFormatter formatter; + + public OrderSummary(ICurrencyFormatter formatter) + { + this.formatter = formatter; + } + + public string TotalLabel(OrderLines lines) + { + return formatter.Format(lines.Total()); + } +} +``` + +## Dependency Injection + +```csharp +public interface IMailer +{ + void Send(string to, string body); +} + +public sealed class Invoice +{ + private readonly string recipient; + private readonly string bodyText; + + public Invoice(string recipient, string bodyText) + { + this.recipient = recipient; + this.bodyText = bodyText; + } + + public string RecipientEmail() + { + return recipient; + } + + public string Body() + { + return bodyText; + } +} + +public sealed class InvoiceSender +{ + private readonly IMailer mailer; + + public InvoiceSender(IMailer mailer) + { + this.mailer = mailer; + } + + public void Send(Invoice invoice) + { + mailer.Send(invoice.RecipientEmail(), invoice.Body()); + } +} +``` + +## Explicit Interfaces + +```csharp +public interface IPaymentGateway +{ + void Charge(string customerId, Money amount); +} + +public sealed class SubscriptionActivator +{ + private readonly IPaymentGateway paymentGateway; + + public SubscriptionActivator(IPaymentGateway paymentGateway) + { + this.paymentGateway = paymentGateway; + } + + public void Activate(string customerId, Money fee) + { + paymentGateway.Charge(customerId, fee); + } +} +``` + +## Duck Typing / Protocol-Style Roles + +```csharp +public interface IStockSource +{ + int AvailableUnits(); +} + +public sealed class InventoryReport +{ + private readonly IStockSource source; + + public InventoryReport(IStockSource source) + { + this.source = source; + } + + public bool IsAvailable() + { + return source.AvailableUnits() > 0; + } +} + +public sealed class WarehouseBin : IStockSource +{ + private readonly int units; + + public WarehouseBin(int units) + { + this.units = units; + } + + public int AvailableUnits() + { + return units; + } +} +``` + +## Composition over Inheritance + +```csharp +public interface IDiscountPolicy +{ + Money Apply(Money total); +} + +public interface ITaxPolicy +{ + Money Apply(Money total); +} + +public sealed class CartPricing +{ + private readonly IDiscountPolicy discountPolicy; + private readonly ITaxPolicy taxPolicy; + + public CartPricing(IDiscountPolicy discountPolicy, ITaxPolicy taxPolicy) + { + this.discountPolicy = discountPolicy; + this.taxPolicy = taxPolicy; + } + + public Money Total(Money subtotal) + { + return taxPolicy.Apply(discountPolicy.Apply(subtotal)); + } +} +``` + +## Message-Based Design + +```csharp +public interface ISeatInventory +{ + void Reserve(int seatCount); +} + +public interface IPaymentService +{ + void Charge(Money amount); +} + +public sealed class Booking +{ + private readonly int seats; + private readonly Money amount; + private readonly ISeatInventory inventory; + private readonly IPaymentService payments; + + public Booking(int seats, Money amount, ISeatInventory inventory, IPaymentService payments) + { + this.seats = seats; + this.amount = amount; + this.inventory = inventory; + this.payments = payments; + } + + public void Confirm() + { + inventory.Reserve(seats); + payments.Charge(amount); + } +} +``` + +## Law of Demeter Violation and Fix + +### Before + +```csharp +public sealed class CustomerRecord +{ + private readonly Address address; + + public CustomerRecord(Address address) + { + this.address = address; + } + + public Address ShippingAddress() + { + return address; + } +} + +public sealed class Order +{ + private readonly CustomerRecord customer; + + public Order(CustomerRecord customer) + { + this.customer = customer; + } + + public CustomerRecord CustomerRecord() + { + return customer; + } +} + +var domestic = order.CustomerRecord().ShippingAddress().IsDomestic(); +``` + +### After + +```csharp +public sealed class Customer +{ + private readonly Address address; + + public Customer(Address address) + { + this.address = address; + } + + public bool ShipsDomestically() + { + return address.IsDomestic(); + } +} + +public sealed class PurchaseOrder +{ + private readonly Customer customer; + + public PurchaseOrder(Customer customer) + { + this.customer = customer; + } + + public bool ShipsDomestically() + { + return customer.ShipsDomestically(); + } +} + +var domestic = order.ShipsDomestically(); +``` + +## Immutable Objects + +```csharp +using System.Collections.Generic; + +public sealed class Rooms +{ + private readonly IReadOnlyCollection items; + + public Rooms(IReadOnlyCollection items) + { + this.items = items; + } + + public Rooms Add(string room) + { + var copy = new List(items) { room }; + return new Rooms(copy); + } + + public int Count() + { + return items.Count; + } +} +``` + +## Null Object + +```csharp +public interface ILogger +{ + void Info(string message); +} + +public sealed class NullLogger : ILogger +{ + public void Info(string message) + { + } +} +``` + +## Anemic versus Rich Model + +### Anemic + +```csharp +public sealed class ScoreData +{ + public int Value { get; set; } + + public ScoreData(int value) + { + Value = value; + } +} + +public sealed class ScoreService +{ + public void Increase(ScoreData score, int points) + { + score.Value = score.Value + points; + } +} +``` + +### Rich + +```csharp +public sealed class Score +{ + private readonly int value; + + public Score(int value) + { + this.value = value; + } + + public Score Increase(int points) + { + return new Score(value + points); + } + + public int Value() + { + return value; + } +} +``` + +## SOLID — Single Responsibility Violation and Fix + +### Before + +```csharp +public sealed class Report +{ + private readonly string title; + private readonly string body; + + public Report(string title, string body) + { + this.title = title; + this.body = body; + } + + public string Title() + { + return title; + } + + public string Body() + { + return body; + } + + public void Save(DbConnection connection) + { + // mixes persistence concern into the data object + var cmd = connection.CreateCommand(); + cmd.CommandText = "INSERT INTO reports (title, body) VALUES (@title, @body)"; + cmd.ExecuteNonQuery(); + } +} +``` + +### After + +```csharp +public sealed class Report +{ + private readonly string title; + private readonly string body; + + public Report(string title, string body) + { + this.title = title; + this.body = body; + } + + public string Title() + { + return title; + } + + public string Body() + { + return body; + } +} + +public sealed class ReportRepository +{ + private readonly DbConnection connection; + + public ReportRepository(DbConnection connection) + { + this.connection = connection; + } + + public void Save(Report report) + { + var cmd = connection.CreateCommand(); + cmd.CommandText = "INSERT INTO reports (title, body) VALUES (@title, @body)"; + cmd.ExecuteNonQuery(); + } +} +``` + +## Object Calisthenics — Wrap Primitive + +```csharp +public sealed class Percentage +{ + private readonly int value; + + public Percentage(int value) + { + if (value < 0 || value > 100) + { + throw new ArgumentOutOfRangeException(nameof(value), "Percentage must be between 0 and 100"); + } + + this.value = value; + } + + public int Of(int amount) + { + return (int)Math.Round(amount * value / 100.0); + } +} + +public sealed class Price +{ + private readonly int cents; + + public Price(int cents) + { + if (cents < 0) + { + throw new ArgumentException("Price cannot be negative"); + } + + this.cents = cents; + } + + public Price ApplyDiscount(Percentage discount) + { + return new Price(cents - discount.Of(cents)); + } + + public int InCents() + { + return cents; + } +} +``` + +## Object Calisthenics — No Else Rule + +### Before + +```csharp +public sealed class ShippingCalculator +{ + public Money ShippingCost(Order order) + { + if (order.IsPremiumMember()) + { + return new Money(0); + } + else + { + if (order.TotalValue().Value() > 10000) + { + return new Money(0); + } + else + { + if (order.ShipsDomestically()) + { + return new Money(500); + } + else + { + return new Money(1500); + } + } + } + } +} +``` + +### After + +```csharp +public sealed class ShippingCalculator +{ + public Money ShippingCost(Order order) + { + if (order.IsPremiumMember()) + { + return new Money(0); + } + + if (order.TotalValue().Value() > 10000) + { + return new Money(0); + } + + if (order.ShipsDomestically()) + { + return new Money(500); + } + + return new Money(1500); + } +} +``` + +## Dependency Direction + +### Before + +```csharp +public sealed class InvoiceExporter +{ + public void Export(string path, string content) + { + // depends directly on a concrete infrastructure class + var fileSystem = new FileSystem(); + fileSystem.WriteAllText(path, content); + } +} +``` + +### After + +```csharp +public interface IDocumentStorage +{ + void Write(string path, string content); +} + +public sealed class InvoiceExporter +{ + private readonly IDocumentStorage storage; + + public InvoiceExporter(IDocumentStorage storage) + { + this.storage = storage; + } + + public void Export(string path, string content) + { + storage.Write(path, content); + } +} + +public sealed class FileDocumentStorage : IDocumentStorage +{ + public void Write(string path, string content) + { + File.WriteAllText(path, content); + } +} +``` + +## Composed Method + +### Before + +```csharp +public sealed class RegistrationService +{ + public void Register(string email, string password) + { + if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) + { + throw new ArgumentException("Invalid email"); + } + + if (password.Length < 8) + { + throw new ArgumentException("Password too short"); + } + + var hash = BCrypt.Net.BCrypt.HashPassword(password); + + var user = new User(email, hash); + userRepository.Save(user); + + mailer.Send(email, "Welcome! Your account is ready."); + } +} +``` + +### After + +```csharp +public sealed class RegistrationService +{ + private readonly IUserRepository userRepository; + private readonly IMailer mailer; + + public RegistrationService(IUserRepository userRepository, IMailer mailer) + { + this.userRepository = userRepository; + this.mailer = mailer; + } + + public void Register(string email, string password) + { + Validate(email, password); + var user = BuildUser(email, password); + Persist(user); + Welcome(user); + } + + private void Validate(string email, string password) + { + if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) + { + throw new ArgumentException("Invalid email"); + } + + if (password.Length < 8) + { + throw new ArgumentException("Password too short"); + } + } + + private User BuildUser(string email, string password) + { + var hash = BCrypt.Net.BCrypt.HashPassword(password); + return new User(email, hash); + } + + private void Persist(User user) + { + userRepository.Save(user); + } + + private void Welcome(User user) + { + mailer.Send(user.Email(), "Welcome! Your account is ready."); + } +} +``` + +## SOLID — Open/Closed Principle + +### Before + +```csharp +public sealed class ShippingCalculator +{ + public Money Cost(string orderType) + { + if (orderType == "standard") + { + return new Money(500); + } + else if (orderType == "express") + { + return new Money(1500); + } + else if (orderType == "overnight") + { + return new Money(3000); + } + + throw new ArgumentException("Unknown order type"); + } +} +``` + +### After + +```csharp +public interface IShippingPolicy +{ + Money Cost(); +} + +public sealed class StandardShipping : IShippingPolicy +{ + public Money Cost() + { + return new Money(500); + } +} + +public sealed class ExpressShipping : IShippingPolicy +{ + public Money Cost() + { + return new Money(1500); + } +} + +public sealed class OvernightShipping : IShippingPolicy +{ + public Money Cost() + { + return new Money(3000); + } +} + +public sealed class ShippingCalculator +{ + private readonly IShippingPolicy policy; + + public ShippingCalculator(IShippingPolicy policy) + { + this.policy = policy; + } + + public Money Cost() + { + return policy.Cost(); + } +} +``` + +## SOLID — Liskov Substitution Principle + +### Before + +```csharp +public class Collection +{ + private readonly List items = new(); + + public virtual void Add(string item) + { + items.Add(item); + } + + public int Count() + { + return items.Count; + } +} + +// LSP violation: the subtype refuses behaviour the base type promises +public sealed class ReadOnlyCollection : Collection +{ + public override void Add(string item) + { + throw new NotSupportedException("This collection is read-only"); + } +} +``` + +### After + +```csharp +public sealed class MutableCollection +{ + private readonly List items = new(); + + public void Add(string item) + { + items.Add(item); + } + + public int Count() + { + return items.Count; + } +} + +public sealed class ReadOnlyCollection +{ + private readonly IReadOnlyList items; + + public ReadOnlyCollection(IReadOnlyList items) + { + this.items = items; + } + + public int Count() + { + return items.Count; + } +} +``` + +## SOLID — Interface Segregation Principle + +### Before + +```csharp +public interface IWorker +{ + void Work(); + void Eat(); + void Sleep(); +} + +public sealed class RobotWorker : IWorker +{ + public void Work() + { + // performs work + } + + public void Eat() + { + throw new NotSupportedException("Robots do not eat"); + } + + public void Sleep() + { + throw new NotSupportedException("Robots do not sleep"); + } +} +``` + +### After + +```csharp +public interface IWorkable +{ + void Work(); +} + +public interface IEatable +{ + void Eat(); +} + +public interface ISleepable +{ + void Sleep(); +} + +public sealed class RobotWorker : IWorkable +{ + public void Work() + { + // performs work + } +} + +public sealed class HumanWorker : IWorkable, IEatable, ISleepable +{ + public void Work() + { + // performs work + } + + public void Eat() + { + // takes a meal break + } + + public void Sleep() + { + // rests overnight + } +} +``` + +## SOLID — Dependency Inversion Principle + +### Before + +```csharp +public sealed class OrderProcessor +{ + public void Process(Order order) + { + // high-level policy depends directly on a concrete infrastructure class + var database = new PostgresDatabase(); + database.Save(order); + } +} +``` + +### After + +```csharp +// The interface is defined in the domain, owned by OrderProcessor +public interface IOrderStore +{ + void Save(Order order); +} + +public sealed class OrderProcessor +{ + private readonly IOrderStore store; + + public OrderProcessor(IOrderStore store) + { + this.store = store; + } + + public void Process(Order order) + { + store.Save(order); + } +} + +public sealed class PostgresOrderStore : IOrderStore +{ + public void Save(Order order) + { + // persist to PostgreSQL + } +} +``` + +## Object Calisthenics — One Level of Indentation + +### Before + +```csharp +public sealed class ReportGenerator +{ + public string GenerateReport(IEnumerable orders) + { + var lines = new List(); + foreach (var order in orders) + { + if (order.IsComplete()) + { + foreach (var item in order.Items()) + { + if (item.Price().Value() > 5000) + { + lines.Add($"{item.Name()}: {item.Price().Value()}"); + } + } + } + } + return string.Join("\n", lines); + } +} +``` + +### After + +```csharp +public sealed class ReportGenerator +{ + public string GenerateReport(IEnumerable orders) + { + var lines = CompleteOrders(orders) + .SelectMany(ExpensiveItems) + .Select(FormatItem); + + return string.Join("\n", lines); + } + + private IEnumerable CompleteOrders(IEnumerable orders) + { + return orders.Where(order => order.IsComplete()); + } + + private IEnumerable ExpensiveItems(Order order) + { + return order.Items().Where(item => item.Price().Value() > 5000); + } + + private string FormatItem(OrderItem item) + { + return $"{item.Name()}: {item.Price().Value()}"; + } +} +``` + +## Object Calisthenics — No Getters/Setters + +### Before + +```csharp +public sealed class Rectangle +{ + private readonly int width; + private readonly int height; + + public Rectangle(int width, int height) + { + this.width = width; + this.height = height; + } + + public int GetWidth() + { + return width; + } + + public int GetHeight() + { + return height; + } +} + +// callers must reach in and compute behaviour externally +var area = rect.GetWidth() * rect.GetHeight(); +var perimeter = 2 * (rect.GetWidth() + rect.GetHeight()); +``` + +### After + +```csharp +public sealed class Rectangle +{ + private readonly int width; + private readonly int height; + + public Rectangle(int width, int height) + { + this.width = width; + this.height = height; + } + + public int Area() + { + return width * height; + } + + public int Perimeter() + { + return 2 * (width + height); + } + + public bool IsSquare() + { + return width == height; + } +} +``` + +## Object Calisthenics — Don't Abbreviate + +### Before + +```csharp +public sealed class OrdMgr +{ + public Money Calc(Order o) + { + return o.Lines().Total(); + } + + public void Proc(Order o) + { + // process the order + } +} +``` + +### After + +```csharp +public sealed class OrderManager +{ + public Money CalculateTotal(Order order) + { + return order.Lines().Total(); + } + + public void ProcessOrder(Order order) + { + // process the order + } +} +``` + +## Explaining Message + +### Before + +```csharp +public sealed class Subscription +{ + private readonly DateTime startDate; + private readonly int durationInDays; + private readonly bool isCancelled; + + public Subscription(DateTime startDate, int durationInDays, bool isCancelled) + { + this.startDate = startDate; + this.durationInDays = durationInDays; + this.isCancelled = isCancelled; + } + + public bool IsExpired() + { + return isCancelled || DateTime.UtcNow > startDate.AddDays(durationInDays); + } +} +``` + +### After + +```csharp +public sealed class Subscription +{ + private readonly DateTime startDate; + private readonly int durationInDays; + private readonly bool isCancelled; + + public Subscription(DateTime startDate, int durationInDays, bool isCancelled) + { + this.startDate = startDate; + this.durationInDays = durationInDays; + this.isCancelled = isCancelled; + } + + public bool IsExpired() + { + return isCancelled || DateTime.UtcNow > ExpirationDate(); + } + + private DateTime ExpirationDate() + { + return startDate.AddDays(durationInDays); + } +} +``` + +## What to Notice + +- Rich models and clear object responsibilities help keep knowledge close to the concept. +- C# makes explicit interfaces, injected collaborators, and small role objects easy to model. +- Protocol-style roles appear as narrow interfaces instead of broad inheritance trees. +- Composition and message passing keep dependencies understandable. +- Wrapping primitives and splitting responsibilities keep each class focused on one reason to change. +- SOLID principles, Object Calisthenics rules, and extracted explaining messages each reduce a different kind of coupling or noise. diff --git a/skills/oop-best-practices/references/dependency-management.md b/skills/oop-best-practices/references/dependency-management.md new file mode 100644 index 0000000..76e18e3 --- /dev/null +++ b/skills/oop-best-practices/references/dependency-management.md @@ -0,0 +1,94 @@ +# Dependency Management + +Use this reference when deciding what to depend on, how to pass collaborators, and how to control coupling between objects. + +## Recognizing Dependencies + +A class has a dependency on another whenever it knows: + +- the name of another class +- the name of a message it intends to send to someone other than itself +- the arguments that message requires +- the order of those arguments + +Each piece of knowledge is a coupling point. If the depended-on thing changes, the depending class may be forced to change too. The goal is not to eliminate dependencies — objects must collaborate — but to keep each class knowing just enough to do its job and not one thing more. + +Coupling between objects accumulates quietly. If a class creates its own collaborators, those collaborators' class names, argument lists, and argument order all become hidden dependencies. Hidden dependencies are more dangerous than explicit ones because they are easy to miss and hard to extract. + +## Dependency Direction + +When two classes must be coupled, the direction of the dependency matters. Choose to depend on things that change less often than you do. + +Three truths govern this choice: + +- some classes are more likely than others to have changes in requirements +- concrete classes are more likely to change than abstract ones +- changing a class that has many dependents causes widespread consequences + +If you apply these ideas to your design choices: + +- if a class is concrete and volatile, then depend on an abstraction in front of it rather than on the class directly +- if a class is abstract and stable, then it is a safe target for many dependents +- if a class is both concrete and has many dependents, it is in a dangerous position — changes to it ripple everywhere + +Abstract classes and interfaces attract dependents precisely because they are stable. Concrete classes tend to change. High-level business logic should not depend on low-level infrastructure; the dependency should point toward the abstraction, not toward the implementation. This is the dependency inversion principle: a class with a high level of abstraction should not depend on a class with a low level of abstraction. + +## Inject vs Create + +When a class needs a collaborator, it faces a choice: create the collaborator internally or receive it from outside. + +Creating a collaborator internally: + +- hard-codes the collaborator's class name inside the depending class +- binds the depending class to a specific implementation +- makes substituting the collaborator impossible without modifying the class +- hides the dependency from callers + +Injecting a collaborator: + +- reduces the dependency to a single expectation: that the injected object responds to a certain message +- makes the dependency explicit and visible at the call site +- allows any compatible object to be passed, without the class knowing or caring which class it belongs to +- makes the class easier to test and easier to reuse in different contexts + +The rule is: if the class only needs to send a message to a collaborator, it does not need to know the collaborator's class name. The responsibility for knowing which class to instantiate belongs elsewhere — in a factory, a composition root, or the calling context. + +If you are constrained and cannot inject the dependency, isolate instance creation to a single location inside the class rather than scattering it. Centralizing creation limits the reach of the dependency and makes it easier to change later. + +## Isolating Volatile Dependencies + +Some dependencies are unavoidable — a class must reach a specific external system, a specific format, or a specific third-party interface. When that external thing is volatile, isolate it behind a stable wrapper. + +Rules for isolation: + +- if an external class name appears in multiple methods, extract the access to a single method inside your class — callers use your method, not the external interface directly +- if a message chain reaches deep into another object's internals, wrap the chain in a method that expresses the intent rather than the navigation path +- if you depend on something you do not own and cannot change, create a thin boundary layer that your code depends on and that translates to the external interface — your code never touches the external shape directly + +The goal is that a change to the external thing requires a change only in the wrapper, not throughout the class or the application. + +## Argument Order and Named Parameters + +Positional arguments create an additional form of dependency: the depending class must know not only what arguments a message takes but also the order in which to pass them. This order is invisible in the call, fragile under refactoring, and easy to get wrong. + +Heuristics for reducing argument-order coupling: + +- if a method takes more than one or two arguments, prefer named or keyword arguments over positional ones +- named arguments make the call site self-documenting and allow the receiver to reorder, add, or remove parameters without breaking callers +- if you must depend on a method with positional arguments that you do not own, wrap it in a single factory method inside your codebase — all calls go through the wrapper, so argument-order coupling is confined to one place +- if an argument has a sensible default, embed the default in the parameter definition rather than in every call site + +Trading positional coupling for name coupling is a good trade. Names are more stable than positions and communicate intent at the point of use. + +Parameters themselves are a weaker form of coupling than permanent references. Passing a collaborator as a parameter ties the objects together only for the duration of the call. A stored reference ties them together for the object's lifetime. Prefer parameters over stored references where it is natural to do so. + +## Warning Signs + +Watch for these patterns — each is a signal that dependencies are harder to manage than they need to be: + +- a hardcoded class name inside a method body is a hidden dependency; it means the class cannot collaborate with anything else +- creating an instance deep inside a complex method mixes object construction with business logic; extract or inject it +- a chain of messages that navigates through multiple objects to reach behavior is a coupling chain — each intermediate object is now a dependency; ask whether a message can be introduced closer to where the behavior lives +- two classes that each depend on the other form a circular dependency; circular dependencies complicate maintenance, prevent independent deployment, and tend to cause initialization problems +- a class that depends on many other concrete classes has high coupling; changes anywhere in that network can force changes here +- a class that many other classes depend on is a stability bottleneck; if it is concrete, it is risky; if it must change often, the consequences are wide diff --git a/skills/oop-best-practices/references/domain-language-and-modeling.md b/skills/oop-best-practices/references/domain-language-and-modeling.md new file mode 100644 index 0000000..b99562d --- /dev/null +++ b/skills/oop-best-practices/references/domain-language-and-modeling.md @@ -0,0 +1,50 @@ +# Concept Language and Precision + +Use this reference when object-oriented design needs sharper concept names and clearer conceptual boundaries, without turning the problem into full strategic modeling. + +## Everyday Code Still Needs Concept Precision + +Even outside explicit domain-driven design work, code becomes harder to understand when concepts are: + +- ambiguous +- overloaded +- generic without being helpful +- named differently in nearby places + +Software design needs words that are precise enough to guide code and conversation. + +## Prefer Fit-for-Purpose Concept Names + +A good concept name: + +- matches the responsibility the object owns +- highlights what makes the concept distinct +- stays understandable in the local context +- reduces the need for extra explanation + +A weak concept name: + +- sounds elegant but hides the real rule +- collapses several different ideas into one word +- uses technical noise instead of meaning + +## Make Distinctions Explicit + +If two nearby ideas behave differently, let the names show that difference. + +Useful questions: + +- Are two words being used for the same concept? +- Is one word hiding two different concepts? +- Would a more explicit name make the rule easier to place? + +## Keep the Vocabulary Close to the Code + +The best concept language for everyday OO design is: + +- easy to change +- visible in names and interfaces +- refined as the team learns more + +The goal is not theoretical purity. +The goal is code that communicates its model clearly. diff --git a/skills/oop-best-practices/references/fran-iglesias-practical-guidance.md b/skills/oop-best-practices/references/fran-iglesias-practical-guidance.md new file mode 100644 index 0000000..edbff5a --- /dev/null +++ b/skills/oop-best-practices/references/fran-iglesias-practical-guidance.md @@ -0,0 +1,244 @@ +# Fran Iglesias Practical Guidance + +This reference distills practical ideas from Fran Iglesias's `design-principles`, `good-practices`, and `oop` articles into heuristics for everyday object-oriented design. +It keeps only the OO-focused takeaways that fit this skill. Refactoring workflows, pattern selection, and DDD-heavy modeling belong elsewhere. + +## Main Themes + +Across these articles, several ideas repeat consistently: + +- model important concepts as objects instead of stretching primitive types +- move knowledge to the object that owns it +- prefer expressive code over explanatory comments +- keep inheritance shallow and honest +- use composition when behavior has multiple axes of variation +- make roles explicit when different objects can answer the same message +- keep dependencies visible without blindly injecting everything +- let objects control their representation instead of exposing raw structure +- use rename as a safe way to inject knowledge into the code + +## Concepts over Raw Types + +From `types_vs_value_objects`: + +- language types are building blocks, not business concepts by themselves +- a concept should hide its representation behind a stable interface +- do not inherit from primitive-like types just to reuse behavior or satisfy type hints +- composition lets the concept evolve without inheriting invalid behavior + +Practical rule: + +- if a value has invariants, operations, or meaning of its own, give it its own object instead of leaking primitives through the codebase + +## Representation without Leaking Structure + +From `representation-2`: + +- getters added only for DTOs or serialization weaken information hiding +- the object should stay in control of how its information is exposed +- define representation boundaries from consumer needs, not from the full internal structure +- prefer narrow representation collaborators over exposing every field + +Practical rule: + +- if a new getter exists only to feed a serializer or mapper, the boundary is probably too leaky + +## Rich Objects over Anemic Objects + +From `anemic-objects`: + +- a data class that only exposes state and setters is often a design smell +- tell-don't-ask and the Law of Demeter are usually violated together +- duplicated rule logic outside the object is a symptom of misplaced responsibility +- tests become easier when behavior moves back into the objects that own the state + +Practical rule: + +- if a service repeatedly reads an object, computes a rule, and pushes state back, try moving that rule into the object + +## Composition, Roles, and Honest Inheritance + +From `inheritance-composition`: + +- deep inheritance trees often signal too many specialization axes in one hierarchy +- inheritance works best when a small base abstraction defines a stable common behavior +- composition is often the right choice when you want behavior reuse without taxonomic coupling +- roles are a cleaner alternative when several unrelated objects can answer the same message + +Practical rule: + +- if two dimensions of change would multiply subclasses, prefer composition and small roles + +## Interfaces as Roles, Not Taxonomy + +From `polimorfismo-y-extensibilidad-de-objetos` and `principios-solid`: + +- interfaces let unrelated objects answer the same message without fake inheritance +- a role contract should contain only what clients actually need +- multiple capabilities are better expressed as several small interfaces than as one bloated parent type +- inheritance is for real specialization, not for code reuse or type-hint appeasement + +Practical rule: + +- if inheritance exists mainly to satisfy a type hint or reuse a small fragment of code, extract a role instead + +## Rename to Put Knowledge in Code + +From `rename`: + +- rename is one of the safest everyday refactors +- better names lower cognitive load more than comments often do +- renaming often reveals what a concept actually means or whether an abstraction is wrong + +Practical rule: + +- if understanding depends on external explanation, try rename before adding new structure + +## Naming Discipline + +From `naming-things`: + +- use one word for one concept inside a context +- avoid abbreviations and single-letter names that cannot be searched or remembered easily +- keep paired actions consistent, such as `read/write` or `store/retrieve` +- distinguish repeated concepts semantically, not mechanically, such as `billingAddress` instead of `address2` +- use singular, plural, and collective names intentionally + +Practical rule: + +- if a name still needs a comment to explain what it really is, keep refining it + +## Expressive Object Shape + +From `codigo-expresivo` and `consistencia-de-objetos`: + +- required data belongs in the constructor +- optional data should be introduced explicitly instead of smuggled in as null noise +- things that change together should live together +- value objects should be complete, valid, and ideally immutable +- operations on immutable objects should return new instances instead of mutating hidden state + +Practical rule: + +- let the public API reveal whether an object is immutable, optional, incomplete, or ready to use + +## Visible Dependencies without Blind Injection + +From `dependencias-acoplamiento` and `principios-solid`: + +- hidden behavioral dependencies create opaque coupling +- inject collaborators when substitution or external behavior matters +- not every object needs dependency injection; simple value-like objects can be created directly +- interfaces should be defined by client needs rather than by framework pressure +- depending on abstractions helps behavioral collaborators evolve independently + +Practical rule: + +- inject behavior collaborators, but create fresh value-like objects directly when each instance represents data for this call + +## Too Many Parameters + +From `too-many-parameters`: + +- positional parameters are fragile because swapping two same-typed arguments silently produces wrong results +- named parameters or parameter objects solve the ordering problem by making each argument self-documenting at the call site +- when several parameters always travel together they are a Data Clump — group them into an object +- when parameters are extracted from one object to be passed to a function, pass the whole object instead and let the function query it directly +- a constructor with many parameters often reveals missing intermediate abstractions +- a boolean flag parameter usually means the method has two distinct behaviors that should be two methods or two specializations + +Practical rule: + +- if a function needs three or more positional parameters of the same type, introduce named parameters, a parameter object, or refactor toward whole-object passing + +## Replacing Conditionals with Polymorphism + +From `introducing-polymorphism`: + +- a chain of if/else or switch statements that routes behavior by checking the type or name of an object is a tell-don't-ask violation at the dispatch level +- each branch of such a conditional is a candidate for its own specialization that responds to the same message +- once each type carries its own update logic, the orchestrator simply sends a message and trusts each object to handle it correctly +- introducing a value object for a domain concept (such as quality or quantity) is often the first step before distributing the conditional logic to the right class +- polymorphism eliminates the need for the caller to know about variant types at all + +Practical rule: + +- if a method reads a name or type field and branches on it to decide what to do, the branching logic belongs in the objects being dispatched, not in the caller + +## Separation of Concerns + +From `separation-of-concerns`: + +- programs should not be written as a single unit that solves the whole problem at once; different parts of the problem should be handled by different parts of the program +- mixing input, transformation, domain logic, and output in the same unit makes each concern harder to change independently +- the principle applies at every scale: within a method, within a class, and across layers +- a function that reads input, processes it, and prints the result is three concerns collapsed into one; splitting them makes each piece independently swappable +- SRP is a class-level application of this same idea — one reason to change means one concern per class + +Practical rule: + +- if changing the output format requires touching the same code as changing business logic, the concerns are not separated + +## Large Class and Accumulated Responsibilities + +From `large-class`: + +- a class grows large when it is easier to add a method to an existing class than to introduce a new one; this accumulation is a design debt +- a large class typically serves multiple stakeholders with unrelated needs, so a change for one stakeholder risks breaking another's concern +- the extreme case is a God Object — one class that handles authentication, profile updates, notifications, and admin roles all at once +- splitting a large class means identifying which responsibilities respond to which stakeholder or axis of change, then extracting each into its own focused class +- a useful signal: if the class can be annotated with comment blocks like "authentication", "profile", "notifications", each block is a candidate class + +Practical rule: + +- if a class would need more than one comment block to organize its methods, those blocks are probably separate responsibilities that deserve separate classes + +## DRY Means Knowledge, Not Code + +From `dry-abstraction`: + +- DRY is about knowledge, not about lines of code — "every piece of knowledge should have a single, unambiguous, authoritative representation in a system" +- two methods that look structurally similar but represent different units of knowledge are not DRY violations; merging them produces a false abstraction +- premature abstraction is not an excess of DRY — it is a failure to understand what DRY actually says; eliminating structural duplication without a real shared concept is over-engineering +- the test for a legitimate abstraction: can you name what the two things have in common at the domain level? If you can only name the implementation pattern, the abstraction is premature +- YAGNI complements DRY: do not add knowledge representations for needs you do not yet have + +Practical rule: + +- if merging two similar methods forces you to add a parameter that controls which behavior runs, the similarity is superficial and the abstraction is wrong + +## Cohesion, Coupling, and the Forgotten Principles + +From `beyond-solid`, `beyond-solid-2`, `beyond-solid-3`, and `beyond-solid-4`: + +- SOLID is incomplete without two additional principles: the Law of Demeter and Tell, Don't Ask +- Tell, Don't Ask: do not read an object's state to make a decision that results in changing that object's state — ask the object to perform the behavior itself +- the Law of Demeter (Principle of Least Knowledge): a unit should only talk to its immediate collaborators; chaining calls through intermediate objects spreads knowledge of the internal structure everywhere +- together, Tell, Don't Ask and the Law of Demeter are the most practical tools for moving behavior to the right object and eliminating anemic classes +- GRASP's Information Expert pattern gives the practical answer: put the responsibility in the class that already holds the information needed to carry it out +- GRASP's Creator pattern: the responsibility for creating an object belongs to the class that aggregates it, contains it, or has the data needed to initialize it +- high cohesion means the elements of a module are strongly related and serve a single purpose; low coupling means modules interact through narrow, stable interfaces +- KISS (Keep It Simple, Stupid): most systems work better when kept simple; complexity should only appear when the problem genuinely requires it +- Fail Fast: validate inputs and invariants as early as possible; low-level modules should not accumulate knowledge about how to handle errors they cannot fix + +Practical rule: + +- if a method reads state from one object, computes something, and then sets state on that same object, apply Tell, Don't Ask — move the computation into the object +- if a class's creation logic is scattered across callers, the class that aggregates or contains the new object is the natural Creator + +## Everyday OO Heuristics + +When improving code, try this order: + +1. name the concept precisely +2. wrap raw data when it carries its own rules +3. move knowledge to the owner (Information Expert) +4. make dependencies and roles visible +5. protect information hiding instead of adding convenience getters +6. let constructors establish complete valid state +7. make object shape express mutability and optionality +8. prefer composition over inheritance when specialization axes multiply +9. apply Tell, Don't Ask before adding a getter — ask the object to do the work instead +10. separate concerns at every scale: method, class, and layer +11. reduce positional parameters by grouping data clumps into objects or using named parameters +12. replace type-dispatching conditionals with polymorphism once the variants are stable diff --git a/skills/oop-best-practices/references/go-examples.md b/skills/oop-best-practices/references/go-examples.md new file mode 100644 index 0000000..0a39083 --- /dev/null +++ b/skills/oop-best-practices/references/go-examples.md @@ -0,0 +1,756 @@ +# Go Examples + +Go has no classes or inheritance. OOP concepts are expressed through structs + methods, implicit interfaces, and composition via embedding. The same design pressures apply — the mechanisms differ. + +## Concepts Covered + +- Value Objects and Invariants +- First-Class Collections +- Tell, Don't Ask +- Role-Based Collaboration +- Dependency Injection +- Explicit Interfaces (implicit satisfaction) +- Composition over Inheritance +- Message-Based Design +- Law of Demeter Violation and Fix +- Immutable Objects (value semantics) +- Null Object +- Anemic versus Rich Model +- SOLID — Single Responsibility +- SOLID — Open/Closed +- SOLID — Interface Segregation +- SOLID — Dependency Inversion +- Object Calisthenics — Wrap Primitive +- Object Calisthenics — No Else Rule +- Object Calisthenics — No Getters +- Object Calisthenics — Don't Abbreviate +- Composed Method +- Explaining Message + +--- + +## Value Objects and Invariants + +```go +// Unexported fields — callers cannot bypass invariants +type Money struct { + cents int +} + +func NewMoney(cents int) (Money, error) { + if cents < 0 { + return Money{}, errors.New("money cannot be negative") + } + return Money{cents: cents}, nil +} + +func (m Money) Add(other Money) Money { + return Money{cents: m.cents + other.cents} +} + +func (m Money) MultiplyByPercent(percent int) Money { + return Money{cents: m.cents * percent / 100} +} + +func (m Money) Cents() int { + return m.cents +} +``` + +--- + +## First-Class Collections + +```go +type OrderLine struct { + subtotal Money +} + +func (ol OrderLine) Subtotal() Money { + return ol.subtotal +} + +// The collection owns its rules +type OrderLines struct { + items []OrderLine +} + +func (ol OrderLines) Total() Money { + total := Money{} + for _, item := range ol.items { + total = total.Add(item.Subtotal()) + } + return total +} + +func (ol OrderLines) IsEmpty() bool { + return len(ol.items) == 0 +} +``` + +--- + +## Tell, Don't Ask + +```go +type Address struct { + countryCode string +} + +// Tell the address — don't ask for the country and decide outside +func (a Address) IsDomestic() bool { + return a.countryCode == "ES" +} + +type Shipment struct { + address Address +} + +func (s Shipment) DispatchWindowInDays() int { + if s.address.IsDomestic() { + return 2 + } + return 5 +} +``` + +--- + +## Role-Based Collaboration + +```go +// The role — what behavior the collaborator must provide +type CurrencyFormatter interface { + Format(amount Money) string +} + +type OrderSummary struct { + formatter CurrencyFormatter +} + +func (os OrderSummary) TotalLabel(lines OrderLines) string { + return os.formatter.Format(lines.Total()) +} +``` + +--- + +## Dependency Injection + +```go +type Mailer interface { + Send(to, body string) error +} + +type Invoice struct { + recipient string + body string +} + +func (i Invoice) RecipientEmail() string { return i.recipient } +func (i Invoice) Body() string { return i.body } + +// Collaborator injected — not created internally +type InvoiceSender struct { + mailer Mailer +} + +func NewInvoiceSender(mailer Mailer) InvoiceSender { + return InvoiceSender{mailer: mailer} +} + +func (is InvoiceSender) Send(invoice Invoice) error { + return is.mailer.Send(invoice.RecipientEmail(), invoice.Body()) +} +``` + +--- + +## Explicit Interfaces (Implicit Satisfaction) + +```go +// Interface is defined by the consumer, not the implementor +// Any struct with a Charge method satisfies PaymentGateway automatically + +type PaymentGateway interface { + Charge(customerID string, amount Money) error +} + +type SubscriptionActivator struct { + gateway PaymentGateway +} + +func (sa SubscriptionActivator) Activate(customerID string, fee Money) error { + return sa.gateway.Charge(customerID, fee) +} + +// Stripe, Paypal — both satisfy the interface without importing it +type StripeGateway struct{} + +func (g StripeGateway) Charge(customerID string, amount Money) error { + // call Stripe API + return nil +} +``` + +--- + +## Composition over Inheritance + +Go has no inheritance. Behavior is shared through interfaces and embedding. + +```go +type DiscountPolicy interface { + Apply(total Money) Money +} + +type TaxPolicy interface { + Apply(total Money) Money +} + +// CartPricing is composed from two collaborating policies +type CartPricing struct { + discount DiscountPolicy + tax TaxPolicy +} + +func (cp CartPricing) Total(subtotal Money) Money { + return cp.tax.Apply(cp.discount.Apply(subtotal)) +} + +// Embedding — structural reuse without inheritance +type TimestampedEntity struct { + CreatedAt time.Time + UpdatedAt time.Time +} + +type Order struct { + TimestampedEntity // embedded — Order gains CreatedAt/UpdatedAt fields + ID string + lines OrderLines +} +``` + +--- + +## Message-Based Design + +```go +type SeatInventory interface { + Reserve(seatCount int) error +} + +type PaymentService interface { + Charge(amount Money) error +} + +type Booking struct { + seats int + amount Money + inventory SeatInventory + payments PaymentService +} + +func (b Booking) Confirm() error { + if err := b.inventory.Reserve(b.seats); err != nil { + return err + } + return b.payments.Charge(b.amount) +} +``` + +--- + +## Law of Demeter Violation and Fix + +### Before + +```go +// Caller navigates through internals — coupled to the chain +domestic := order.Customer().ShippingAddress().IsDomestic() +``` + +### After + +```go +type Customer struct { + address Address +} + +// Customer answers questions about itself +func (c Customer) ShipsDomestically() bool { + return c.address.IsDomestic() +} + +type Order struct { + customer Customer +} + +// Order delegates to Customer — no chain traversal +func (o Order) ShipsDomestically() bool { + return o.customer.ShipsDomestically() +} + +domestic := order.ShipsDomestically() +``` + +--- + +## Immutable Objects (Value Semantics) + +```go +// Struct is passed by value — each copy is independent +// Value receiver methods do not mutate the receiver + +type Rooms struct { + items []string +} + +// Returns a new Rooms — original unchanged +func (r Rooms) Add(room string) Rooms { + newItems := make([]string, len(r.items)+1) + copy(newItems, r.items) + newItems[len(r.items)] = room + return Rooms{items: newItems} +} + +func (r Rooms) Count() int { + return len(r.items) +} +``` + +--- + +## Null Object + +```go +// Go has no null objects but interfaces fill the same role — no-op implementation + +type Logger interface { + Info(message string) +} + +// NullLogger satisfies Logger — does nothing +type NullLogger struct{} + +func (NullLogger) Info(_ string) {} + +// RealLogger — used in production +type RealLogger struct{} + +func (RealLogger) Info(message string) { + log.Println(message) +} + +// Service accepts either — callers never check for nil +type OrderProcessor struct { + logger Logger +} +``` + +--- + +## Anemic versus Rich Model + +### Anemic + +```go +type ScoreData struct { + Value int +} + +// Logic lives outside the data — external function mutates +func IncreaseScore(score *ScoreData, points int) { + score.Value += points +} +``` + +### Rich + +```go +type Score struct { + points int +} + +// Behavior lives on the type — returns a new value +func (s Score) Increase(extra int) Score { + return Score{points: s.points + extra} +} + +func (s Score) Value() int { + return s.points +} +``` + +--- + +## SOLID — Single Responsibility + +### Before + +```go +type Report struct { + title string + content string +} + +// Report formats and persists — two unrelated responsibilities +func (r Report) Save(db *sql.DB) error { + _, err := db.Exec("INSERT INTO reports (title, content) VALUES (?, ?)", r.title, r.content) + return err +} +``` + +### After + +```go +type Report struct { + title string + content string +} + +func (r Report) Title() string { return r.title } +func (r Report) Content() string { return r.content } + +type ReportRepository struct { + db *sql.DB +} + +func (rr ReportRepository) Save(r Report) error { + _, err := rr.db.Exec("INSERT INTO reports (title, content) VALUES (?, ?)", r.Title(), r.Content()) + return err +} +``` + +--- + +## SOLID — Open/Closed + +### Before + +```go +func ShippingCost(orderType string) int { + switch orderType { + case "standard": return 5 + case "express": return 15 + case "overnight": return 25 + default: return 0 + } +} +``` + +### After + +```go +type ShippingPolicy interface { + Cost() int +} + +type StandardShipping struct{} +type ExpressShipping struct{} +type OvernightShipping struct{} + +func (StandardShipping) Cost() int { return 5 } +func (ExpressShipping) Cost() int { return 15 } +func (OvernightShipping) Cost() int { return 25 } + +// Adding a new type does not touch ShippingCalculator +func ShippingCost(policy ShippingPolicy) int { + return policy.Cost() +} +``` + +--- + +## SOLID — Interface Segregation + +```go +// Prefer many small interfaces over one large one +// Callers depend only on what they use + +type Worker interface { + Work() +} + +type Eater interface { + Eat() +} + +type Sleeper interface { + Sleep() +} + +type HumanWorker struct{} + +func (HumanWorker) Work() {} +func (HumanWorker) Eat() {} +func (HumanWorker) Sleep() {} + +type Robot struct{} + +// Robot only needs to satisfy Worker — not forced to implement Eat/Sleep +func (Robot) Work() {} +``` + +--- + +## SOLID — Dependency Inversion + +### Before + +```go +type OrderProcessor struct { + db *postgres.DB // depends on concrete infrastructure +} + +func (op *OrderProcessor) Process(order Order) error { + return op.db.Save(order) +} +``` + +### After + +```go +// Interface owned by the domain — not by infrastructure +type OrderStore interface { + Save(order Order) error +} + +type OrderProcessor struct { + store OrderStore // depends on abstraction +} + +func NewOrderProcessor(store OrderStore) OrderProcessor { + return OrderProcessor{store: store} +} + +func (op OrderProcessor) Process(order Order) error { + return op.store.Save(order) +} + +// Infrastructure adapts to the domain interface +type PostgresOrderStore struct{ db *sql.DB } + +func (s PostgresOrderStore) Save(order Order) error { + _, err := s.db.Exec("INSERT INTO orders ...", order.ID) + return err +} +``` + +--- + +## Object Calisthenics — Wrap Primitive + +### Before + +```go +func ApplyDiscount(priceInCents int, discountPercent int) int { + if discountPercent < 0 || discountPercent > 100 { + panic("invalid discount") + } + return priceInCents - (priceInCents * discountPercent / 100) +} +``` + +### After + +```go +type Percentage struct { + value int +} + +func NewPercentage(v int) (Percentage, error) { + if v < 0 || v > 100 { + return Percentage{}, fmt.Errorf("percentage must be between 0 and 100, got %d", v) + } + return Percentage{value: v}, nil +} + +func (p Percentage) Of(amount int) int { + return amount * p.value / 100 +} + +type Price struct { + cents int +} + +func (pr Price) ApplyDiscount(discount Percentage) Price { + return Price{cents: pr.cents - discount.Of(pr.cents)} +} +``` + +--- + +## Object Calisthenics — No Else Rule + +### Before + +```go +func ShippingCost(order Order) int { + if order.IsExpress() { + return 15 + } else { + if order.TotalWeight() > 10 { + return 8 + } else { + return 3 + } + } +} +``` + +### After + +```go +func ShippingCost(order Order) int { + if order.IsExpress() { return 15 } + if order.TotalWeight() > 10 { return 8 } + return 3 +} +``` + +--- + +## Object Calisthenics — No Getters + +### Before + +```go +type Rectangle struct { + Width int + Height int +} + +area := rect.Width * rect.Height +perimeter := 2 * (rect.Width + rect.Height) +``` + +### After + +```go +type Rectangle struct { + width int + height int +} + +func NewRectangle(width, height int) Rectangle { + return Rectangle{width: width, height: height} +} + +func (r Rectangle) Area() int { return r.width * r.height } +func (r Rectangle) Perimeter() int { return 2 * (r.width + r.height) } +func (r Rectangle) IsSquare() bool { return r.width == r.height } +``` + +--- + +## Object Calisthenics — Don't Abbreviate + +### Before + +```go +type OrdMgr struct{} + +func (m OrdMgr) Calc(o Order) int { + s := 0 + for _, i := range o.Itms() { + s += i.Prc() + } + return s +} +``` + +### After + +```go +type OrderManager struct{} + +func (m OrderManager) CalculateTotal(order Order) int { + total := 0 + for _, item := range order.Items() { + total += item.Price() + } + return total +} +``` + +--- + +## Composed Method + +### Before + +```go +func (s RegistrationService) Register(email, password string) error { + if !strings.Contains(email, "@") { return errors.New("invalid email") } + if len(password) < 8 { return errors.New("password too short") } + hashed := hashPassword(password) + if err := s.repo.Save(NewUser(email, hashed)); err != nil { return err } + return s.mailer.Send(email, "Welcome!") +} +``` + +### After + +```go +func (s RegistrationService) Register(email, password string) error { + if err := s.validate(email, password); err != nil { return err } + user, err := s.buildUser(email, password) + if err != nil { return err } + if err := s.persist(user); err != nil { return err } + return s.welcome(user) +} + +func (s RegistrationService) validate(email, password string) error { + if !strings.Contains(email, "@") { return errors.New("invalid email") } + if len(password) < 8 { return errors.New("password too short") } + return nil +} + +func (s RegistrationService) buildUser(email, password string) (User, error) { + return NewUser(email, hashPassword(password)) +} + +func (s RegistrationService) persist(user User) error { + return s.repo.Save(user) +} + +func (s RegistrationService) welcome(user User) error { + return s.mailer.Send(user.Email(), "Welcome!") +} +``` + +--- + +## Explaining Message + +### Before + +```go +func (s Subscription) IsExpired() bool { + return time.Now().After(s.startDate.Add(time.Duration(s.durationDays) * 24 * time.Hour)) +} +``` + +### After + +```go +func (s Subscription) IsExpired() bool { + return time.Now().After(s.expirationDate()) +} + +func (s Subscription) expirationDate() time.Time { + return s.startDate.Add(time.Duration(s.durationDays) * 24 * time.Hour) +} +``` + +--- + +## What to Notice + +- Go has no classes — structs with unexported fields and methods replace them. +- Interfaces are satisfied implicitly: any struct with the right methods qualifies. Callers define the interface; implementors do not import it. +- There is no inheritance. Variation is expressed through interfaces (OCP) and embedding (reuse). Delegation is explicit. +- Value receivers return new values — the natural way to model immutability. +- LSP applies at the interface level: any implementation must honor the contract, not just the method signature. +- The same design pressures (cohesion, coupling, naming, encapsulation) appear in Go exactly as they do in class-based languages. diff --git a/skills/oop-best-practices/references/gradual-abstraction.md b/skills/oop-best-practices/references/gradual-abstraction.md new file mode 100644 index 0000000..7e47825 --- /dev/null +++ b/skills/oop-best-practices/references/gradual-abstraction.md @@ -0,0 +1,106 @@ +# Gradual Abstraction + +Introduce abstraction incrementally — start with the simplest working code and let real change pressure reveal where structure belongs. + +## Shameless Green: Green Is the Goal, Not Cleverness + +The first version of any code should reach green as quickly and directly as possible. Shameless Green prioritizes understandability over changeability. It accumulates concrete examples, duplicates where necessary, and defers structural insight until the code teaches you what it needs. + +Shameless Green is not careless — it is deliberate. It refuses to speculate. The code might be duplicative and far from object-oriented, but if nothing ever changes, it is the most cost-effective solution. Embarrassing duplication is acceptable when the abstractions that would remove it are not yet visible. + +Write Shameless Green because: + +- You do not yet have enough concrete examples to see the right abstraction +- An incorrect abstraction is harder to recover from than temporary duplication +- The simplest code that passes the tests is the safest foundation for future refactoring + +## The Cost of Duplication vs. The Cost of a Wrong Abstraction + +Duplication has a cost. But a wrong abstraction has a higher one. + +When you abstract too early, you lock in a model before you understand the problem. That model then shapes every future addition. Changing a wrong abstraction requires undoing both the abstraction and everything built on top of it. Temporary duplication only requires a refactoring once the right concept becomes clear. + +When weighing duplication against abstraction, ask: + +- Does removing this duplication make the code easier or harder to understand? +- Will a change here cost the same regardless of whether I act now or wait? +- Am I seeing enough concrete examples to be confident about what they have in common? + +If abstracting now would muddy the waters or require naming something you do not yet fully understand, wait. If the future cost of doing nothing is low, do nothing. Time often delivers better information — and sometimes the change never arrives at all. + +Codigo Sostenible frames this as designing for the present: code written to anticipate unknown future scenarios becomes a liability. Writing generic, reusable structures before any real use has happened is the recipe for complexity that is harder to maintain than it would have been to simply rewrite. The bottleneck in large projects is not typing new lines — it is understanding and modifying existing ones. + +## Flocking Rules: The Step-by-Step Process for Finding Abstraction + +When a new requirement arrives and the code begins to feel like it needs structure, apply the Flocking Rules. These rules guide you from concrete duplication toward an abstraction you can name and trust: + +1. Select the things that are most alike. +2. Find the smallest difference between them. +3. Make the simplest change that removes that difference. + +Each change should be small enough that the tests remain green throughout. If they go red, undo and return to green before continuing. + +The Flocking Rules work because they do not ask you to see the abstraction in advance. You discover it incrementally. Each small step makes two things slightly more alike. When two things are identical, you have found the shared concept and can name it. The name is the abstraction. + +Refactoring changes are broken into four sequential steps: + +- Parse the new code +- Parse and execute it +- Parse, execute, and use its result +- Delete unused code + +Working at this level of granularity gives you precise feedback. When something goes wrong, you know exactly which step caused it. As you gain experience, you take larger steps — but only after you have earned the right by doing small ones first. + +When confused, do not try to solve the whole problem at once. The more uncertain you are, the smaller the steps should be. Nibble away. Cutting small things down often reduces the large ones to manageable size. + +## Message-Driven Abstraction: Let What You Say Guide What Should Exist + +A deeply object-oriented signal is when code examines an argument to supply behavior on its behalf. That pattern reveals a missing object. In object-oriented design, behavior belongs to the thing that holds the data, not to the caller that inspects it. + +When you find yourself writing a method that takes an argument, tests it, and then returns different behavior depending on its value — a new object is asking to exist. The argument is not data to be interrogated; it is a stand-in for an object that should be responsible for its own behavior. + +This is message-driven design: the message you want to send reveals the object that should receive it. Let the messages you need to express guide what classes and roles belong in the system. + +The Flocking Rules often expose this pattern. After iteratively reducing duplication, the code converges on a shape where the extracted methods all take an argument they examine with a conditional. At that point, the argument is no longer just a value — it is the responsibility of a new object. + +Do not race to create this object prematurely. Let the Flocking Rules surface the pattern first. Once the shape becomes unmistakable and consistent, the object's identity is clear enough to name and extract safely. + +## Signals That Abstraction Is Ready + +Not every duplication signals a missing abstraction. But some do. The code is ready for abstraction when: + +- The same structure appears in multiple places and each instance responds to the same change in the same way +- A conditional keeps growing with new cases that all follow the same shape +- Extracted methods show a consistent structure — same number of branches, same argument form, same return type — suggesting they all express the same underlying concept +- You can name what the variants have in common using a word from the domain, not a technical word invented for the code +- A change to one instance always requires a matching change to the others + +The concept is not ready when: + +- You can only see one or two concrete examples +- The name you would give it does not belong to the domain +- Making the abstraction now would require naming something at the wrong level — either too specific or too general + +When the right name arrives naturally from the domain, the abstraction is ready. + +## Stable Landing Points and Safe Progress + +The Flocking Rules guide you through intermediate states that are consistent enough to deploy. Each small step leaves the code at a stable landing point — green, coherent, ready to continue or to stop. + +This is the safety mechanism of gradual abstraction. You do not need to know the final design before you begin. Good practices reveal design as you go. Every refactoring that isolates a single responsibility makes the next decision clearer. Small, isolated methods are easy to move later. Large, entangled ones are not. + +POODR's TRUE heuristic — Transparent, Reasonable, Usable, Exemplary — describes the goal of code that can absorb change. These qualities are not designed in one stroke; they accumulate through a discipline of deferring decisions until they are forced, isolating responsibilities as they become apparent, and making each incremental change the simplest one that moves the design forward. + +Postpone design decisions until you are forced to make them. Any decision made before an explicit requirement arrives is a guess. Preserve your ability to decide later by keeping intermediate states clean and coherent. + +## Decision Rule + +When facing a decision about whether to introduce an abstraction now or wait: + +- If you cannot yet name the concept using domain language, wait. +- If you have fewer than two or three concrete examples of the repeated structure, wait. +- If removing the duplication would require a name you are not confident in, wait. +- If a real change request has just arrived and it reveals exactly how the code should move, act — and follow the Flocking Rules one small step at a time. +- If the same shape keeps appearing and the next change always requires touching the same set of places, the abstraction is ready. + +Start with the simplest code that works. Let change pressure accumulate. Apply small, methodical steps when it does. Name concepts only when the examples make the name obvious. The abstraction will emerge — do not force it to arrive before the code is ready to show you what it is. diff --git a/skills/oop-best-practices/references/java-examples.md b/skills/oop-best-practices/references/java-examples.md new file mode 100644 index 0000000..42930f9 --- /dev/null +++ b/skills/oop-best-practices/references/java-examples.md @@ -0,0 +1,993 @@ +# Java Examples + +These examples cover the same core concepts as the other language-specific example files. + +## Concepts Covered + +- Value Objects and Invariants +- First-Class Collections +- Tell, Don't Ask +- Role-Based Collaboration +- Dependency Injection +- Explicit Interfaces +- Duck Typing / Protocol-Style Roles +- Composition over Inheritance +- Message-Based Design +- Law of Demeter Violation and Fix +- Immutable Objects +- Null Object +- Anemic versus Rich Model + +## Value Objects and Invariants + +```java +public final class Money { + private final int cents; + + public Money(int cents) { + if (cents < 0) { + throw new IllegalArgumentException("Money cannot be negative"); + } + this.cents = cents; + } + + public Money add(Money other) { + return new Money(this.cents + other.cents); + } + + public Money multiplyBy(int percent) { + return new Money(Math.round(this.cents * percent / 100.0f)); + } + + public int value() { + return cents; + } +} +``` + +## First-Class Collections + +```java +import java.util.List; + +public final class OrderLine { + private final Money subtotalAmount; + + public OrderLine(Money subtotalAmount) { + this.subtotalAmount = subtotalAmount; + } + + public Money subtotal() { + return subtotalAmount; + } +} + +public final class OrderLines { + private final List items; + + public OrderLines(List items) { + this.items = List.copyOf(items); + } + + public Money total() { + Money total = new Money(0); + for (OrderLine item : items) { + total = total.add(item.subtotal()); + } + return total; + } + + public boolean isEmpty() { + return items.isEmpty(); + } +} +``` + +## Tell, Don't Ask + +```java +public final class Address { + private final String countryCode; + + public Address(String countryCode) { + this.countryCode = countryCode; + } + + public boolean isDomestic() { + return "ES".equals(countryCode); + } +} + +public final class Shipment { + private final Address address; + + public Shipment(Address address) { + this.address = address; + } + + public int dispatchWindowInDays() { + return address.isDomestic() ? 2 : 5; + } +} +``` + +## Role-Based Collaboration + +```java +public interface CurrencyFormatter { + String format(Money amount); +} + +public final class OrderSummary { + private final CurrencyFormatter formatter; + + public OrderSummary(CurrencyFormatter formatter) { + this.formatter = formatter; + } + + public String totalLabel(OrderLines lines) { + return formatter.format(lines.total()); + } +} +``` + +## Dependency Injection + +```java +public interface Mailer { + void send(String to, String body); +} + +public final class Invoice { + private final String recipient; + private final String bodyText; + + public Invoice(String recipient, String bodyText) { + this.recipient = recipient; + this.bodyText = bodyText; + } + + public String recipientEmail() { + return recipient; + } + + public String body() { + return bodyText; + } +} + +public final class InvoiceSender { + private final Mailer mailer; + + public InvoiceSender(Mailer mailer) { + this.mailer = mailer; + } + + public void send(Invoice invoice) { + mailer.send(invoice.recipientEmail(), invoice.body()); + } +} +``` + +## Explicit Interfaces + +```java +public interface PaymentGateway { + void charge(String customerId, Money amount); +} + +public final class SubscriptionActivator { + private final PaymentGateway paymentGateway; + + public SubscriptionActivator(PaymentGateway paymentGateway) { + this.paymentGateway = paymentGateway; + } + + public void activate(String customerId, Money fee) { + paymentGateway.charge(customerId, fee); + } +} +``` + +## Duck Typing / Protocol-Style Roles + +```java +public interface StockSource { + int availableUnits(); +} + +public final class InventoryReport { + private final StockSource source; + + public InventoryReport(StockSource source) { + this.source = source; + } + + public boolean isAvailable() { + return source.availableUnits() > 0; + } +} + +public final class WarehouseBin implements StockSource { + private final int units; + + public WarehouseBin(int units) { + this.units = units; + } + + public int availableUnits() { + return units; + } +} +``` + +## Composition over Inheritance + +```java +public interface DiscountPolicy { + Money apply(Money total); +} + +public interface TaxPolicy { + Money apply(Money total); +} + +public final class CartPricing { + private final DiscountPolicy discountPolicy; + private final TaxPolicy taxPolicy; + + public CartPricing(DiscountPolicy discountPolicy, TaxPolicy taxPolicy) { + this.discountPolicy = discountPolicy; + this.taxPolicy = taxPolicy; + } + + public Money total(Money subtotal) { + return taxPolicy.apply(discountPolicy.apply(subtotal)); + } +} +``` + +## Message-Based Design + +```java +public interface SeatInventory { + void reserve(int seatCount); +} + +public interface PaymentService { + void charge(Money amount); +} + +public final class Booking { + private final int seats; + private final Money amount; + private final SeatInventory inventory; + private final PaymentService payments; + + public Booking(int seats, Money amount, SeatInventory inventory, PaymentService payments) { + this.seats = seats; + this.amount = amount; + this.inventory = inventory; + this.payments = payments; + } + + public void confirm() { + inventory.reserve(seats); + payments.charge(amount); + } +} +``` + +## Law of Demeter Violation and Fix + +### Before + +```java +public final class CustomerRecord { + private final Address address; + + public CustomerRecord(Address address) { + this.address = address; + } + + public Address shippingAddress() { + return address; + } +} + +public final class Order { + private final CustomerRecord customer; + + public Order(CustomerRecord customer) { + this.customer = customer; + } + + public CustomerRecord customerRecord() { + return customer; + } +} + +boolean domestic = order.customerRecord().shippingAddress().isDomestic(); +``` + +### After + +```java +public final class Customer { + private final Address address; + + public Customer(Address address) { + this.address = address; + } + + public boolean shipsDomestically() { + return address.isDomestic(); + } +} + +public final class PurchaseOrder { + private final Customer customer; + + public PurchaseOrder(Customer customer) { + this.customer = customer; + } + + public boolean shipsDomestically() { + return customer.shipsDomestically(); + } +} + +boolean domestic = order.shipsDomestically(); +``` + +## Immutable Objects + +```java +import java.util.ArrayList; +import java.util.List; + +public final class Rooms { + private final List items; + + public Rooms(List items) { + this.items = List.copyOf(items); + } + + public Rooms add(String room) { + List copy = new ArrayList<>(items); + copy.add(room); + return new Rooms(copy); + } + + public int count() { + return items.size(); + } +} +``` + +## Null Object + +```java +public interface Logger { + void info(String message); +} + +public final class NullLogger implements Logger { + public void info(String message) { + } +} +``` + +## Anemic versus Rich Model + +### Anemic + +```java +public final class ScoreData { + public int value; + + public ScoreData(int value) { + this.value = value; + } +} + +public final class ScoreService { + public void increase(ScoreData score, int points) { + score.value = score.value + points; + } +} +``` + +### Rich + +```java +public final class Score { + private final int value; + + public Score(int value) { + this.value = value; + } + + public Score increase(int points) { + return new Score(value + points); + } + + public int value() { + return value; + } +} +``` + +## SOLID — Single Responsibility Violation and Fix + +### Before + +```java +public final class Report { + private final String title; + private final String content; + + public Report(String title, String content) { + this.title = title; + this.content = content; + } + + public String title() { + return title; + } + + public void save() { + // writing to database — second unrelated responsibility + database.insert("reports", title, content); + } +} +``` + +### After + +```java +public final class Report { + private final String title; + private final String content; + + public Report(String title, String content) { + this.title = title; + this.content = content; + } + + public String title() { + return title; + } + + public String body() { + return content; + } +} + +public final class ReportRepository { + public void save(Report report) { + database.insert("reports", report.title(), report.body()); + } +} +``` + +## Object Calisthenics — Wrap Primitive + +### Before + +```java +public int applyDiscount(int priceInCents, int discountPercent) { + if (discountPercent < 0 || discountPercent > 100) { + throw new IllegalArgumentException("Invalid discount"); + } + return Math.round(priceInCents * (1 - discountPercent / 100.0f)); +} +``` + +### After + +```java +public final class Percentage { + private final int value; + + public Percentage(int value) { + if (value < 0 || value > 100) { + throw new IllegalArgumentException("Percentage must be between 0 and 100"); + } + this.value = value; + } + + public int of(int amount) { + return Math.round(amount * (value / 100.0f)); + } +} + +public final class Price { + private final int cents; + + public Price(int cents) { + this.cents = cents; + } + + public Price applyDiscount(Percentage discount) { + return new Price(cents - discount.of(cents)); + } + + public int value() { + return cents; + } +} +``` + +## Object Calisthenics — No Else Rule + +### Before + +```java +public int shippingCost(Order order) { + if (order.isExpress()) { + return 15; + } else { + if (order.totalWeight() > 10) { + return 8; + } else { + return 3; + } + } +} +``` + +### After + +```java +public int shippingCost(Order order) { + if (order.isExpress()) return 15; + if (order.totalWeight() > 10) return 8; + return 3; +} +``` + +## Dependency Direction + +### Before + +```java +public final class InvoiceExporter { + public void export(Invoice invoice) { + FileSystem fs = new FileSystem(); + fs.write("invoices/" + invoice.id() + ".txt", invoice.body()); + } +} +``` + +### After + +```java +public interface DocumentStorage { + void write(String path, String content); +} + +public final class InvoiceExporter { + private final DocumentStorage storage; + + public InvoiceExporter(DocumentStorage storage) { + this.storage = storage; + } + + public void export(Invoice invoice) { + storage.write("invoices/" + invoice.id() + ".txt", invoice.body()); + } +} +``` + +## Composed Method + +### Before + +```java +public final class RegistrationService { + public void register(String email, String password) { + if (!email.contains("@")) throw new IllegalArgumentException("Invalid email"); + if (password.length() < 8) throw new IllegalArgumentException("Password too short"); + String hashed = hashPassword(password); + userRepository.save(new User(email, hashed)); + mailer.send(email, "Welcome!"); + } +} +``` + +### After + +```java +public final class RegistrationService { + public void register(String email, String password) { + validate(email, password); + User user = buildUser(email, password); + persist(user); + welcome(user); + } + + private void validate(String email, String password) { + if (!email.contains("@")) throw new IllegalArgumentException("Invalid email"); + if (password.length() < 8) throw new IllegalArgumentException("Password too short"); + } + + private User buildUser(String email, String password) { + return new User(email, hashPassword(password)); + } + + private void persist(User user) { + userRepository.save(user); + } + + private void welcome(User user) { + mailer.send(user.email(), "Welcome!"); + } +} +``` + +## SOLID — Open/Closed Principle + +### Before + +```java +public final class ShippingCalculator { + public int calculate(Order order) { + if (order.type().equals("standard")) { + return 5; + } else if (order.type().equals("express")) { + return 15; + } else if (order.type().equals("overnight")) { + return 25; + } + throw new IllegalArgumentException("Unknown order type"); + } +} +``` + +### After + +```java +public interface ShippingPolicy { + int shippingCost(Order order); +} + +public final class StandardShipping implements ShippingPolicy { + public int shippingCost(Order order) { return 5; } +} + +public final class ExpressShipping implements ShippingPolicy { + public int shippingCost(Order order) { return 15; } +} + +public final class OvernightShipping implements ShippingPolicy { + public int shippingCost(Order order) { return 25; } +} + +public final class ShippingCalculator { + private final ShippingPolicy policy; + + public ShippingCalculator(ShippingPolicy policy) { + this.policy = policy; + } + + public int calculate(Order order) { + return policy.shippingCost(order); + } +} +// New shipping types are added by implementing ShippingPolicy — the calculator is never modified. +``` + +## SOLID — Liskov Substitution Principle + +### Before + +```java +// LSP violation: subtype throws where the parent promises it won't. +public class ReadOnlyCollection extends java.util.ArrayList { + @Override + public boolean add(String element) { + throw new UnsupportedOperationException("Collection is read-only"); + } +} +``` + +### After + +```java +// Independent classes — no inheritance contract is broken. +public final class MutableCollection { + private final java.util.List items = new java.util.ArrayList<>(); + + public void add(String item) { + items.add(item); + } + + public java.util.List all() { + return java.util.Collections.unmodifiableList(items); + } +} + +public final class ReadOnlyCollection { + private final java.util.List items; + + public ReadOnlyCollection(java.util.List items) { + this.items = java.util.List.copyOf(items); + } + + public java.util.List all() { + return items; + } +} +``` + +## SOLID — Interface Segregation Principle + +### Before + +```java +// Fat interface forces RobotWorker to throw on methods it cannot support. +public interface Worker { + void work(); + void eat(); + void sleep(); +} + +public final class RobotWorker implements Worker { + public void work() { /* performs task */ } + public void eat() { throw new UnsupportedOperationException("Robots don't eat"); } + public void sleep() { throw new UnsupportedOperationException("Robots don't sleep"); } +} +``` + +### After + +```java +public interface Workable { + void work(); +} + +public interface Eatable { + void eat(); +} + +public interface Sleepable { + void sleep(); +} + +public final class HumanWorker implements Workable, Eatable, Sleepable { + public void work() { /* performs task */ } + public void eat() { /* has lunch */ } + public void sleep() { /* rests */ } +} + +public final class RobotWorker implements Workable { + public void work() { /* performs task */ } +} +``` + +## SOLID — Dependency Inversion Principle + +### Before + +```java +public final class OrderProcessor { + private final PostgresDatabase database = new PostgresDatabase(); // hardcoded low-level detail + + public void process(Order order) { + database.insert("orders", order); + } +} +``` + +### After + +```java +// Interface owned by the high-level module, not the low-level one. +public interface OrderStore { + void save(Order order); +} + +public final class PostgresOrderStore implements OrderStore { + public void save(Order order) { + // writes to Postgres + } +} + +public final class OrderProcessor { + private final OrderStore store; + + public OrderProcessor(OrderStore store) { + this.store = store; + } + + public void process(Order order) { + store.save(order); + } +} +``` + +## Object Calisthenics — One Level of Indentation + +### Before + +```java +public String generateReport(java.util.List orders) { + StringBuilder report = new StringBuilder(); + for (Order order : orders) { + if (order.isComplete()) { + for (OrderLine line : order.lines()) { + if (line.subtotal().value() > 10000) { + report.append(line.name()).append(": ").append(line.subtotal().value()).append("\n"); + } + } + } + } + return report.toString(); +} +``` + +### After + +```java +public String generateReport(java.util.List orders) { + return orders.stream() + .filter(this::isComplete) + .flatMap(order -> expensiveItems(order).stream()) + .map(this::formatItem) + .collect(java.util.stream.Collectors.joining("\n")); +} + +private boolean isComplete(Order order) { + return order.isComplete(); +} + +private java.util.List expensiveItems(Order order) { + return order.lines().stream() + .filter(line -> line.subtotal().value() > 10000) + .collect(java.util.stream.Collectors.toList()); +} + +private String formatItem(OrderLine line) { + return line.name() + ": " + line.subtotal().value(); +} +``` + +## Object Calisthenics — No Getters/Setters + +### Before + +```java +public final class Rectangle { + private final int width; + private final int height; + + public Rectangle(int width, int height) { + this.width = width; + this.height = height; + } + + public int getWidth() { return width; } + public int getHeight() { return height; } +} + +// Callers compute behaviour externally — knowledge leaks out of the object. +int area = rect.getWidth() * rect.getHeight(); +int perimeter = 2 * (rect.getWidth() + rect.getHeight()); +boolean square = rect.getWidth() == rect.getHeight(); +``` + +### After + +```java +public final class Rectangle { + private final int width; + private final int height; + + public Rectangle(int width, int height) { + this.width = width; + this.height = height; + } + + public int area() { + return width * height; + } + + public int perimeter() { + return 2 * (width + height); + } + + public boolean isSquare() { + return width == height; + } +} +``` + +## Object Calisthenics — Don't Abbreviate + +### Before + +```java +public final class OrdMgr { + public int calc(Order o) { + int t = 0; + for (OrderLine l : o.lines()) { + t += l.subtotal().value(); + } + return t; + } + + public void proc(Order o) { + int t = calc(o); + // process with total t + } +} +``` + +### After + +```java +public final class OrderManager { + public int calculateTotal(Order order) { + return order.lines().stream() + .mapToInt(line -> line.subtotal().value()) + .sum(); + } + + public void processOrder(Order order) { + int total = calculateTotal(order); + // process with total + } +} +``` + +## Explaining Message + +### Before + +```java +public final class Subscription { + private final long startedAtMillis; + private final int durationDays; + + public Subscription(long startedAtMillis, int durationDays) { + this.startedAtMillis = startedAtMillis; + this.durationDays = durationDays; + } + + public boolean isExpired() { + return System.currentTimeMillis() > startedAtMillis + (long) durationDays * 24 * 60 * 60 * 1000; + } +} +``` + +### After + +```java +public final class Subscription { + private final long startedAtMillis; + private final int durationDays; + + public Subscription(long startedAtMillis, int durationDays) { + this.startedAtMillis = startedAtMillis; + this.durationDays = durationDays; + } + + public boolean isExpired() { + return System.currentTimeMillis() > expirationDate(); + } + + private long expirationDate() { + return startedAtMillis + (long) durationDays * 24 * 60 * 60 * 1000; + } +} +``` + +## What to Notice + +- Rich models and clear object responsibilities help keep knowledge close to the concept. +- Java uses small interfaces to model both explicit contracts and protocol-style roles. +- Value objects, collection objects, and narrow collaborators keep responsibilities focused. +- Composition and message passing reduce the need for brittle inheritance trees. +- Wrapping primitives and splitting responsibilities keep each class focused on one reason to change. +- SOLID principles, Object Calisthenics rules, and extracted explaining messages each reduce a different kind of coupling or noise. diff --git a/skills/oop-best-practices/references/language-examples.md b/skills/oop-best-practices/references/language-examples.md new file mode 100644 index 0000000..ba31634 --- /dev/null +++ b/skills/oop-best-practices/references/language-examples.md @@ -0,0 +1,60 @@ +# Language Examples + +Use this file as an index to the language-specific example references. + +## Covered Languages + +- `references/typescript-examples.md` +- `references/java-examples.md` +- `references/python-examples.md` +- `references/csharp-examples.md` +- `references/ruby-examples.md` +- `references/php-examples.md` +- `references/go-examples.md` +- `references/rust-examples.md` + +## Shared Concept Set + +Each language file contains examples for the same concepts: + +- Value Objects and Invariants +- First-Class Collections +- Tell, Don't Ask +- Role-Based Collaboration +- Dependency Injection +- Explicit Interfaces +- Duck Typing / Protocol-Style Roles +- Composition over Inheritance +- Message-Based Design +- Law of Demeter Violation and Fix +- Immutable Objects +- Null Object +- Anemic versus Rich Model +- SOLID — Single Responsibility Violation and Fix +- Object Calisthenics — Wrap Primitive +- Object Calisthenics — No Else Rule +- Dependency Direction +- Composed Method +- SOLID — Open/Closed Principle +- SOLID — Liskov Substitution Principle +- SOLID — Interface Segregation Principle +- SOLID — Dependency Inversion Principle +- Object Calisthenics — One Level of Indentation +- Object Calisthenics — No Getters/Setters +- Object Calisthenics — Don't Abbreviate +- Explaining Message + +## How to Use This Reference + +- Read the language file that matches the user's codebase first. +- If the user works across multiple languages, compare the same concept across files. +- Prefer concept-level consistency over syntax-level imitation. +- When adding a new concept, add it to every language file so the set stays aligned. +- Use `message-based-design.md`, `naming-and-abstractions.md`, `advanced-modeling-concepts.md`, or `fran-iglesias-practical-guidance.md` when an example raises a deeper OO design question. + +## Suggested Reading Order + +1. Start with the language the user is actively using. +2. Review value objects, rich versus anemic models, message-based design, and composition over inheritance first. +3. Compare explicit interfaces and protocol-style roles across one static language and one dynamic language. +4. Return to `core-principles.md`, `message-based-design.md`, `advanced-modeling-concepts.md`, or `fran-iglesias-practical-guidance.md` when the example raises a design question. diff --git a/skills/oop-best-practices/references/message-based-design.md b/skills/oop-best-practices/references/message-based-design.md new file mode 100644 index 0000000..9300a40 --- /dev/null +++ b/skills/oop-best-practices/references/message-based-design.md @@ -0,0 +1,85 @@ +# Message-Based Design + +Use this reference when object interactions are more important than class hierarchies. + +## Think in Messages First + +Ask: + +- What message does this object need to send? +- What result or behavior does it expect back? +- Which collaborator should answer that message? + +This perspective often reveals better boundaries than starting from class trees. + +## Depend on Behavior, Not Data + +Prefer this: + +- asking an object to do something meaningful + +Over this: + +- fetching raw data and deciding elsewhere + +Data-focused collaboration usually increases coupling because callers must know internal structure. + +## Trust Collaborators Through Roles + +Objects should collaborate through roles with clear expectations. + +A role is useful when: + +- several concrete objects can answer the same message +- the caller does not care about the exact class +- the variation is about behavior, not representation + +This is the core design benefit behind duck typing and interface-based collaboration. + +## Create Explicit Interfaces + +A good public interface: + +- is small +- expresses intent +- hides internal structure +- is stable enough for clients to depend on + +A weak public interface: + +- leaks implementation details +- forces callers to know too much context +- changes often because responsibilities are unclear + +## Minimize Context + +Objects become easier to reuse when they depend on less surrounding knowledge. + +Reduce context by: + +- injecting collaborators +- isolating volatile dependencies +- avoiding long navigation chains +- passing cohesive concepts instead of raw pieces + +## Listen to the Law of Demeter + +When you see navigation like `a.b().c().d()`, ask whether: + +- the caller knows too much +- a responsibility is misplaced +- a message should be introduced instead + +The goal is not blind rule-following, but lower coupling and better object conversations. + +## Duck Typing as Role Discovery + +Duck typing is useful because it highlights that the caller cares about a capability, not a class. + +Use this idea when: + +- several collaborators play the same role +- explicit inheritance would be too rigid +- the abstraction is behavioral rather than taxonomic + +In statically typed languages, the same design often appears as a small interface or protocol. diff --git a/skills/oop-best-practices/references/method-design.md b/skills/oop-best-practices/references/method-design.md new file mode 100644 index 0000000..cd03970 --- /dev/null +++ b/skills/oop-best-practices/references/method-design.md @@ -0,0 +1,71 @@ +# Method Design + +Use this reference when writing or reviewing methods to ensure they are intention-revealing, single-level, well-composed, and easy to understand without reading their internals. + +## Composed Method + +- Divide every non-trivial method into a sequence of named steps, each at one level of abstraction. +- If a method mixes high-level orchestration with low-level implementation details, it is doing more than one thing. +- Each step in a composed method should read like a sentence describing what happens, not how. +- If you cannot name a step clearly, that step is either at the wrong level or doing too much. +- The body of the top-level method becomes a readable summary; readers should be able to understand the intent without reading the helpers. +- Extract helpers even when there is no reuse — the goal is clarity and isolation, not deduplication. +- If you feel the need to add blank lines between blocks of code inside a method to visually separate them, each block is a candidate for extraction into a named method. + +## Intention-Revealing Names + +- Name a method after what it accomplishes, not after the mechanism it uses to accomplish it. +- If you name a method after its current implementation, you tie the name to the implementation and cannot change one without invalidating the other. +- A name that reflects intent survives internal changes; a name that reflects implementation becomes a lie after every refactor. +- Name a method one level of abstraction higher than the thing it returns or does — this isolates callers from implementation decisions. +- If you cannot name a method without describing its implementation, that method likely does not yet represent a stable concept. +- Prefer full descriptive names over abbreviated ones; the cost of a long name is paid once, the cost of an unclear name is paid every time someone reads it. +- When two methods differ only in mechanism but not in purpose, they should share a name — varying implementation through polymorphism rather than naming. + +## Single Level of Abstraction + +- A method should operate at one level of abstraction throughout its body. +- If a method mixes orchestration calls (calling other methods by name) with inline computations or raw data manipulation, it has mixed levels. +- High-level steps belong in the top-level method; low-level details belong in helpers named at the appropriate level. +- If reading a method requires you to context-switch between understanding the overall intent and tracking local variable manipulation, the abstraction levels are mixed. +- Introduce an intermediate method to bridge levels rather than collapsing different levels into one body. +- Apply this rule recursively: each helper should itself be at one level of abstraction. + +## Guard Clauses + +- Use an early return to handle edge cases, precondition violations, and absent values at the top of a method. +- Guard clauses separate exceptional and degenerate cases from the main path, making the main path easier to read. +- If a method's main logic is nested inside one or more conditional checks, invert those conditions into early returns. +- Place all guard clauses at the top of the method body, before any main-flow logic begins. +- If a guard clause grows complex, extract it into a named predicate method whose name explains what condition is being guarded against. +- Prefer a single early return per guard over a large else block — readers should not need to track an open branch while reading the main flow. +- Guard clauses communicate: "these are the abnormal situations; everything below this point is the normal case." + +## Explaining Messages + +- When a method contains an inline expression whose purpose is not obvious from its syntax, extract it into a separate method and send that method as a message. +- The extracted method exists to explain purpose, not to avoid repetition — it may only have one caller. +- Naming the extracted method after what the expression means transforms opaque logic into self-documenting code. +- If you feel the urge to write an inline comment explaining what an expression does, that is a signal to extract it as a named method instead. +- The pattern applies to boolean conditions, arithmetic expressions, and string manipulations equally — anything that requires mental effort to decode belongs in a named method. +- An explaining method is not a helper in the reuse sense; it is a vocabulary choice that makes the calling method readable at a glance. + +## Method Length + +- There is no universally correct line count, but a method that cannot fit on one screen is a strong signal to examine it for extraction opportunities. +- Cyclomatic complexity is a more reliable guide than raw line count: each conditional branch adds mental load that can be reduced by extraction. +- If understanding a method requires holding more than a few things in working memory simultaneously, it is too long. +- A method with many lines that each do one simple named thing may be acceptable; a method with a few dense lines that each require analysis is not. +- A method that has grown to handle more than one conceptual case probably needs to be split rather than refactored inline. +- The goal is not minimizing lines of code; it is maximizing the ratio of meaning to effort for the reader. +- If you can read a method from top to bottom and understand what it does without pausing, it is the right size. + +## Visibility and Cohesion + +- Public methods form a contract with callers; keep this surface small and stable. +- Every method added to the public interface increases the coupling between a class and its callers and reduces the freedom to change internals. +- Methods that exist only to support other methods in the same class should be private; they are implementation details, not contracts. +- Protected methods signal to subclasses: "this is intentionally open for override, but not for external use." Use protected deliberately, not as a default. +- If a private method grows complex enough to feel like it deserves its own tests, it is a candidate for extraction into a collaborating class with a well-defined public interface. +- The cohesion of a method set is visible in its public surface: if public methods change for different reasons, the class has more than one responsibility. +- Hiding helper methods behind private visibility also protects callers from implementation changes — if a helper is only private, refactoring it never breaks external code. diff --git a/skills/oop-best-practices/references/naming-and-abstractions.md b/skills/oop-best-practices/references/naming-and-abstractions.md new file mode 100644 index 0000000..203303d --- /dev/null +++ b/skills/oop-best-practices/references/naming-and-abstractions.md @@ -0,0 +1,161 @@ +# Naming and Abstractions + +Use this reference when the main design problem is unclear naming, over-generalization, or abstractions that do not carry their weight. + +## Names Are Part of the Design + +A name is not just a label. It defines the abstraction the reader will imagine. + +Good names: + +- are easy to pronounce +- are easy to search +- use real problem-space concepts +- distinguish similar concepts clearly +- age well as the codebase grows + +Weak names: + +- hide intent behind abbreviations +- depend on temporary context +- use technical noise instead of domain meaning +- create aliases for the same concept across the codebase + +## If a Name Is Hard to Find, Recheck the Abstraction + +When a new method, class, or variable has no convincing name, ask whether: + +- it actually mixes several ideas +- it was extracted too early +- it belongs inside another object +- the domain concept is still poorly understood + +Sometimes the best fix is not a better name, but a better boundary. + +## Avoid Premature Abstractions + +Premature abstractions create accidental complexity when: + +- the abstraction is broader than current needs +- the domain language does not support it +- several future scenarios are being guessed at once +- the abstraction hides the important concept instead of clarifying it + +Prefer to wait until the code reveals stable structure under real use. + +## DRY Means Shared Knowledge, Not Identical Syntax + +Remove duplication when the same rule or concept is implemented in multiple places. +Do not unify code that only looks similar if it represents different business reasons. + +A useful test: + +- If one copy changes, should the others always change too? + +If not, they may not be the same knowledge. + +## Use Domain Language Carefully + +Prefer names from the domain when they clarify meaning. +Avoid made-up universal metaphors that disconnect the code from the problem space. + +Code becomes harder to understand when: + +- the metaphor in code differs from the business conversation +- one concept has many aliases +- one word means different things but context is not explicit + +## Practical Checklist + +Before keeping a new abstraction, ask: + +- Can I name it precisely? +- Does the domain support this concept? +- Does it remove duplicated knowledge? +- Does it make the code easier to change? +- Would the next reader understand it without extra explanation? + +## Name After Meaning, Not Current Implementation + +Naming a method after what it does right now ties the name to the implementation (from Refactor Cotidiano — Fran Iglesias, drawing on 99 Bottles of OOP): + +- a method named `beer` that returns `"beer"` cannot change its internal behavior without breaking the name +- a method named `beverage` describes what the concept represents, not the specific string it returns today +- names that are one level of abstraction above the current value survive requirement changes + +Rule: name after what the concept means to the domain, not after the value or mechanism currently behind it. + +## Technical Names Are a Design Smell + +Embedding type information or pattern names in identifiers obscures the domain (from Codigo Sostenible — Carlos Blé): + +Patterns that reduce abstraction quality: + +- prefixes encoding type: `sSurname`, `IShoppingCart`, `bIsValid` +- suffixes encoding role: `AbstractShoppingCart`, `ShoppingCartImpl`, `UserFinderSingleton`, `OrderFacade` +- framework conventions imported into application code: `.NET`-style `I`-prefix applied throughout a domain model + +Why these hurt: + +- they offer information the IDE already provides (syntax coloring, type hints, hover metadata) +- they consume the naming budget for the important part: the domain concept +- they make it harder to find a name with genuine business meaning, which in turn hides poor design + +When you cannot name an interface and its single implementation without resorting to a suffix, ask whether the interface is earning its place at all. + +## Magic Values Are Unnamed Concepts + +A literal value embedded directly in code is a concept that has not been given a name (from Refactor Cotidiano — Fran Iglesias): + +- `.21` in `$amount * .21` is a business rule (VAT rate) hidden as a number +- giving it a name (`VAT_RATE`) makes the business rule explicit, prevents duplication, and survives a rate change + +The same applies to: + +- raw string patterns (regular expressions inlined instead of named constants) +- format strings repeated across the codebase +- numeric thresholds and limits with no label + +Each unnamed value is an opportunity to add a name that a domain expert would recognize. + +## A Name That Is Hard to Find Diagnoses the Abstraction + +The difficulty of naming is data, not just friction (from Codigo Sostenible — Carlos Blé): + +When you cannot find two different names for an interface and its implementation, and you fall back to `IFoo` / `FooImpl`, the naming difficulty is a signal that the separation may not be worth having. + +When you cannot name a new method or class without resorting to generic words (`Manager`, `Handler`, `Helper`, `Utils`), the boundary is probably wrong. + +A name that resists being chosen is telling you: + +- the thing mixes several ideas (split it) +- it was extracted too early (inline it and wait) +- the domain concept is still poorly understood (talk to a domain expert) + +## Alien Metaphors and Mutant Names + +Two patterns that make a codebase progressively harder to read (from Codigo Sostenible — Carlos Blé): + +**Alien metaphors**: technical or architectural abstractions that do not match the vocabulary of the business. A "rules engine" that validates loan applications is understandable to engineers; the business speaks of "credit assessment." Code that uses only the technical term creates a translation gap every time a domain expert and a developer communicate. + +**Mutant names**: one domain concept that has accumulated different names across different files. The concept was named once, then renamed locally here and there without a global rename. Callers now refer to the same thing as `client`, `customer`, `user`, and `account` depending on where the code was written. + +Both patterns compound: alien metaphors get aliased under yet another name when the original metaphor no longer fits. The result is code where understanding any one part requires knowing all the other names for the same thing. + +Prevention: when renaming a concept, rename it everywhere; when introducing a technical abstraction, keep a name that the domain recognizes. + +## Evolving Names as Understanding Grows + +Names are not permanent. They crystallize the current understanding of the domain (from Refactor Cotidiano — Fran Iglesias): + +- code always lags behind domain knowledge because the domain changes +- a name that was correct when written may be misleading after the domain evolves +- the right moment to rename is whenever you understand the domain better than the current name reflects + +A practical workflow: + +- when reading code to fix a bug or add a feature, look for names that no longer match what you now know +- make the rename as a safe refactor before or after the functional change, not mixed with it +- accumulate small renames continuously; do not wait for a dedicated "naming sprint" + +The test for whether a rename is ready: if a new team member reads the name in context and correctly predicts what the concept does without extra explanation, the name is good enough. diff --git a/skills/oop-best-practices/references/object-calisthenics.md b/skills/oop-best-practices/references/object-calisthenics.md new file mode 100644 index 0000000..d3a0706 --- /dev/null +++ b/skills/oop-best-practices/references/object-calisthenics.md @@ -0,0 +1,66 @@ +# Object Calisthenics + +Use this reference when reviewing or writing object-oriented code and you want to apply Jeff Bay's nine structural rules as temporary exercises for exposing design pressure. They are not production acceptance criteria: evaluate the resulting cohesion, invariant protection, coupling, readability, and change cost rather than numeric compliance. + +## Rule 1: One Level of Indentation Per Method + +- **Intent**: Deep nesting is a signal that a method is doing more than one thing. Each additional level forces the reader to hold more mental context simultaneously, increasing the chance of misreading the logic. Keeping one level per method forces each method to have a single, clear entry point and a single job. +- **Application**: When a method body requires a nested block — a loop inside a condition, a condition inside a loop — extract the inner block into a named private method. The name of the extracted method becomes documentation. Codigo Sostenible (Ble) frames this as maintaining a uniform level of abstraction within each block: all lines inside a method should live at the same conceptual altitude. +- **When to relax**: A single well-named guard clause that wraps the whole body is acceptable. Very short utility methods (two to three lines) with a trivially nested ternary do not need extraction if the nesting communicates the intent better than a helper name would. +- **Practical heuristic**: If you can see two levels of indentation inside a single method body, extract the inner block into a named method before doing anything else. + +## Rule 2: Do Not Use the Else Keyword + +- **Intent**: The `else` branch is often the sign that a method handles two distinct cases at the same conceptual level rather than resolving one and moving on. Removing `else` pushes you toward guard clauses for edge cases and polymorphism for true variation, both of which reduce coupling. +- **Application**: Replace early-exit conditions with guard clauses that return or throw immediately. Codigo Sostenible (Ble) distinguishes guard clauses — which handle terminal edge cases — from symmetric `if/else` branches that express mutually exclusive business paths. Not every `else` is wrong; when two paths are genuinely equal partners in the domain, a symmetric `if/else` or ternary is more honest than a forced early return. The goal is to eliminate `else` when it exists only to avoid returning early, not to remove it dogmatically when the symmetry of two branches is the actual intent. +- **When to relax**: When two branches represent co-equal alternatives that together define a concept, a symmetric structure with `else` can be clearer than sequential guards. Refactorcotidiano (Iglesias) shows that forcing a single return point in modern languages — the historical reason behind avoiding multiple exits — creates the very nesting that `else` elimination is supposed to prevent. +- **Practical heuristic**: If the first branch of an `if` ends in a return, throw, or continue, remove the `else` and let the remaining code fall through naturally. + +## Rule 3: Wrap All Primitives and Strings + +- **Intent**: Primitives carry no domain meaning on their own. A raw string can be a name, an email address, a postal code, or an error message — the type system cannot tell them apart. Wrapping a primitive in a domain type gives it meaning, allows it to enforce its own invariants, and keeps validation co-located with the concept. Refactorcotidiano (Iglesias) describes this as leaving primitives behind: built-in types are generic and sometimes you need something that adds meaning and constraints. +- **Application**: Introduce a value object for any primitive that has rules (a non-empty string, a positive number, a currency amount, an identifier). The value object validates in its constructor and exposes only behavior, not raw state. Codigo Sostenible (Ble) lists this as one of the four pillars of avoiding JaBOL-style programming: do not use built-in data types in the business layer — wrap them in your own types. +- **When to relax**: Primitives used purely as configuration values, array indices, or truly universal concepts (boolean flags for simple toggles) do not need wrapping unless they start accumulating validation logic or appear in multiple places with the same constraints. +- **Practical heuristic**: If the same primitive appears in two or more places with the same validation condition, it is a concept that deserves its own type. + +## Rule 4: First-Class Collections + +- **Intent**: A raw collection exposed to a caller forces that caller to know the collection's structure, its iteration logic, and its filtering rules. This spreads collection behavior across many classes — a form of shotgun coupling. A first-class collection is a class whose sole instance variable is the collection, and which owns all behavior over that collection. Refactorcotidiano (Iglesias) illustrates this with `TaskService`: when the service holds a raw array, it must know how to iterate it, filter it, and understand `Task` internals — three sources of coupling that a dedicated collection class would absorb. +- **Application**: When a collection has any rules — a minimum size, a filter predicate, a uniqueness constraint, a specific ordering — extract it into a named class. That class owns its add, remove, and query operations. Callers send messages to it; they do not reach inside. +- **When to relax**: Simple read-only lists passed through a method signature for the sole purpose of iteration need not become classes. Extract a collection class when the collection accumulates behavior, not when it is merely transported. +- **Practical heuristic**: If any class outside the collection's origin knows how to iterate, filter, or validate its contents, the collection needs to be a first-class object. + +## Rule 5: One Dot Per Line (Law of Demeter) + +- **Intent**: Chaining method calls across multiple objects — `a.getB().getC().doSomething()` — couples the calling class to every intermediate object and every step in the chain. POODR (Metz) calls this a train wreck: any change in `B` or `C` can force changes in the class that started the chain. The Law of Demeter says an object should only send messages to itself, its direct collaborators, objects it creates, and objects passed to it as arguments. +- **Application**: Each message chain that crosses object boundaries is a candidate for delegation. Instead of the caller navigating through B to reach C, B should expose a method that asks C on the caller's behalf. Refactorcotidiano (Iglesias) frames this under Tell, Don't Ask: send a command, do not navigate to retrieve state and act on it yourself. POODR adds that Demeter violations often reveal a missing abstraction — the journey through the graph is doing a job that belongs in a dedicated object. +- **When to relax**: Fluent builder APIs and method chaining on the same object (returning `self`) are not Demeter violations. Chaining through stable value objects with no behavioral variation is low risk. The rule applies most strongly to chains that cross behavioral objects. +- **Practical heuristic**: If you see more than one dot accessing collaborators (not fluent self-returns), add a delegating method to the first object in the chain. + +## Rule 6: Do Not Abbreviate + +- **Intent**: Abbreviations optimize for typing speed and penalize reading. Names are read many more times than they are written. An abbreviated name forces every reader to reconstruct what the author meant, and different readers may reconstruct it differently. Codigo Sostenible (Ble) links this to the historical context of scientific programming — single-letter variables and abbreviations came from a time when memory was scarce and keyboards were slow. That constraint no longer exists. Implementation Patterns (Beck) states explicitly that names should be optimized for readability, not ease of typing. +- **Application**: Use full words. Name methods by their intent, classes by their responsibility, and variables by their role in the computation. If a name requires three words to be unambiguous, use three words. Consistency across the model matters: use the same term everywhere for the same concept. +- **When to relax**: Widely understood domain abbreviations that all team members share (e.g., `http`, `id`, `url`, `dto`) are acceptable. Loop counters in a two-line body where the variable's role is visually obvious are a reasonable concession. +- **Practical heuristic**: If you have to explain what an abbreviation stands for to a new reader, spell it out in the code. + +## Rule 7: Keep All Entities Small + +- **Intent**: A class that grows beyond roughly fifty lines is usually doing more than one thing. A package with more than ten files is usually covering more than one concept. Size is a proxy for responsibility: when a class is small, it is easier to give it a single, precise name, and a single precise name resists the accumulation of unrelated methods. POODR (Metz) states that a class should do the smallest possible useful thing — it should have a single responsibility. +- **Application**: When a class approaches the size limit, ask: can you describe its responsibility in one sentence without using "and" or "or"? If not, it has more than one responsibility. Extract the secondary responsibility into a collaborator. Apply the same logic to packages: a namespace that holds too many files is a signal that it needs to be split into subdomains. POODR's heuristic is to rephrase each method as a statement about the class's responsibility — if the statement does not fit, the method belongs elsewhere. +- **When to relax**: Generated code, data-binding classes, and infrastructure adapters often grow beyond fifty lines for structural reasons unrelated to design decisions. The rule applies most strongly to domain and application-layer classes where responsibility concentration is a real design risk. +- **Practical heuristic**: If you cannot name a class without using a conjunction, split it until each part earns a single, unambiguous name. + +## Rule 8: No Classes with More than Two Instance Variables + +- **Intent**: This is the most severe rule and it is deliberately so. A class with many instance variables is managing multiple pieces of state that likely change for different reasons. Forcing a limit of two variables pushes you to extract collaborating objects and to model real domain concepts as types rather than as fields on a large class. In practice, the rule is a thinking tool: even if you never reach exactly two, the pressure it creates exposes hidden abstractions. +- **Application**: When a class has three or more instance variables, look for a cluster of two or more variables that belong together. That cluster is likely a concept the domain needs — extract it into its own class. Repeat until each class holds only the variables that define its identity. POODR's treatment of SRP supports this: different instance variables that change for different reasons signal that a class has multiple responsibilities. +- **When to relax**: Some domain entities genuinely require more than two attributes to define their identity — an order with a customer, a date, a list of items, and a status cannot be forced to two without artificial extraction. Treat this rule as a pressure to find hidden objects, not as a hard limit to obey in every case. +- **Practical heuristic**: If two instance variables always appear together in method signatures or are always read in the same methods, they belong in their own class. + +## Rule 9: No Getters, Setters, or Properties + +- **Intent**: Getters and setters expose internal state, inviting callers to retrieve data and act on it externally — the Ask side of Tell, Don't Ask. This converts objects into passive data containers and moves behavior to the callers, scattering logic that belongs together. Codigo Sostenible (Ble) calls this "JaBOL" — writing in an object-oriented language as if writing procedural code. Alan Kay, one of OOP's originators, emphasized message passing between self-contained objects, not data bags with accessors. Getters and setters add indirection but not real encapsulation. +- **Application**: Replace getters with methods that express what the caller needs done. Instead of asking for a value and acting on it, send a command to the object that holds the value and let it act on itself. Use constructors and factory methods for initialization; avoid setters entirely. Minimize the number of public methods overall. Refactorcotidiano (Iglesias) expresses this as Tell, Don't Ask: give the object a job to do rather than extracting its state to do the job for it. +- **When to relax**: Boundary objects that must serialize to an external format (DTOs, API response models, ORM entities) often require readable properties by convention. These are interface adapters, not domain objects; the rule applies to the core domain where encapsulation matters most. Read-only accessors that expose computed results — not raw state — are generally acceptable. +- **Practical heuristic**: If the caller retrieves a value with a getter and then makes a decision based on it, move that decision into the object that owns the value. diff --git a/skills/oop-best-practices/references/oop-good-practices-examples.md b/skills/oop-best-practices/references/oop-good-practices-examples.md new file mode 100644 index 0000000..5a5ae3e --- /dev/null +++ b/skills/oop-best-practices/references/oop-good-practices-examples.md @@ -0,0 +1,68 @@ +# OOP Good Practices: Course Observations + +Source: reviewed lessons and history from [CodelyTV/object_oriented_programming-good_practices-course](https://github.com/CodelyTV/object_oriented_programming-good_practices-course). The repository contains progressive and occasionally overwritten educational snapshots; treat the guidance below as corrected interpretation, not a claim that every proposed refactoring appears in the final tree. + +## Law of Demeter as Knowledge Coupling + +The TypeScript finder reaches through `User -> UserFullName -> UserName/UserLastName -> value` to format a name. The problem is not the number of dots by itself: the finder knows the nested representation and changes when that representation changes. + +Move the smallest stable capability to the concept that owns it. In this case `UserFullName.formatted()` is usually more cohesive than putting presentation on `User`. A finder may then ask for the semantic result without knowing the nested fields. Fluent APIs, immutable DTO mapping, and chains returning the same object are not automatically Demeter violations. + +Value Objects do not improve encapsulation when every caller still traverses their public `.value` graph. Introduce them for meaning, invariants, type distinction, or behavior, and expose the narrow semantic operation that callers actually need. + +## Tell, Do Not Ask + +The Python example contrasts direct inspection/mutation of `saved_products` with `add_to_saved_products` and `remove_from_saved_products`. The useful lesson is ownership of the decision: duplicate and removal policy belong with the object or collection that owns membership. + +Do not infer more than the code guarantees. Python list membership uses object equality; because the course `Product` lacks semantic equality, the implementation prevents the same instance from being added twice, not two different instances with the same product ID. ID-based uniqueness requires explicit equality or lookup by ID. + +Tell, Do Not Ask does not ban queries. Queries are appropriate for orchestration, authorization inputs, read models, and presentation. Move a decision when a caller repeatedly interprets another object's owned state. Keep CLI/HTTP/localized rendering in presenters or adapters rather than moving every display string into a domain entity. + +## Named Construction and Cohesion + +The final Java snapshot assembles `UserId`, `UserFullName`, default access level, and registration time inside `UserRegistrar`; despite the directory name, it no longer contains the historical `User.register(...)` factory. Treat it as a construction-knowledge smell and an exercise, not as a finished named-constructor example. + +A named constructor is useful when its name expresses a lifecycle event or stable variant and it centralizes defaults/invariants that callers should not know. It is not useful merely to hide `new`. Private construction centralizes supported creation paths; each path must still validate its own invariants. + +When creation needs ambient dependencies such as a clock, choose deliberately: + +- let the application obtain time and pass an explicit `Instant` to a cohesive domain factory; +- use a dedicated factory when several external collaborators participate; +- avoid making the Aggregate depend directly on infrastructure just to preserve a named constructor. + +Do not proliferate factories for speculative variants. Let repeated, stable construction knowledge earn the abstraction. + +## Collections and Identity + +Keep invariant-bearing mutable collections private. Expose intention-revealing mutations and immutable snapshots or semantic queries when callers need reads. Introduce a first-class collection when membership, uniqueness, ordering, capacity, selection, or aggregation forms a real concept; a transported read-only list does not need a wrapper. + +Define identity explicitly. For saved products, decide whether duplicates mean the same instance, product ID, SKU, or complete value. Return an outcome or typed failure for duplicate/absent operations when callers need to react; silent no-op is a policy, not a universal default. + +## Dependencies and Substitutability + +Constructor-injected repository roles in the course improve dependency direction. The Java fake, however, discards saves and always misses on search, so it satisfies signatures without the useful behavioral contract. Liskov substitution includes observable behavior, not compilation alone. + +Use a stateful fake when save-then-find semantics matter, create/reset it per test, and share the same lifecycle-scoped instance across commands and queries. Time, randomness, and environment are dependencies too; inject them when their values affect behavior or tests. + +Do not create an interface for every class. Extract a role at a volatile/I/O boundary, when multiple implementations exist, or when a client needs a narrower capability. + +## Cross-Language Enforcement + +- **TypeScript:** `readonly` is shallow and compile-time only; public nested fields still leak representation. Structural mocks can conform accidentally, and runtime payloads still need validation. +- **Python:** privacy is conventional, lists and wrappers remain mutable unless protected, and collection membership depends on `__eq__`. Return tuples/copies for immutable views and avoid binary `float` for authoritative money. +- **Java:** records provide shallow immutability and value equality, while `LocalDateTime.now()` is ambient and timezone-free. Prefer `Clock` plus `Instant` for deterministic audit time unless civil local time is the domain concept. + +## Review Questions + +- Which decision or invariant is scattered outside the object that owns it? +- Does a message chain leak volatile structure or merely map stable data? +- Does a wrapper add meaning/behavior, or only another `.value` hop? +- Is collection uniqueness based on the correct identity? +- Does a named constructor express a real creation intent and enforce its contract? +- Does a fake preserve the collaborator's essential behavioral semantics? +- Is presentation being confused with domain behavior? +- Is a numeric style rule revealing a design problem, or creating artificial objects? + +## Course Caveats + +Do not copy length-only UUID validation, arbitrary universal name lengths, public mutable Python collections, float money, domain-owned display formatting, hard-coded current time, a non-persisting fake repository, broad `RuntimeException` catch-to-empty responses, or the final Java snapshot as proof of named constructors. Protect observable behavior with tests before applying these refactorings; use static architecture checks only for an intentional structural boundary, not to assert dot counts. diff --git a/skills/oop-best-practices/references/php-examples.md b/skills/oop-best-practices/references/php-examples.md new file mode 100644 index 0000000..f1ab784 --- /dev/null +++ b/skills/oop-best-practices/references/php-examples.md @@ -0,0 +1,1184 @@ +# PHP Examples + +These examples cover the same core concepts as the other language-specific example files. + +## Concepts Covered + +- Value Objects and Invariants +- First-Class Collections +- Tell, Don't Ask +- Role-Based Collaboration +- Dependency Injection +- Explicit Interfaces +- Duck Typing / Protocol-Style Roles +- Composition over Inheritance +- Message-Based Design +- Law of Demeter Violation and Fix +- Immutable Objects +- Null Object +- Anemic versus Rich Model + +## Value Objects and Invariants + +```php +final class Money +{ + public function __construct(private int $cents) + { + if ($cents < 0) { + throw new InvalidArgumentException('Money cannot be negative'); + } + } + + public function add(Money $other): Money + { + return new Money($this->cents + $other->value()); + } + + public function multiplyBy(int $percent): Money + { + return new Money((int) round($this->cents * $percent / 100)); + } + + public function value(): int + { + return $this->cents; + } +} +``` + +## First-Class Collections + +```php +final class OrderLine +{ + public function __construct(private Money $subtotalAmount) + { + } + + public function subtotal(): Money + { + return $this->subtotalAmount; + } +} + +final class OrderLines +{ + public function __construct(private array $items) + { + } + + public function total(): Money + { + $total = new Money(0); + foreach ($this->items as $item) { + $total = $total->add($item->subtotal()); + } + return $total; + } + + public function isEmpty(): bool + { + return count($this->items) === 0; + } +} +``` + +## Tell, Don't Ask + +```php +final class Address +{ + public function __construct(private string $countryCode) + { + } + + public function isDomestic(): bool + { + return $this->countryCode === 'ES'; + } +} + +final class Shipment +{ + public function __construct(private Address $address) + { + } + + public function dispatchWindowInDays(): int + { + return $this->address->isDomestic() ? 2 : 5; + } +} +``` + +## Role-Based Collaboration + +```php +interface CurrencyFormatter +{ + public function format(Money $amount): string; +} + +final class OrderSummary +{ + public function __construct(private CurrencyFormatter $formatter) + { + } + + public function totalLabel(OrderLines $lines): string + { + return $this->formatter->format($lines->total()); + } +} +``` + +## Dependency Injection + +```php +interface Mailer +{ + public function send(string $to, string $body): void; +} + +final class Invoice +{ + public function __construct( + private string $recipient, + private string $bodyText, + ) { + } + + public function recipientEmail(): string + { + return $this->recipient; + } + + public function body(): string + { + return $this->bodyText; + } +} + +final class InvoiceSender +{ + public function __construct(private Mailer $mailer) + { + } + + public function send(Invoice $invoice): void + { + $this->mailer->send($invoice->recipientEmail(), $invoice->body()); + } +} +``` + +## Explicit Interfaces + +```php +interface PaymentGateway +{ + public function charge(string $customerId, Money $amount): void; +} + +final class SubscriptionActivator +{ + public function __construct(private PaymentGateway $paymentGateway) + { + } + + public function activate(string $customerId, Money $fee): void + { + $this->paymentGateway->charge($customerId, $fee); + } +} +``` + +## Duck Typing / Protocol-Style Roles + +```php +interface StockSource +{ + public function availableUnits(): int; +} + +final class InventoryReport +{ + public function __construct(private StockSource $source) + { + } + + public function isAvailable(): bool + { + return $this->source->availableUnits() > 0; + } +} + +final class WarehouseBin implements StockSource +{ + public function __construct(private int $units) + { + } + + public function availableUnits(): int + { + return $this->units; + } +} +``` + +## Composition over Inheritance + +```php +interface DiscountPolicy +{ + public function apply(Money $total): Money; +} + +interface TaxPolicy +{ + public function apply(Money $total): Money; +} + +final class CartPricing +{ + public function __construct( + private DiscountPolicy $discountPolicy, + private TaxPolicy $taxPolicy, + ) { + } + + public function total(Money $subtotal): Money + { + $discounted = $this->discountPolicy->apply($subtotal); + return $this->taxPolicy->apply($discounted); + } +} +``` + +## Message-Based Design + +```php +interface SeatInventory +{ + public function reserve(int $seatCount): void; +} + +interface PaymentService +{ + public function charge(Money $amount): void; +} + +final class Booking +{ + public function __construct( + private int $seats, + private Money $amount, + private SeatInventory $inventory, + private PaymentService $payments, + ) { + } + + public function confirm(): void + { + $this->inventory->reserve($this->seats); + $this->payments->charge($this->amount); + } +} +``` + +## Law of Demeter Violation and Fix + +### Before + +```php +final class CustomerRecord +{ + public function __construct(private Address $address) + { + } + + public function shippingAddress(): Address + { + return $this->address; + } +} + +final class Order +{ + public function __construct(private CustomerRecord $customer) + { + } + + public function customerRecord(): CustomerRecord + { + return $this->customer; + } +} + +$domestic = $order->customerRecord()->shippingAddress()->isDomestic(); +``` + +### After + +```php +final class Customer +{ + public function __construct(private Address $address) + { + } + + public function shipsDomestically(): bool + { + return $this->address->isDomestic(); + } +} + +final class PurchaseOrder +{ + public function __construct(private Customer $customer) + { + } + + public function shipsDomestically(): bool + { + return $this->customer->shipsDomestically(); + } +} + +$domestic = $order->shipsDomestically(); +``` + +## Immutable Objects + +```php +final class Rooms +{ + public function __construct(private array $items) + { + } + + public function add(string $room): Rooms + { + return new Rooms([...$this->items, $room]); + } + + public function count(): int + { + return count($this->items); + } +} +``` + +## Null Object + +```php +interface Logger +{ + public function info(string $message): void; +} + +final class NullLogger implements Logger +{ + public function info(string $message): void + { + } +} +``` + +## Anemic versus Rich Model + +### Anemic + +```php +final class ScoreData +{ + public function __construct(public int $value) + { + } +} + +function increaseScore(ScoreData $score, int $points): void +{ + $score->value = $score->value + $points; +} +``` + +### Rich + +```php +final class Score +{ + public function __construct(private int $value) + { + } + + public function increase(int $points): Score + { + return new Score($this->value + $points); + } + + public function value(): int + { + return $this->value; + } +} +``` + +## SOLID — Single Responsibility Violation and Fix + +### Before + +```php +final class Report +{ + public function __construct( + private readonly string $title, + private readonly string $content, + ) { + } + + public function title(): string + { + return $this->title; + } + + public function content(): string + { + return $this->content; + } + + public function save(\PDO $pdo): void + { + $stmt = $pdo->prepare('INSERT INTO reports (title, content) VALUES (?, ?)'); + $stmt->execute([$this->title, $this->content]); + } +} +``` + +### After + +```php +final class Report +{ + public function __construct( + private readonly string $title, + private readonly string $content, + ) { + } + + public function title(): string + { + return $this->title; + } + + public function content(): string + { + return $this->content; + } +} + +final class ReportRepository +{ + public function __construct(private readonly \PDO $pdo) + { + } + + public function save(Report $report): void + { + $stmt = $this->pdo->prepare('INSERT INTO reports (title, content) VALUES (?, ?)'); + $stmt->execute([$report->title(), $report->content()]); + } +} +``` + +## Object Calisthenics — Wrap Primitive + +```php +final class Percentage +{ + public function __construct(private readonly int $value) + { + if ($value < 0 || $value > 100) { + throw new \InvalidArgumentException( + "Percentage must be between 0 and 100, got {$value}." + ); + } + } + + public function of(int $amount): int + { + return (int) round($amount * $this->value / 100); + } + + public function value(): int + { + return $this->value; + } +} + +final class Price +{ + public function __construct(private readonly int $cents) + { + if ($cents < 0) { + throw new \InvalidArgumentException('Price cannot be negative.'); + } + } + + public function applyDiscount(Percentage $discount): Price + { + return new Price($this->cents - $discount->of($this->cents)); + } + + public function cents(): int + { + return $this->cents; + } +} +``` + +## Object Calisthenics — No Else Rule + +### Before + +```php +final class ShippingCalculator +{ + public function shippingCost(Order $order): int + { + if ($order->isPremiumMember()) { + return 0; + } else { + if ($order->totalCents() >= 5000) { + return 0; + } else { + if ($order->isInternational()) { + return 1500; + } else { + return 500; + } + } + } + } +} +``` + +### After + +```php +final class ShippingCalculator +{ + public function shippingCost(Order $order): int + { + if ($order->isPremiumMember()) { + return 0; + } + + if ($order->totalCents() >= 5000) { + return 0; + } + + if ($order->isInternational()) { + return 1500; + } + + return 500; + } +} +``` + +## Dependency Direction + +### Before + +```php +final class InvoiceExporter +{ + public function export(string $path, string $content): void + { + $fs = new FileSystem(); + $fs->write($path, $content); + } +} + +final class FileSystem +{ + public function write(string $path, string $content): void + { + file_put_contents($path, $content); + } +} +``` + +### After + +```php +interface DocumentStorage +{ + public function write(string $path, string $content): void; +} + +final class FileSystemStorage implements DocumentStorage +{ + public function write(string $path, string $content): void + { + file_put_contents($path, $content); + } +} + +final class InvoiceExporter +{ + public function __construct(private readonly DocumentStorage $storage) + { + } + + public function export(string $path, string $content): void + { + $this->storage->write($path, $content); + } +} +``` + +## Composed Method + +### Before + +```php +final class RegistrationService +{ + public function register(string $email, string $password): void + { + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Invalid email address.'); + } + if (strlen($password) < 8) { + throw new \DomainException('Password must be at least 8 characters.'); + } + + $hash = password_hash($password, PASSWORD_BCRYPT); + $user = ['email' => $email, $hash => $hash, 'created_at' => date('Y-m-d H:i:s')]; + + $this->pdo->prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?,?,?)') + ->execute([$user['email'], $user['hash'], $user['created_at']]); + + mail($email, 'Welcome!', 'Thanks for signing up.'); + } +} +``` + +### After + +```php +final class RegistrationService +{ + public function __construct( + private readonly \PDO $pdo, + private readonly Mailer $mailer, + ) { + } + + public function register(string $email, string $password): void + { + $this->validate($email, $password); + $user = $this->buildUser($email, $password); + $this->persist($user); + $this->welcome($email); + } + + private function validate(string $email, string $password): void + { + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Invalid email address.'); + } + if (strlen($password) < 8) { + throw new \DomainException('Password must be at least 8 characters.'); + } + } + + private function buildUser(string $email, string $password): array + { + return [ + 'email' => $email, + 'password_hash' => password_hash($password, PASSWORD_BCRYPT), + 'created_at' => date('Y-m-d H:i:s'), + ]; + } + + private function persist(array $user): void + { + $this->pdo->prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?,?,?)') + ->execute([$user['email'], $user['password_hash'], $user['created_at']]); + } + + private function welcome(string $email): void + { + $this->mailer->send($email, 'Welcome!', 'Thanks for signing up.'); + } +} +``` + +## SOLID — Open/Closed Principle + +### Before + +```php +final class ShippingCalculator +{ + public function cost(string $orderType, int $weightGrams): int + { + if ($orderType === 'standard') { + return 500 + (int) round($weightGrams * 0.1); + } elseif ($orderType === 'express') { + return 1500 + (int) round($weightGrams * 0.3); + } elseif ($orderType === 'overnight') { + return 3000 + (int) round($weightGrams * 0.5); + } + + return 500; + } +} +``` + +### After + +```php +interface ShippingPolicy +{ + public function cost(int $weightGrams): int; +} + +final class StandardShipping implements ShippingPolicy +{ + public function cost(int $weightGrams): int + { + return 500 + (int) round($weightGrams * 0.1); + } +} + +final class ExpressShipping implements ShippingPolicy +{ + public function cost(int $weightGrams): int + { + return 1500 + (int) round($weightGrams * 0.3); + } +} + +final class OvernightShipping implements ShippingPolicy +{ + public function cost(int $weightGrams): int + { + return 3000 + (int) round($weightGrams * 0.5); + } +} + +final class ShippingCalculator +{ + public function __construct(private readonly ShippingPolicy $policy) + { + } + + public function cost(int $weightGrams): int + { + return $this->policy->cost($weightGrams); + } +} +``` + +## SOLID — Liskov Substitution Principle + +### Before + +```php +class Collection +{ + private array $items = []; + + public function add(mixed $item): void + { + $this->items[] = $item; + } + + public function all(): array + { + return $this->items; + } +} + +class ReadOnlyCollection extends Collection +{ + public function add(mixed $item): void + { + throw new \BadMethodCallException('This collection is read-only.'); + } +} +``` + +### After + +```php +final class MutableCollection +{ + private array $items = []; + + public function add(mixed $item): void + { + $this->items[] = $item; + } + + public function all(): array + { + return $this->items; + } +} + +final class ReadOnlyCollection +{ + public function __construct(private readonly array $items) + { + } + + public function all(): array + { + return $this->items; + } +} +``` + +## SOLID — Interface Segregation Principle + +### Before + +```php +interface Worker +{ + public function work(): void; + public function eat(): void; + public function sleep(): void; +} + +final class RobotWorker implements Worker +{ + public function work(): void + { + // performs task + } + + public function eat(): void + { + throw new \BadMethodCallException('Robots do not eat.'); + } + + public function sleep(): void + { + throw new \BadMethodCallException('Robots do not sleep.'); + } +} +``` + +### After + +```php +interface Workable +{ + public function work(): void; +} + +interface Eatable +{ + public function eat(): void; +} + +interface Sleepable +{ + public function sleep(): void; +} + +final class HumanWorker implements Workable, Eatable, Sleepable +{ + public function work(): void + { + // performs task + } + + public function eat(): void + { + // has lunch + } + + public function sleep(): void + { + // rests + } +} + +final class RobotWorker implements Workable +{ + public function work(): void + { + // performs task + } +} +``` + +## SOLID — Dependency Inversion Principle + +### Before + +```php +final class OrderProcessor +{ + public function process(array $order): void + { + $db = new PostgresDatabase(); + $db->insert('orders', $order); + } +} + +final class PostgresDatabase +{ + public function insert(string $table, array $data): void + { + // writes to PostgreSQL + } +} +``` + +### After + +```php +interface OrderStore +{ + public function save(array $order): void; +} + +final class PostgresOrderStore implements OrderStore +{ + public function save(array $order): void + { + // writes to PostgreSQL + } +} + +final class OrderProcessor +{ + public function __construct(private readonly OrderStore $store) + { + } + + public function process(array $order): void + { + $this->store->save($order); + } +} +``` + +## Object Calisthenics — One Level of Indentation + +### Before + +```php +final class ReportGenerator +{ + public function generateReport(array $orders): string + { + $lines = []; + foreach ($orders as $order) { + if ($order['status'] === 'complete') { + foreach ($order['items'] as $item) { + if ($item['price'] > 1000) { + $lines[] = $item['name'] . ': $' . number_format($item['price'] / 100, 2); + } + } + } + } + return implode("\n", $lines); + } +} +``` + +### After + +```php +final class ReportGenerator +{ + public function generateReport(array $orders): string + { + $items = array_merge(...array_map( + fn(array $order): array => $this->expensiveItems($order), + $this->completeOrders($orders), + )); + + return implode("\n", array_map($this->formatItem(...), $items)); + } + + private function completeOrders(array $orders): array + { + return array_values(array_filter( + $orders, + fn(array $order): bool => $order['status'] === 'complete', + )); + } + + private function expensiveItems(array $order): array + { + return array_values(array_filter( + $order['items'], + fn(array $item): bool => $item['price'] > 1000, + )); + } + + private function formatItem(array $item): string + { + return $item['name'] . ': $' . number_format($item['price'] / 100, 2); + } +} +``` + +## Object Calisthenics — No Getters/Setters + +### Before + +```php +final class Rectangle +{ + public function __construct( + private readonly int $width, + private readonly int $height, + ) { + } + + public function getWidth(): int + { + return $this->width; + } + + public function getHeight(): int + { + return $this->height; + } +} + +// Caller computes behaviour outside the object +$area = $rect->getWidth() * $rect->getHeight(); +$perimeter = 2 * ($rect->getWidth() + $rect->getHeight()); +$isSquare = $rect->getWidth() === $rect->getHeight(); +``` + +### After + +```php +final class Rectangle +{ + public function __construct( + private readonly int $width, + private readonly int $height, + ) { + } + + public function area(): int + { + return $this->width * $this->height; + } + + public function perimeter(): int + { + return 2 * ($this->width + $this->height); + } + + public function isSquare(): bool + { + return $this->width === $this->height; + } +} +``` + +## Object Calisthenics — Don't Abbreviate + +### Before + +```php +final class OrdMgr +{ + public function calc(Order $o): int + { + return $o->totalCents(); + } + + public function proc(Order $o): void + { + // process order + } +} +``` + +### After + +```php +final class OrderManager +{ + public function calculateTotal(Order $order): int + { + return $order->totalCents(); + } + + public function processOrder(Order $order): void + { + // process order + } +} +``` + +## Explaining Message + +### Before + +```php +final class Subscription +{ + public function __construct( + private readonly \DateTimeImmutable $startDate, + private readonly int $durationDays, + ) { + } + + public function isExpired(): bool + { + return new \DateTimeImmutable() > (new \DateTimeImmutable())->setTimestamp( + $this->startDate->getTimestamp() + ($this->durationDays * 86400), + ); + } +} +``` + +### After + +```php +final class Subscription +{ + public function __construct( + private readonly \DateTimeImmutable $startDate, + private readonly int $durationDays, + ) { + } + + public function isExpired(): bool + { + return new \DateTimeImmutable() > $this->expirationDate(); + } + + private function expirationDate(): \DateTimeInterface + { + return $this->startDate->modify("+{$this->durationDays} days"); + } +} +``` + +## What to Notice + +- Rich models and clear object responsibilities help keep knowledge close to the concept. +- PHP can model the same object boundaries with final classes and small interfaces. +- Explicit contracts, injected collaborators, and composition help PHP stay object-oriented instead of procedural. +- Delegating through meaningful messages reduces train-wreck navigation. +- Wrapping primitives and splitting responsibilities keep each class focused on one reason to change. +- SOLID principles, Object Calisthenics rules, and extracted explaining messages each reduce a different kind of coupling or noise. diff --git a/skills/oop-best-practices/references/python-examples.md b/skills/oop-best-practices/references/python-examples.md new file mode 100644 index 0000000..edc974c --- /dev/null +++ b/skills/oop-best-practices/references/python-examples.md @@ -0,0 +1,832 @@ +# Python Examples + +These examples cover the same core concepts as the other language-specific example files. + +## Concepts Covered + +- Value Objects and Invariants +- First-Class Collections +- Tell, Don't Ask +- Role-Based Collaboration +- Dependency Injection +- Explicit Interfaces +- Duck Typing / Protocol-Style Roles +- Composition over Inheritance +- Message-Based Design +- Law of Demeter Violation and Fix +- Immutable Objects +- Null Object +- Anemic versus Rich Model + +## Value Objects and Invariants + +```python +class Money: + def __init__(self, cents: int) -> None: + if cents < 0: + raise ValueError("Money cannot be negative") + self._cents = cents + + def add(self, other: "Money") -> "Money": + return Money(self._cents + other._cents) + + def multiply_by(self, percent: int) -> "Money": + return Money(round(self._cents * percent / 100)) + + def value(self) -> int: + return self._cents +``` + +## First-Class Collections + +```python +class OrderLine: + def __init__(self, subtotal_amount: Money) -> None: + self._subtotal_amount = subtotal_amount + + def subtotal(self) -> Money: + return self._subtotal_amount + +class OrderLines: + def __init__(self, items: list[OrderLine]) -> None: + self._items = list(items) + + def total(self) -> Money: + total = Money(0) + for item in self._items: + total = total.add(item.subtotal()) + return total + + def is_empty(self) -> bool: + return len(self._items) == 0 +``` + +## Tell, Don't Ask + +```python +class Address: + def __init__(self, country_code: str) -> None: + self._country_code = country_code + + def is_domestic(self) -> bool: + return self._country_code == "ES" + +class Shipment: + def __init__(self, address: Address) -> None: + self._address = address + + def dispatch_window_in_days(self) -> int: + return 2 if self._address.is_domestic() else 5 +``` + +## Role-Based Collaboration + +```python +class CurrencyFormatter: + def format(self, amount: Money) -> str: + raise NotImplementedError + +class OrderSummary: + def __init__(self, formatter: CurrencyFormatter) -> None: + self._formatter = formatter + + def total_label(self, lines: OrderLines) -> str: + return self._formatter.format(lines.total()) +``` + +## Dependency Injection + +```python +class Mailer: + def send(self, to: str, body: str) -> None: + raise NotImplementedError + +class Invoice: + def __init__(self, recipient: str, body_text: str) -> None: + self._recipient = recipient + self._body_text = body_text + + def recipient_email(self) -> str: + return self._recipient + + def body(self) -> str: + return self._body_text + +class InvoiceSender: + def __init__(self, mailer: Mailer) -> None: + self._mailer = mailer + + def send(self, invoice: Invoice) -> None: + self._mailer.send(invoice.recipient_email(), invoice.body()) +``` + +## Explicit Interfaces + +```python +from typing import Protocol + +class PaymentGateway(Protocol): + def charge(self, customer_id: str, amount: Money) -> None: + pass + +class SubscriptionActivator: + def __init__(self, payment_gateway: PaymentGateway) -> None: + self._payment_gateway = payment_gateway + + def activate(self, customer_id: str, fee: Money) -> None: + self._payment_gateway.charge(customer_id, fee) +``` + +## Duck Typing / Protocol-Style Roles + +```python +class InventoryReport: + def __init__(self, source) -> None: + self._source = source + + def is_available(self) -> bool: + return self._source.available_units() > 0 + +class WarehouseBin: + def __init__(self, units: int) -> None: + self._units = units + + def available_units(self) -> int: + return self._units +``` + +## Composition over Inheritance + +```python +class DiscountPolicy: + def apply(self, total: Money) -> Money: + raise NotImplementedError + +class TaxPolicy: + def apply(self, total: Money) -> Money: + raise NotImplementedError + +class CartPricing: + def __init__(self, discount_policy: DiscountPolicy, tax_policy: TaxPolicy) -> None: + self._discount_policy = discount_policy + self._tax_policy = tax_policy + + def total(self, subtotal: Money) -> Money: + discounted = self._discount_policy.apply(subtotal) + return self._tax_policy.apply(discounted) +``` + +## Message-Based Design + +```python +class SeatInventory: + def reserve(self, seat_count: int) -> None: + raise NotImplementedError + +class PaymentService: + def charge(self, amount: Money) -> None: + raise NotImplementedError + +class Booking: + def __init__( + self, + seats: int, + amount: Money, + inventory: SeatInventory, + payments: PaymentService, + ) -> None: + self._seats = seats + self._amount = amount + self._inventory = inventory + self._payments = payments + + def confirm(self) -> None: + self._inventory.reserve(self._seats) + self._payments.charge(self._amount) +``` + +## Law of Demeter Violation and Fix + +### Before + +```python +class CustomerRecord: + def __init__(self, address: Address) -> None: + self._address = address + + def shipping_address(self) -> Address: + return self._address + +class Order: + def __init__(self, customer: CustomerRecord) -> None: + self._customer = customer + + def customer_record(self) -> CustomerRecord: + return self._customer + +domestic = order.customer_record().shipping_address().is_domestic() +``` + +### After + +```python +class Customer: + def __init__(self, address: Address) -> None: + self._address = address + + def ships_domestically(self) -> bool: + return self._address.is_domestic() + +class PurchaseOrder: + def __init__(self, customer: Customer) -> None: + self._customer = customer + + def ships_domestically(self) -> bool: + return self._customer.ships_domestically() + +domestic = order.ships_domestically() +``` + +## Immutable Objects + +```python +class Rooms: + def __init__(self, items: tuple[str, ...]) -> None: + self._items = items + + def add(self, room: str) -> "Rooms": + return Rooms(self._items + (room,)) + + def count(self) -> int: + return len(self._items) +``` + +## Null Object + +```python +class Logger: + def info(self, message: str) -> None: + raise NotImplementedError + +class NullLogger(Logger): + def info(self, message: str) -> None: + return None +``` + +## Anemic versus Rich Model + +### Anemic + +```python +class ScoreData: + def __init__(self, value: int) -> None: + self.value = value + +def increase_score(score: ScoreData, points: int) -> None: + score.value = score.value + points +``` + +### Rich + +```python +class Score: + def __init__(self, value: int) -> None: + self._value = value + + def increase(self, points: int) -> "Score": + return Score(self._value + points) + + def value(self) -> int: + return self._value +``` + +## SOLID — Single Responsibility Violation and Fix + +### Before — one class does too much + +```python +from dataclasses import dataclass + +@dataclass +class Report: + title: str + content: str + + def save(self, report_id: int) -> None: + # mixes persistence logic into a data class + db = Database() + db.execute( + "INSERT INTO reports (id, title, content) VALUES (?, ?, ?)", + (report_id, self.title, self.content), + ) +``` + +### After — split by responsibility + +```python +from dataclasses import dataclass + +@dataclass(frozen=True) +class Report: + title: str + content: str + +class ReportRepository: + def __init__(self, db: Database) -> None: + self._db = db + + def save(self, report_id: int, report: Report) -> None: + self._db.execute( + "INSERT INTO reports (id, title, content) VALUES (?, ?, ?)", + (report_id, report.title, report.content), + ) +``` + +## Object Calisthenics — Wrap Primitive + +### Before — raw int leaks the invariant everywhere + +```python +def apply_discount(price: int, discount: int) -> int: + # nothing stops discount=150 from being passed + return round(price * (1 - discount / 100)) +``` + +### After — encapsulate the concept and its rules + +```python +class Percentage: + def __init__(self, value: int) -> None: + if not (0 <= value <= 100): + raise ValueError(f"Percentage must be between 0 and 100, got {value}") + self._value = value + + def of(self, amount: int) -> int: + return round(amount * self._value / 100) + + def __repr__(self) -> str: + return f"Percentage({self._value})" + +class Price: + def __init__(self, amount: int) -> None: + if amount < 0: + raise ValueError("Price cannot be negative") + self._amount = amount + + def apply_discount(self, discount: Percentage) -> "Price": + return Price(self._amount - discount.of(self._amount)) + + def value(self) -> int: + return self._amount +``` + +## Object Calisthenics — No Else Rule + +### Before — nested if/else blocks + +```python +def shipping_cost(order) -> int: + if order.is_member(): + if order.total() > 100: + return 0 + else: + return 3 + else: + if order.total() > 50: + return 5 + else: + return 10 +``` + +### After — guard clauses with early returns + +```python +def shipping_cost(order) -> int: + if order.is_member() and order.total() > 100: + return 0 + if order.is_member(): + return 3 + if order.total() > 50: + return 5 + return 10 +``` + +## Dependency Direction + +### Before — hardcoded volatile dependency + +```python +class InvoiceExporter: + def export(self, invoice_id: int, content: str) -> None: + fs = FileSystem() # concrete, volatile + fs.write(f"/invoices/{invoice_id}.pdf", content) +``` + +### After — depend on an abstraction via Protocol + +```python +from typing import Protocol + +class DocumentStorage(Protocol): + def write(self, path: str, content: str) -> None: + ... + +class InvoiceExporter: + def __init__(self, storage: DocumentStorage) -> None: + self._storage = storage + + def export(self, invoice_id: int, content: str) -> None: + self._storage.write(f"/invoices/{invoice_id}.pdf", content) + +# Any object with a matching write() method satisfies DocumentStorage. +# The exporter never imports FileSystem — the direction of the dependency +# now points toward the abstraction, not the volatile implementation. +``` + +## Composed Method + +### Before — one flat method doing everything + +```python +import hashlib + +class RegistrationService: + def register(self, email: str, password: str) -> None: + if not email or "@" not in email: + raise ValueError("Invalid email") + if len(password) < 8: + raise ValueError("Password too short") + hashed = hashlib.sha256(password.encode()).hexdigest() + user = {"email": email, "password_hash": hashed} + self._db.insert("users", user) + self._mailer.send(email, "Welcome!", "Your account is ready.") +``` + +### After — register reads as a high-level sequence of steps + +```python +import hashlib +from dataclasses import dataclass + +@dataclass(frozen=True) +class User: + email: str + password_hash: str + +class RegistrationService: + def register(self, email: str, password: str) -> None: + self._validate(email, password) + user = self._build_user(email, password) + self._persist(user) + self._welcome(user) + + def _validate(self, email: str, password: str) -> None: + if not email or "@" not in email: + raise ValueError("Invalid email") + if len(password) < 8: + raise ValueError("Password too short") + + def _build_user(self, email: str, password: str) -> User: + hashed = hashlib.sha256(password.encode()).hexdigest() + return User(email=email, password_hash=hashed) + + def _persist(self, user: User) -> None: + self._db.insert("users", {"email": user.email, "password_hash": user.password_hash}) + + def _welcome(self, user: User) -> None: + self._mailer.send(user.email, "Welcome!", "Your account is ready.") +``` + +## SOLID — Open/Closed Principle + +### Before — switch on type string + +```python +class ShippingCalculator: + def cost(self, order) -> int: + if order.type == "standard": + return 5 + elif order.type == "express": + return 15 + elif order.type == "overnight": + return 25 + return 0 +``` + +### After — open for extension, closed for modification + +```python +from abc import ABC, abstractmethod + +class ShippingPolicy(ABC): + @abstractmethod + def cost(self, order) -> int: + ... + +class StandardShipping(ShippingPolicy): + def cost(self, order) -> int: + return 5 + +class ExpressShipping(ShippingPolicy): + def cost(self, order) -> int: + return 15 + +class OvernightShipping(ShippingPolicy): + def cost(self, order) -> int: + return 25 + +class ShippingCalculator: + def __init__(self, policy: ShippingPolicy) -> None: + self._policy = policy + + def cost(self, order) -> int: + return self._policy.cost(order) +``` + +## SOLID — Liskov Substitution Principle + +### Before — subclass breaks the contract + +```python +class Collection: + def __init__(self) -> None: + self._items: list = [] + + def add(self, item) -> None: + self._items.append(item) + + def all(self) -> list: + return list(self._items) + +class ReadOnlyCollection(Collection): + def add(self, item) -> None: + raise NotImplementedError("This collection is read-only") # LSP violation +``` + +### After — two independent classes with composition + +```python +class MutableCollection: + def __init__(self, items: list | None = None) -> None: + self._items: list = list(items) if items else [] + + def add(self, item) -> None: + self._items.append(item) + + def all(self) -> list: + return list(self._items) + +class ReadOnlyCollection: + def __init__(self, items: list) -> None: + self._items = list(items) + + def all(self) -> list: + return list(self._items) +``` + +## SOLID — Interface Segregation Principle + +### Before — fat ABC forces irrelevant methods + +```python +from abc import ABC, abstractmethod + +class Worker(ABC): + @abstractmethod + def work(self) -> None: + ... + + @abstractmethod + def eat(self) -> None: + ... + + @abstractmethod + def sleep(self) -> None: + ... + +class RobotWorker(Worker): + def work(self) -> None: + print("Robot working") + + def eat(self) -> None: + raise NotImplementedError("Robots do not eat") + + def sleep(self) -> None: + raise NotImplementedError("Robots do not sleep") +``` + +### After — focused Protocol types per capability + +```python +from typing import Protocol + +class Workable(Protocol): + def work(self) -> None: + ... + +class Eatable(Protocol): + def eat(self) -> None: + ... + +class Sleepable(Protocol): + def sleep(self) -> None: + ... + +class HumanWorker: + def work(self) -> None: + print("Human working") + + def eat(self) -> None: + print("Human eating") + + def sleep(self) -> None: + print("Human sleeping") + +class RobotWorker: + def work(self) -> None: + print("Robot working") +``` + +## SOLID — Dependency Inversion Principle + +### Before — high-level module depends on a concrete detail + +```python +class PostgresDatabase: + def save_order(self, order) -> None: + ... # writes directly to Postgres + +class OrderProcessor: + def __init__(self) -> None: + self._db = PostgresDatabase() # hardcoded volatile dependency + + def process(self, order) -> None: + self._db.save_order(order) +``` + +### After — high-level module owns the abstraction + +```python +from typing import Protocol + +# Protocol owned by the high-level module. +class OrderStore(Protocol): + def save_order(self, order) -> None: + ... + +class OrderProcessor: + def __init__(self, store: OrderStore) -> None: + self._store = store + + def process(self, order) -> None: + self._store.save_order(order) +``` + +## Object Calisthenics — One Level of Indentation + +### Before — nested loops and conditions in one function + +```python +def generate_report(orders: list) -> list[str]: + lines = [] + for order in orders: + if order.is_complete(): + for item in order.items: + if item.price > 100: + lines.append(f"{item.name}: {item.price}") + return lines +``` + +### After — three extracted helpers, each with one level + +```python +def complete_orders(orders: list) -> list: + return [order for order in orders if order.is_complete()] + +def expensive_items(order) -> list: + return [item for item in order.items if item.price > 100] + +def format_item(item) -> str: + return f"{item.name}: {item.price}" + +def generate_report(orders: list) -> list[str]: + return [ + format_item(item) + for order in complete_orders(orders) + for item in expensive_items(order) + ] +``` + +## Object Calisthenics — No Getters/Setters + +### Before — callers extract data and compute behavior externally + +```python +class Rectangle: + def __init__(self, width: int, height: int) -> None: + self._width = width + self._height = height + + def get_width(self) -> int: + return self._width + + def get_height(self) -> int: + return self._height + +rect = Rectangle(4, 6) +area = rect.get_width() * rect.get_height() +perimeter = 2 * (rect.get_width() + rect.get_height()) +``` + +### After — behavior lives inside the object + +```python +class Rectangle: + def __init__(self, width: int, height: int) -> None: + self._width = width + self._height = height + + def area(self) -> int: + return self._width * self._height + + def perimeter(self) -> int: + return 2 * (self._width + self._height) + + def is_square(self) -> bool: + return self._width == self._height + +rect = Rectangle(4, 6) +area = rect.area() +perimeter = rect.perimeter() +``` + +## Object Calisthenics — Don't Abbreviate + +### Before — cryptic names obscure intent + +```python +class OrdMgr: + def calc(self, o) -> int: + t = 0 + for i in o.itms: + t += i.p * i.q + return t + + def proc(self, o) -> None: + if self.calc(o) > 0: + o.confirm() +``` + +### After — names reveal meaning at a glance + +```python +class OrderManager: + def calculate_total(self, order) -> int: + return sum(item.price * item.quantity for item in order.items) + + def process_order(self, order) -> None: + if self.calculate_total(order) > 0: + order.confirm() +``` + +## Explaining Message + +### Before — inline expression hides intent + +```python +from datetime import datetime, timedelta + +class Subscription: + def __init__(self, start_date: datetime, duration_days: int) -> None: + self._start_date = start_date + self._duration_days = duration_days + + def is_expired(self) -> bool: + return datetime.now() > self._start_date + timedelta(days=self._duration_days) +``` + +### After — delegate to a named private method + +```python +from datetime import datetime, timedelta + +class Subscription: + def __init__(self, start_date: datetime, duration_days: int) -> None: + self._start_date = start_date + self._duration_days = duration_days + + def is_expired(self) -> bool: + return datetime.now() > self._expiration_date() + + def _expiration_date(self) -> datetime: + return self._start_date + timedelta(days=self._duration_days) +``` + +## What to Notice + +- Rich models and clear object responsibilities help keep knowledge close to the concept. +- Python can express the same object boundaries with very little ceremony. +- Protocols and duck typing both support role-based collaboration. +- Composition and message passing stay readable without large frameworks. +- Wrapping primitives and splitting responsibilities keep each class focused on one reason to change. +- SOLID principles, Object Calisthenics rules, and extracted explaining messages each reduce a different kind of coupling or noise. diff --git a/skills/oop-best-practices/references/ruby-examples.md b/skills/oop-best-practices/references/ruby-examples.md new file mode 100644 index 0000000..f96296e --- /dev/null +++ b/skills/oop-best-practices/references/ruby-examples.md @@ -0,0 +1,902 @@ +# Ruby Examples + +These examples cover the same core concepts as the other language-specific example files. + +## Concepts Covered + +- Value Objects and Invariants +- First-Class Collections +- Tell, Don't Ask +- Role-Based Collaboration +- Dependency Injection +- Explicit Interfaces +- Duck Typing / Protocol-Style Roles +- Composition over Inheritance +- Message-Based Design +- Law of Demeter Violation and Fix +- Immutable Objects +- Null Object +- Anemic versus Rich Model + +## Value Objects and Invariants + +```ruby +class Money + def initialize(cents) + raise ArgumentError, 'Money cannot be negative' if cents < 0 + + @cents = cents + end + + def add(other) + Money.new(@cents + other.value) + end + + def multiply_by(percent) + Money.new((@cents * percent / 100.0).round) + end + + def value + @cents + end +end +``` + +## First-Class Collections + +```ruby +class OrderLine + def initialize(subtotal_amount) + @subtotal_amount = subtotal_amount + end + + def subtotal + @subtotal_amount + end +end + +class OrderLines + def initialize(items) + @items = items + end + + def total + @items.reduce(Money.new(0)) { |total, item| total.add(item.subtotal) } + end + + def empty? + @items.empty? + end +end +``` + +## Tell, Don't Ask + +```ruby +class Address + def initialize(country_code) + @country_code = country_code + end + + def domestic? + @country_code == 'ES' + end +end + +class Shipment + def initialize(address) + @address = address + end + + def dispatch_window_in_days + @address.domestic? ? 2 : 5 + end +end +``` + +## Role-Based Collaboration + +```ruby +class OrderSummary + def initialize(formatter) + @formatter = formatter + end + + def total_label(lines) + @formatter.format(lines.total) + end +end +``` + +## Dependency Injection + +```ruby +class Invoice + def initialize(recipient, body_text) + @recipient = recipient + @body_text = body_text + end + + def recipient_email + @recipient + end + + def body + @body_text + end +end + +class InvoiceSender + def initialize(mailer) + @mailer = mailer + end + + def send(invoice) + @mailer.send(invoice.recipient_email, invoice.body) + end +end +``` + +## Explicit Interfaces + +```ruby +module PaymentGateway + def charge(_customer_id, _amount) + raise NotImplementedError + end +end + +class SubscriptionActivator + def initialize(payment_gateway) + @payment_gateway = payment_gateway + end + + def activate(customer_id, fee) + @payment_gateway.charge(customer_id, fee) + end +end +``` + +## Duck Typing / Protocol-Style Roles + +```ruby +class InventoryReport + def initialize(source) + @source = source + end + + def available? + @source.available_units > 0 + end +end + +class WarehouseBin + def initialize(units) + @units = units + end + + def available_units + @units + end +end +``` + +## Composition over Inheritance + +```ruby +class CartPricing + def initialize(discount_policy, tax_policy) + @discount_policy = discount_policy + @tax_policy = tax_policy + end + + def total(subtotal) + discounted = @discount_policy.apply(subtotal) + @tax_policy.apply(discounted) + end +end +``` + +## Message-Based Design + +```ruby +class Booking + def initialize(seats, amount, inventory, payments) + @seats = seats + @amount = amount + @inventory = inventory + @payments = payments + end + + def confirm + @inventory.reserve(@seats) + @payments.charge(@amount) + end +end +``` + +## Law of Demeter Violation and Fix + +### Before + +```ruby +class CustomerRecord + def initialize(address) + @address = address + end + + def shipping_address + @address + end +end + +class Order + def initialize(customer) + @customer = customer + end + + def customer_record + @customer + end +end + +domestic = order.customer_record.shipping_address.domestic? +``` + +### After + +```ruby +class Customer + def initialize(address) + @address = address + end + + def ships_domestically? + @address.domestic? + end +end + +class PurchaseOrder + def initialize(customer) + @customer = customer + end + + def ships_domestically? + @customer.ships_domestically? + end +end + +domestic = order.ships_domestically? +``` + +## Immutable Objects + +```ruby +class Rooms + def initialize(items) + @items = items.freeze + end + + def add(room) + Rooms.new(@items + [room]) + end + + def count + @items.length + end +end +``` + +## Null Object + +```ruby +class NullLogger + def info(_message) + end +end +``` + +## Anemic versus Rich Model + +### Anemic + +```ruby +class ScoreData + attr_accessor :value + + def initialize(value) + @value = value + end +end + +def increase_score(score, points) + score.value = score.value + points +end +``` + +### Rich + +```ruby +class Score + def initialize(value) + @value = value + end + + def increase(points) + Score.new(@value + points) + end + + def value + @value + end +end +``` + +## SOLID — Single Responsibility Violation and Fix + +### Before (Report does too much) + +```ruby +class Report + def initialize(title, body) + @title = title + @body = body + end + + def title + @title + end + + def body + @body + end + + def save_to_database(db_connection) + db_connection.execute( + 'INSERT INTO reports (title, body) VALUES (?, ?)', + @title, @body + ) + end +end +``` + +### After (split responsibilities) + +```ruby +class Report + attr_reader :title, :body + + def initialize(title, body) + @title = title.freeze + @body = body.freeze + end +end + +class ReportRepository + def initialize(db_connection) + @db = db_connection + end + + def save(report) + @db.execute( + 'INSERT INTO reports (title, body) VALUES (?, ?)', + report.title, report.body + ) + end +end +``` + +## Object Calisthenics — Wrap Primitive + +```ruby +class Percentage + def initialize(value) + raise ArgumentError, 'Percentage must be between 0 and 100' unless (0..100).include?(value) + + @value = value.freeze + end + + def of(amount) + (amount * @value / 100.0).round + end + + def value + @value + end +end + +class Price + def initialize(cents) + raise ArgumentError, 'Price cannot be negative' if cents < 0 + + @cents = cents.freeze + end + + def apply_discount(discount) + Price.new(@cents - discount.of(@cents)) + end + + def cents + @cents + end +end +``` + +## Object Calisthenics — No Else Rule + +### Before (nested if/else) + +```ruby +def shipping_cost(order) + if order.domestic? + if order.total_cents > 5000 + 0 + else + 500 + end + else + 1500 + end +end +``` + +### After (guard clauses, early return) + +```ruby +def shipping_cost(order) + return 1500 unless order.domestic? + return 0 if order.total_cents > 5000 + + 500 +end +``` + +## Dependency Direction + +### Before (hard-wired dependency) + +```ruby +class InvoiceExporter + def export(invoice, path) + fs = FileSystem.new + fs.write(path, invoice.to_csv) + end +end +``` + +### After (inject any collaborator that responds to `write`) + +```ruby +class InvoiceExporter + def initialize(storage) + @storage = storage # duck-typed: any object responding to #write(path, content) + end + + def export(invoice, path) + @storage.write(path, invoice.to_csv) + end +end +``` + +## Composed Method + +### Before (mixed concerns in one method) + +```ruby +class RegistrationService + def register(email, password) + raise ArgumentError, 'Email is required' if email.nil? || email.empty? + raise ArgumentError, 'Password too short' if password.length < 8 + + hashed = BCrypt::Password.create(password) + user = User.new(email: email, password_hash: hashed) + UserRepository.new.save(user) + Mailer.new.send_welcome(email) + user + end +end +``` + +### After (composed sequence of private methods) + +```ruby +class RegistrationService + def register(email, password) + validate(email, password) + user = build_user(email, password) + persist(user) + welcome(user) + user + end + + private + + def validate(email, password) + raise ArgumentError, 'Email is required' if email.nil? || email.empty? + raise ArgumentError, 'Password too short' if password.length < 8 + end + + def build_user(email, password) + hashed = BCrypt::Password.create(password) + User.new(email: email, password_hash: hashed) + end + + def persist(user) + @repository.save(user) + end + + def welcome(user) + @mailer.send_welcome(user.email) + end +end +``` + +## SOLID — Open/Closed Principle + +### Before (case on type string) + +```ruby +class ShippingCalculator + def cost(order) + case order.type + when 'standard' then 500 + when 'express' then 1200 + when 'overnight' then 2500 + else raise ArgumentError, "Unknown order type: #{order.type}" + end + end +end +``` + +### After (open to new policies, closed to modification) + +```ruby +class StandardShipping + def cost(_order) + 500 + end +end + +class ExpressShipping + def cost(_order) + 1200 + end +end + +class OvernightShipping + def cost(_order) + 2500 + end +end + +class ShippingCalculator + def initialize(policy) + @policy = policy + end + + def cost(order) + @policy.cost(order) + end +end +``` + +## SOLID — Liskov Substitution Principle + +### Before (LSP violation — subclass breaks the contract) + +```ruby +class Collection + def initialize + @items = [] + end + + def add(item) + @items << item + end + + def all + @items + end +end + +class ReadOnlyCollection < Collection + def add(_item) + raise 'Cannot modify a read-only collection' + end +end +``` + +### After (independent classes, no broken inheritance) + +```ruby +class MutableCollection + def initialize + @items = [] + end + + def add(item) + @items << item + end + + def all + @items + end +end + +class ReadOnlyCollection + def initialize(items) + @items = items.freeze + end + + def all + @items + end +end +``` + +## SOLID — Interface Segregation Principle + +### Before (fat module forces unrelated implementations) + +```ruby +module Worker + def work + raise NotImplementedError + end + + def eat + raise NotImplementedError + end + + def sleep + raise NotImplementedError + end +end + +class RobotWorker + include Worker + + def work + 'working' + end + + def eat + raise 'Robots do not eat' + end + + def sleep + raise 'Robots do not sleep' + end +end +``` + +### After (narrow modules included only where needed) + +```ruby +module Workable + def work + raise NotImplementedError + end +end + +module Eatable + def eat + raise NotImplementedError + end +end + +module Sleepable + def sleep + raise NotImplementedError + end +end + +class HumanWorker + include Workable + include Eatable + include Sleepable + + def work = 'working' + def eat = 'eating' + def sleep = 'sleeping' +end + +class RobotWorker + include Workable + + def work = 'working' +end +``` + +## SOLID — Dependency Inversion Principle + +### Before (depends on a concrete class) + +```ruby +class OrderProcessor + def process(order) + db = PostgresDatabase.new + db.save(order) + end +end +``` + +### After (depends on any object that responds to `save`) + +```ruby +class OrderProcessor + def initialize(storage) + @storage = storage # duck-typed: any object responding to #save(order) + end + + def process(order) + @storage.save(order) + end +end +``` + +## Object Calisthenics — One Level of Indentation + +### Before (nested loops and conditionals) + +```ruby +def generate_report(orders) + result = [] + orders.each do |order| + if order.complete? + order.items.each do |item| + if item.price > 100 + result << "#{item.name}: #{item.price}" + end + end + end + end + result +end +``` + +### After (extracted private methods, functional style) + +```ruby +def generate_report(orders) + complete_orders(orders) + .flat_map { |order| expensive_items(order.items) } + .map { |item| format_item(item) } +end + +private + +def complete_orders(orders) + orders.select(&:complete?) +end + +def expensive_items(items) + items.select { |item| item.price > 100 } +end + +def format_item(item) + "#{item.name}: #{item.price}" +end +``` + +## Object Calisthenics — No Getters/Setters + +### Before (callers compute behaviour from exposed data) + +```ruby +class Rectangle + attr_reader :width, :height + + def initialize(width, height) + @width = width + @height = height + end +end + +# Caller computes what the object should know: +area = rect.width * rect.height +perimeter = 2 * (rect.width + rect.height) +square = rect.width == rect.height +``` + +### After (behaviour lives on the object) + +```ruby +class Rectangle + def initialize(width, height) + @width = width + @height = height + end + + def area + @width * @height + end + + def perimeter + 2 * (@width + @height) + end + + def square? + @width == @height + end +end +``` + +## Object Calisthenics — Don't Abbreviate + +### Before (cryptic class and method names) + +```ruby +class OrdMgr + def calc(o) + o.items.sum(&:price) + end + + def proc(o) + calc(o) + o.mark_processed + end +end +``` + +### After (names reveal intent) + +```ruby +class OrderManager + def calculate_total(order) + order.items.sum(&:price) + end + + def process_order(order) + calculate_total(order) + order.mark_processed + end +end +``` + +## Explaining Message + +### Before (inline computation obscures intent) + +```ruby +class Subscription + def initialize(started_at, duration_days) + @started_at = started_at + @duration_days = duration_days + end + + def expired? + Time.now > @started_at + (@duration_days * 24 * 60 * 60) + end +end +``` + +### After (private method names the concept) + +```ruby +class Subscription + def initialize(started_at, duration_days) + @started_at = started_at + @duration_days = duration_days + end + + def expired? + Time.now > expiration_date + end + + private + + def expiration_date + @started_at + (@duration_days * 24 * 60 * 60) + end +end +``` + +## What to Notice + +- Rich models and clear object responsibilities help keep knowledge close to the concept. +- Ruby makes message-based design and duck typing feel natural. +- Explicit interfaces can still be made visible through narrow modules and roles. +- Composition and delegation stay easier to evolve than large inheritance hierarchies. +- Wrapping primitives and splitting responsibilities keep each class focused on one reason to change. +- SOLID principles, Object Calisthenics rules, and extracted explaining messages each reduce a different kind of coupling or noise. diff --git a/skills/oop-best-practices/references/rust-examples.md b/skills/oop-best-practices/references/rust-examples.md new file mode 100644 index 0000000..2fc239a --- /dev/null +++ b/skills/oop-best-practices/references/rust-examples.md @@ -0,0 +1,770 @@ +# Rust Examples + +Rust has no classes or inheritance. OOP concepts are expressed through structs + `impl` blocks, traits, and composition. Ownership makes immutability and encapsulation the natural defaults rather than discipline to enforce. + +## Concepts Covered + +- Value Objects and Invariants +- First-Class Collections +- Tell, Don't Ask +- Role-Based Collaboration +- Dependency Injection +- Explicit Interfaces (traits) +- Composition over Inheritance +- Message-Based Design +- Law of Demeter Violation and Fix +- Immutable Objects (ownership and `let`) +- Absence without null (Option) +- Anemic versus Rich Model +- SOLID — Single Responsibility +- SOLID — Open/Closed +- SOLID — Interface Segregation +- SOLID — Dependency Inversion +- Object Calisthenics — Wrap Primitive (Newtype) +- Object Calisthenics — No Else Rule +- Object Calisthenics — No Getters +- Object Calisthenics — Don't Abbreviate +- Composed Method +- Explaining Message + +--- + +## Value Objects and Invariants + +```rust +// Newtype pattern — a wrapper that enforces invariants +// Private field: external code cannot construct Money directly +pub struct Money { + cents: i64, +} + +impl Money { + pub fn new(cents: i64) -> Result { + if cents < 0 { + return Err("money cannot be negative"); + } + Ok(Self { cents }) + } + + pub fn add(&self, other: &Money) -> Money { + Money { cents: self.cents + other.cents } + } + + pub fn multiply_by_percent(&self, percent: u8) -> Money { + Money { cents: self.cents * percent as i64 / 100 } + } + + pub fn cents(&self) -> i64 { + self.cents + } +} +``` + +--- + +## First-Class Collections + +```rust +pub struct OrderLine { + subtotal: Money, +} + +impl OrderLine { + pub fn subtotal(&self) -> &Money { + &self.subtotal + } +} + +// The collection owns its invariants +pub struct OrderLines { + items: Vec, +} + +impl OrderLines { + pub fn total(&self) -> Money { + self.items + .iter() + .fold(Money::new(0).unwrap(), |acc, item| acc.add(item.subtotal())) + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} +``` + +--- + +## Tell, Don't Ask + +```rust +pub struct Address { + country_code: String, +} + +impl Address { + // Tell the address — don't ask for the string and decide outside + pub fn is_domestic(&self) -> bool { + self.country_code == "ES" + } +} + +pub struct Shipment { + address: Address, +} + +impl Shipment { + pub fn dispatch_window_in_days(&self) -> u32 { + if self.address.is_domestic() { 2 } else { 5 } + } +} +``` + +--- + +## Role-Based Collaboration + +```rust +// Trait = role — defines what behavior a collaborator must provide +pub trait CurrencyFormatter { + fn format(&self, amount: &Money) -> String; +} + +pub struct OrderSummary { + formatter: F, +} + +impl OrderSummary { + pub fn total_label(&self, lines: &OrderLines) -> String { + self.formatter.format(&lines.total()) + } +} +``` + +--- + +## Dependency Injection + +```rust +pub trait Mailer { + fn send(&self, to: &str, body: &str) -> Result<(), Box>; +} + +pub struct Invoice { + recipient: String, + body: String, +} + +impl Invoice { + pub fn recipient_email(&self) -> &str { &self.recipient } + pub fn body(&self) -> &str { &self.body } +} + +// Collaborator injected — not created inside the struct +pub struct InvoiceSender { + mailer: M, +} + +impl InvoiceSender { + pub fn new(mailer: M) -> Self { + Self { mailer } + } + + pub fn send(&self, invoice: &Invoice) -> Result<(), Box> { + self.mailer.send(invoice.recipient_email(), invoice.body()) + } +} +``` + +--- + +## Explicit Interfaces (Traits) + +```rust +pub trait PaymentGateway { + fn charge(&self, customer_id: &str, amount: &Money) -> Result<(), String>; +} + +pub struct SubscriptionActivator { + gateway: G, +} + +impl SubscriptionActivator { + pub fn activate(&self, customer_id: &str, fee: &Money) -> Result<(), String> { + self.gateway.charge(customer_id, fee) + } +} + +// StripeGateway satisfies PaymentGateway by implementing the trait +pub struct StripeGateway; + +impl PaymentGateway for StripeGateway { + fn charge(&self, _customer_id: &str, _amount: &Money) -> Result<(), String> { + Ok(()) // call Stripe API + } +} +``` + +--- + +## Composition over Inheritance + +Rust has no inheritance. Behavior is shared through traits and delegation. + +```rust +pub trait DiscountPolicy { + fn apply(&self, total: &Money) -> Money; +} + +pub trait TaxPolicy { + fn apply(&self, total: &Money) -> Money; +} + +pub struct CartPricing { + discount: D, + tax: T, +} + +impl CartPricing { + pub fn total(&self, subtotal: &Money) -> Money { + let discounted = self.discount.apply(subtotal); + self.tax.apply(&discounted) + } +} +``` + +--- + +## Message-Based Design + +```rust +pub trait SeatInventory { + fn reserve(&mut self, seat_count: u32) -> Result<(), String>; +} + +pub trait PaymentService { + fn charge(&self, amount: &Money) -> Result<(), String>; +} + +pub struct Booking { + seats: u32, + amount: Money, + inventory: I, + payments: P, +} + +impl Booking { + pub fn confirm(&mut self) -> Result<(), String> { + self.inventory.reserve(self.seats)?; + self.payments.charge(&self.amount) + } +} +``` + +--- + +## Law of Demeter Violation and Fix + +### Before + +```rust +// Caller traverses the chain — coupled to intermediate structure +let domestic = order.customer().shipping_address().is_domestic(); +``` + +### After + +```rust +pub struct Customer { + address: Address, +} + +impl Customer { + // Customer answers questions about itself + pub fn ships_domestically(&self) -> bool { + self.address.is_domestic() + } +} + +pub struct Order { + customer: Customer, +} + +impl Order { + // Order delegates — no chain traversal at the call site + pub fn ships_domestically(&self) -> bool { + self.customer.ships_domestically() + } +} + +let domestic = order.ships_domestically(); +``` + +--- + +## Immutable Objects (Ownership and `let`) + +```rust +// let is immutable by default — mutation requires `mut` +pub struct Rooms { + items: Vec, +} + +impl Rooms { + // Returns a new Rooms — original unchanged + pub fn add(&self, room: &str) -> Rooms { + let mut new_items = self.items.clone(); + new_items.push(room.to_string()); + Rooms { items: new_items } + } + + pub fn count(&self) -> usize { + self.items.len() + } +} +``` + +--- + +## Absence without Null (Option) + +```rust +// Rust has no null — Option = Some(T) | None +// The compiler forces callers to handle the missing case + +pub struct UserRepository { + users: std::collections::HashMap, +} + +impl UserRepository { + pub fn find(&self, id: &str) -> Option<&User> { + self.users.get(id) + } +} + +// Null Object equivalent — default implementation via Option methods +let name = repo.find("123") + .map(|u| u.name()) + .unwrap_or("Anonymous"); + +// Or pattern match explicitly +match repo.find("123") { + Some(user) => println!("Found: {}", user.name()), + None => println!("Not found"), +} +``` + +--- + +## Anemic versus Rich Model + +### Anemic + +```rust +pub struct ScoreData { + pub value: i32, // public mutable field — no protection +} + +// Logic lives outside +fn increase_score(score: &mut ScoreData, points: i32) { + score.value += points; +} +``` + +### Rich + +```rust +pub struct Score { + points: i32, // private — callers cannot bypass the rule +} + +impl Score { + pub fn new(points: i32) -> Self { + Self { points } + } + + // Returns a new Score — immutable increment + pub fn increase(&self, extra: i32) -> Score { + Score { points: self.points + extra } + } + + pub fn value(&self) -> i32 { + self.points + } +} +``` + +--- + +## SOLID — Single Responsibility + +### Before + +```rust +// Report formats and persists — two unrelated responsibilities +impl Report { + pub fn save(&self, conn: &mut PgConnection) -> QueryResult { + diesel::insert_into(reports::table) + .values((reports::title.eq(&self.title), reports::content.eq(&self.content))) + .execute(conn) + } +} +``` + +### After + +```rust +pub struct Report { + title: String, + content: String, +} + +impl Report { + pub fn title(&self) -> &str { &self.title } + pub fn content(&self) -> &str { &self.content } +} + +pub struct ReportRepository { + conn: PgConnection, +} + +impl ReportRepository { + pub fn save(&mut self, report: &Report) -> QueryResult { + diesel::insert_into(reports::table) + .values((reports::title.eq(report.title()), reports::content.eq(report.content()))) + .execute(&mut self.conn) + } +} +``` + +--- + +## SOLID — Open/Closed + +### Before + +```rust +fn shipping_cost(order_type: &str) -> u32 { + match order_type { + "standard" => 5, + "express" => 15, + "overnight" => 25, + _ => 0, + } +} +``` + +### After + +```rust +pub trait ShippingPolicy { + fn cost(&self) -> u32; +} + +pub struct StandardShipping; +pub struct ExpressShipping; +pub struct OvernightShipping; + +impl ShippingPolicy for StandardShipping { fn cost(&self) -> u32 { 5 } } +impl ShippingPolicy for ExpressShipping { fn cost(&self) -> u32 { 15 } } +impl ShippingPolicy for OvernightShipping { fn cost(&self) -> u32 { 25 } } + +// Adding a new variant does not touch this function +fn shipping_cost(policy: &dyn ShippingPolicy) -> u32 { + policy.cost() +} +``` + +--- + +## SOLID — Interface Segregation + +```rust +pub trait Workable { fn work(&self); } +pub trait Eatable { fn eat(&self); } +pub trait Sleepable{ fn sleep(&self); } + +pub struct HumanWorker; + +impl Workable for HumanWorker { fn work(&self) {} } +impl Eatable for HumanWorker { fn eat(&self) {} } +impl Sleepable for HumanWorker { fn sleep(&self) {} } + +pub struct Robot; + +// Robot only implements what it needs — compiler enforces it +impl Workable for Robot { fn work(&self) {} } +``` + +--- + +## SOLID — Dependency Inversion + +### Before + +```rust +use postgres::Client; + +pub struct OrderProcessor { + db: Client, // depends on concrete infrastructure +} + +impl OrderProcessor { + pub fn process(&mut self, order: &Order) -> Result<(), postgres::Error> { + self.db.execute("INSERT INTO orders ...", &[&order.id]) + .map(|_| ()) + } +} +``` + +### After + +```rust +// Interface owned by the domain +pub trait OrderStore { + fn save(&mut self, order: &Order) -> Result<(), Box>; +} + +pub struct OrderProcessor { + store: S, // depends on abstraction +} + +impl OrderProcessor { + pub fn new(store: S) -> Self { Self { store } } + + pub fn process(&mut self, order: &Order) -> Result<(), Box> { + self.store.save(order) + } +} + +pub struct PostgresOrderStore { /* db connection */ } + +impl OrderStore for PostgresOrderStore { + fn save(&mut self, order: &Order) -> Result<(), Box> { + // persist to Postgres + Ok(()) + } +} +``` + +--- + +## Object Calisthenics — Wrap Primitive (Newtype) + +### Before + +```rust +fn apply_discount(price_in_cents: i64, discount_percent: u8) -> i64 { + assert!(discount_percent <= 100, "invalid discount"); + price_in_cents - (price_in_cents * discount_percent as i64 / 100) +} +``` + +### After + +```rust +pub struct Percentage(u8); + +impl Percentage { + pub fn new(value: u8) -> Result { + if value > 100 { + return Err("percentage must be between 0 and 100"); + } + Ok(Self(value)) + } + + pub fn of(&self, amount: i64) -> i64 { + amount * self.0 as i64 / 100 + } +} + +pub struct Price(i64); + +impl Price { + pub fn apply_discount(&self, discount: &Percentage) -> Price { + Price(self.0 - discount.of(self.0)) + } + + pub fn value(&self) -> i64 { self.0 } +} +``` + +--- + +## Object Calisthenics — No Else Rule + +### Before + +```rust +fn shipping_cost(order: &Order) -> u32 { + if order.is_express() { + return 15; + } else { + if order.total_weight() > 10 { + return 8; + } else { + return 3; + } + } +} +``` + +### After + +```rust +fn shipping_cost(order: &Order) -> u32 { + if order.is_express() { return 15; } + if order.total_weight() > 10 { return 8; } + 3 +} +``` + +--- + +## Object Calisthenics — No Getters + +### Before + +```rust +pub struct Rectangle { + pub width: u32, + pub height: u32, +} + +let area = rect.width * rect.height; +let perimeter = 2 * (rect.width + rect.height); +``` + +### After + +```rust +pub struct Rectangle { + width: u32, + height: u32, +} + +impl Rectangle { + pub fn new(width: u32, height: u32) -> Self { Self { width, height } } + + pub fn area(&self) -> u32 { self.width * self.height } + pub fn perimeter(&self) -> u32 { 2 * (self.width + self.height) } + pub fn is_square(&self) -> bool{ self.width == self.height } +} +``` + +--- + +## Object Calisthenics — Don't Abbreviate + +### Before + +```rust +struct OrdMgr; + +impl OrdMgr { + fn calc(&self, o: &Order) -> i64 { + o.itms().iter().map(|i| i.prc()).sum() + } +} +``` + +### After + +```rust +struct OrderManager; + +impl OrderManager { + fn calculate_total(&self, order: &Order) -> i64 { + order.items().iter().map(|item| item.price()).sum() + } +} +``` + +--- + +## Composed Method + +### Before + +```rust +impl RegistrationService { + pub fn register(&self, email: &str, password: &str) -> Result<(), String> { + if !email.contains('@') { return Err("invalid email".into()); } + if password.len() < 8 { return Err("password too short".into()); } + let hashed = hash_password(password); + self.repo.save(&User::new(email, &hashed))?; + self.mailer.send(email, "Welcome!").map_err(|e| e.to_string()) + } +} +``` + +### After + +```rust +impl RegistrationService { + pub fn register(&self, email: &str, password: &str) -> Result<(), String> { + self.validate(email, password)?; + let user = self.build_user(email, password); + self.persist(&user)?; + self.welcome(&user) + } + + fn validate(&self, email: &str, password: &str) -> Result<(), String> { + if !email.contains('@') { return Err("invalid email".into()); } + if password.len() < 8 { return Err("password too short".into()); } + Ok(()) + } + + fn build_user(&self, email: &str, password: &str) -> User { + User::new(email, &hash_password(password)) + } + + fn persist(&self, user: &User) -> Result<(), String> { + self.repo.save(user) + } + + fn welcome(&self, user: &User) -> Result<(), String> { + self.mailer.send(user.email(), "Welcome!").map_err(|e| e.to_string()) + } +} +``` + +--- + +## Explaining Message + +### Before + +```rust +impl Subscription { + pub fn is_expired(&self) -> bool { + std::time::SystemTime::now() + > self.start_date + std::time::Duration::from_secs(self.duration_days * 86400) + } +} +``` + +### After + +```rust +impl Subscription { + pub fn is_expired(&self) -> bool { + std::time::SystemTime::now() > self.expiration_date() + } + + fn expiration_date(&self) -> std::time::SystemTime { + self.start_date + std::time::Duration::from_secs(self.duration_days * 86400) + } +} +``` + +--- + +## What to Notice + +- Rust has no classes — structs with private fields and `impl` blocks replace them. The constructor (`new`) is the single entry point that enforces invariants. +- Traits are the interface mechanism. They are explicit, named, and implemented intentionally — no accidental satisfaction. +- Ownership enforces immutability by default: `let` bindings cannot be reassigned; methods taking `&self` cannot mutate. +- There is no null. `Option` forces callers to handle absence at compile time — the Null Object pattern becomes `Option::unwrap_or_default()` or `Option::map`. +- There is no inheritance. Reuse is achieved through traits (shared behavior) and composition (explicit delegation). LSP applies at the trait level. +- The newtype pattern (`struct Percentage(u8)`) is the idiomatic way to wrap primitives and enforce domain invariants with zero runtime cost. diff --git a/skills/oop-best-practices/references/simple-design-rules.md b/skills/oop-best-practices/references/simple-design-rules.md new file mode 100644 index 0000000..28fbdde --- /dev/null +++ b/skills/oop-best-practices/references/simple-design-rules.md @@ -0,0 +1,96 @@ +# Four Rules of Simple Design + +Source: [CodelyTV Four Rules of Simple Design course](https://github.com/CodelyTV/four_rules_of_simple_design-course) + +Apply Kent Beck's rules in priority order. Higher rules constrain lower ones: + +1. Passes the tests +2. Reveals intention +3. No duplication +4. Fewest elements + +The repository presents rules 2 and 4 before duplication and adds test exercises afterward. Treat that as lesson organization, not a different priority order. Tests remain the safety constraint for every simplification. + +## Passes The Tests + +Use tests to preserve observable behavior while changing structure. Passing tests are necessary but not sufficient: a test can pass while asserting the wrong contract or replacing the collaborator whose real semantics contain the defect. + +- Drive the public operation and assert its result, persisted state, emitted event, or boundary response. +- Do not spy on private helpers. Renaming, extracting, or inlining a helper must not break a behavioral test. +- Reproduce a defect at the boundary where it manifests before fixing it. +- Add lower-level tests only when they protect a distinct contract. + +The course's email-update example is deliberately instructive: an interaction test verifies `save(updatedUser)` while the in-memory repository silently refuses replacement by a different object instance. The mock passes, but persisted state is still wrong. + +Use `tdd-best-practices` for test design and `refactoring-best-practices` for safe change sequencing. + +## Reveals Intention + +### Use domain vocabulary + +Prefer the language used by domain experts over generic technical terms. In the course, `block` becomes the domain term `ban`, with `isBanned` and `UserAlreadyBannedError` completing the same vocabulary. + +Do not stop at renaming one method. Align commands, predicates, errors, paths, and tests so the concept has one name. + +### Make state no richer than required + +Replace a generic numeric `status` with the smallest honest model. A boolean such as `isBanned` is appropriate while the relevant domain state is truly binary. If transitions, reasons, or more states become behaviorally relevant, use an explicit state model instead. + +### Encode absence semantics in the contract + +The course distinguishes optional lookup from required lookup: + +- `search` returns an optional value because absence is normal. +- `find` returns a value or raises a specific not-found error. + +Use these words only if they fit the codebase's conventions. The general rule is to make absence explicit and consistent through the name, return type, and error behavior. A repository may remain nullable while an application-level finder translates absence into a domain error. + +## No Duplication + +Remove duplicated knowledge, not merely similar text. + +### Literal duplication + +Repeated email validation at multiple entry points is one rule expressed repeatedly. Move it to one authoritative validator or Value Object when email validity is intrinsic to that value. + +### Structural duplication + +Invoice and order calculators in the course have the same loop, threshold discount, and tax shape. First determine whether they implement the same pricing policy. If they do, extract and name that policy. If they can evolve independently, keep them separate despite similar syntax. + +Do not default to a base class or trait. Inheritance can couple unrelated concepts and make coincidental similarity harder to undo. Prefer composition around a genuine shared decision. + +### Conceptual duplication + +Welcome email, SMS, and push workflows look alike, but each channel may have a different reason to change. Consolidate only the business decision that must remain synchronized; preserve channel-specific construction and delivery where they vary independently. + +Ask: "Would one policy change require all copies to change together?" If not, the resemblance may not be duplication. + +## Fewest Elements + +Apply YAGNI after clarity and duplication have been addressed. + +- Delete speculative states, fields, queries, counts, and deletion variants that serve no current behavior. +- Do not create mirror interfaces for passive records or one-method interfaces that merely rename an existing use case. +- Keep interfaces that establish dependency direction, isolate an external system, support substitutability, or define a stable client-owned port. +- Do not count implementations as the sole test. A repository can have one production adapter and still need an interface because the domain must not depend on infrastructure. +- Do not count a mock as proof that a production abstraction is useful; many languages can substitute collaborators without a dedicated interface. + +Before adding or deleting an element, identify the current job it performs. "Maybe later" is not a job, but an architectural boundary or public contract can be. + +## Working Sequence + +1. Establish green behavioral tests at the affected boundary. +2. Improve names and contracts until the intent is explicit. +3. Identify duplicated decisions and give each one an authoritative owner. +4. Remove elements that no longer contribute behavior, clarity, or a necessary boundary. +5. Run tests after each small move and reassess the four rules. + +## Course Counterexamples + +Do not copy every example as target architecture. Several folders intentionally contain defects or excessive abstractions, and most local READMEs are framework boilerplate rather than lesson guidance. + +- Do not generalize `search` and `find` into a universal naming law. +- Do not replace every status with a boolean. +- Do not merge independently evolving code because it looks alike. +- Do not remove every interface with one implementation. +- Do not accept passing mock tests as evidence that integration semantics work. diff --git a/skills/oop-best-practices/references/solid-principles.md b/skills/oop-best-practices/references/solid-principles.md new file mode 100644 index 0000000..fd2481e --- /dev/null +++ b/skills/oop-best-practices/references/solid-principles.md @@ -0,0 +1,137 @@ +# SOLID Principles + +Use this reference when reviewing class design, evaluating dependencies, or deciding how to extend behavior without breaking existing code. + +## Single Responsibility Principle (SRP) + +**Pressure it addresses:** Classes with multiple responsibilities become hard to reuse, because their responsibilities are entangled. A change needed for one reason can break code that depends on a different reason. + +**The rule:** Every class, method, and module should have exactly one reason to change. That reason comes from the domain, not from technical convenience. + +**How to identify the responsibility:** Try to describe the class in one sentence. If the description requires the word "and," the class likely has more than one responsibility. If it requires "or," the responsibilities are not even closely related. Ask each method as a question directed at the class — if a question sounds ridiculous, that behavior belongs elsewhere. + +**Warning signs:** +- A class handles both formatting and calculation (for example, invoice formatting mixed with invoice totals). +- Methods in the same class change for different business reasons — layout decisions versus pricing rules. +- A blank line or comment separates logically distinct phases inside a single method; those phases are candidates for extraction. +- The class is hard to reuse in isolation because pulling in one behavior forces you to accept all the others. +- Changing one feature consistently breaks tests for an unrelated feature. + +**Practical heuristics:** +- If you need only part of a class's behavior but cannot get at it without the rest, then the class has too many responsibilities. +- If a class is difficult to test in isolation, it is probably doing too much or depending on too many things. +- If multiple teams or stories touch the same class regularly for different reasons, split it. +- Delay the split until a second responsibility actually appears; premature separation creates complexity without benefit. + +--- + +## Open-Closed Principle (OCP) + +**Pressure it addresses:** Every modification to existing, working code is a risk. The goal is to add behavior by adding new code, not by editing code that already works and is already tested. + +**The rule:** A class or module should be open to extension and closed to modification. New behavior should be introducible by supplying new collaborators, not by editing the original source. + +**How it works in practice:** Composition and role-based injection are the primary tools. When a class depends on an abstraction (an interface or duck-typed role) rather than a concrete type, a new variant of behavior can be introduced by implementing that abstraction without touching the consuming class. The Strategy pattern is a direct application: swap the algorithm object, not the algorithm's host. + +**Warning signs:** +- Adding a new case requires editing a switch statement or an if/else chain in an existing class. +- Every new business variant forces a change to the same central class. +- A class constructor hard-codes the name of a collaborator it creates internally. +- The class cannot be tested with a substitute collaborator because creation is embedded. + +**Practical heuristics:** +- If you find yourself editing the same class every time a new variant of behavior arrives, that class is not closed. +- If a class instantiates its own collaborators directly, inject them instead; this opens the class to extension without touching it. +- Apply OCP selectively: over-engineering every class with extension hooks before any variant exists adds unnecessary complexity. Introduce the hook when a second variant actually appears, or when a plausible future variant is visible from domain knowledge. +- Frameworks and libraries are the strongest case for OCP — their users must be able to extend behavior without modifying the library source. + +--- + +## Liskov Substitution Principle (LSP) + +**Pressure it addresses:** Inheritance hierarchies break when a subclass cannot be used wherever the parent type is expected. Code that type-checks subtypes at runtime is a signal that the hierarchy is incoherent. + +**The rule:** Any subtype must be substitutable for its supertype. Code that operates on a reference of the parent type must work correctly when handed any subtype — without knowing which subtype it received. + +**Three rules that define substitutability:** +- **Signature rule:** Methods in the subtype must have the same signatures as the methods they override. A compiler enforces this in statically typed languages; in dynamic languages it must be maintained by discipline. +- **Method rule (behavioral):** A subtype must preserve the behavioral contract of the supertype. If a supertype's method increments a count every time it is called, the subtype's override must also increment the count. Postconditions cannot be weakened. This rule is semantic and cannot be enforced by a compiler. +- **Property rule:** Invariants of the supertype must remain invariants in the subtype. A set is not a valid subtype of a list if adding a duplicate element leaves the size unchanged, because the list invariant is that size always increases on addition. + +**Warning signs:** +- A method checks the runtime type of an argument and branches on it to call type-specific methods. This is a substitution violation in disguise. +- A subclass overrides a method and does less than the superclass promised, or raises an error that the superclass never raised. +- A subclass must call `super` in very precise places, and forgetting it silently produces wrong results. This indicates the hierarchy forces subclasses to know the superclass algorithm, a form of coupling that leads to violations. +- A test written against the parent type fails when handed a subtype instance. + +**Practical heuristics:** +- If substituting a subtype for the supertype requires callers to add special cases, the hierarchy is broken. +- If a subclass needs to override a method to make it a no-op or throw "not supported," then it is not a true subtype — use composition instead of inheritance. +- Prefer hook methods over requiring subclasses to call `super`. Hook methods let the superclass control the algorithm while the subclass contributes only its specialization. +- When designing types, find the hierarchy by refactoring: if two or three concrete types share behavior, explore whether a common supertype makes sense under LSP, not just whether it is convenient. + +--- + +## Interface Segregation Principle (ISP) + +**Pressure it addresses:** When a single interface groups methods needed by different clients, each client is forced to depend on methods it never calls. Changes driven by one client's needs ripple across all clients even when those changes are irrelevant to them. + +**The rule:** Interfaces should be minimal — small enough that every method in the interface is used by every consumer. If different clients use different subsets of an interface, split the interface into those subsets. + +**Warning signs:** +- A class implements an interface but leaves several methods as no-ops or stubs because it does not need them. +- A consumer imports or depends on an interface but only ever calls one or two of its methods. +- Updating an interface to satisfy one client forces recompilation or redeployment of modules that do not use the changed method. +- The same fat interface is shared across microservices or independently deployed units — a single change forces a cascade of unrelated redeployments. + +**Practical heuristics:** +- If you can describe a client's use of an interface with a narrow role name (a "Printable," a "Persistable," a "Notifiable"), that role should likely be its own interface. +- If adding a method to an interface requires touching every implementor, ask whether the method truly belongs with the existing contract or if it represents a separate concern. +- ISP is a specific application of SRP applied to the interface boundary rather than the class body. +- In languages with duck typing or structural typing, ISP is enforced naturally: depend only on the messages you actually send, not on the full class. + +--- + +## Dependency Inversion Principle (DIP) + +**Pressure it addresses:** High-level business logic should not be hostage to low-level infrastructure details. If a service class hard-codes a database driver, the business rules cannot be tested, reused, or replaced without dragging in the infrastructure. + +**The rule:** High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions. "High level" means closer to the business domain; "low level" means closer to infrastructure, I/O, or hardware. + +**How inversion works:** The consuming class declares what it needs (an abstraction — interface or duck-typed role). A third party — a factory, a container, or the composition root — decides which concrete implementation to supply. The direction of the dependency arrow reverses: the concrete implementation depends on the abstraction, not the other way around. + +**Warning signs:** +- A business-layer class imports or instantiates a database class, a mailer class, or an HTTP client directly. +- Changing the database or messaging system requires editing classes that have nothing to do with infrastructure. +- A class cannot be unit-tested without spinning up a real database or network connection. +- The same class name appears in many places throughout the codebase as a direct reference rather than through a shared abstraction. + +**Practical heuristics:** +- If a class calls `new` on a collaborator inside its own methods or constructor, inject the collaborator instead. +- Prefer depending on a narrow role (a "Repository," a "Notifier," a "Logger") over depending on a specific class. +- The fewer concrete class names a module knows, the more loosely coupled it is. +- Dependency injection is the mechanism; DIP is the design principle behind it. The injection point — constructor, method parameter, or setter — makes the abstraction explicit. +- Depend on things that change less often than you do. Abstractions defined in terms of your domain change less often than concrete infrastructure classes. + +--- + +## Warning Signs Across All Five Principles + +- A class cannot be described in one sentence without "and" or "or." +- Changing one thing breaks something unrelated. +- Adding a new variant requires editing code in multiple existing classes. +- instanceof / type checks appear in business logic. +- A class cannot be tested without setting up infrastructure. +- Interfaces with methods that some implementors leave unimplemented. +- Hard-coded class names in business logic that should be injection points. +- Subclass behavior surprises callers who thought they were talking to the parent type. + +--- + +## How the Principles Work Together + +SRP produces cohesive classes — each class handles one concern. This makes the boundary of each class clear, which makes it easier to define a stable interface around it (ISP). A stable, narrow interface is what OCP depends on: you can extend behavior by swapping implementations of that interface without touching the class that consumes it. LSP ensures that those swapped implementations are genuinely interchangeable — callers never need to know which concrete type they received. DIP ties the system together: high-level classes declare dependencies on the abstractions produced by ISP, and composition roots wire in the concrete implementations that satisfy LSP. + +Violations compound each other. A class with multiple responsibilities (SRP violation) tends to accumulate a wide interface (ISP violation). That wide interface makes it hard to swap implementations (OCP limitation). When inheritance is used to share the bloated behavior, subtypes often cannot fully honor the contract (LSP violation). And because the class grew fat by pulling in concrete collaborators, it is now coupled to infrastructure (DIP violation). + +Following any one principle consistently creates pressure to follow the others. The principles are heuristics for the same underlying goal: keep each piece of code responsible for one thing, depend on stable abstractions, and make new behavior additive rather than destructive. diff --git a/skills/oop-best-practices/references/typescript-examples.md b/skills/oop-best-practices/references/typescript-examples.md new file mode 100644 index 0000000..2304e98 --- /dev/null +++ b/skills/oop-best-practices/references/typescript-examples.md @@ -0,0 +1,843 @@ +# TypeScript Examples + +These examples cover the same core concepts as the other language-specific example files. + +## Concepts Covered + +- Value Objects and Invariants +- First-Class Collections +- Tell, Don't Ask +- Role-Based Collaboration +- Dependency Injection +- Explicit Interfaces +- Duck Typing / Protocol-Style Roles +- Composition over Inheritance +- Message-Based Design +- Law of Demeter Violation and Fix +- Immutable Objects +- Null Object +- Anemic versus Rich Model + +## Value Objects and Invariants + +```typescript +class Money { + constructor(private readonly cents: number) { + if (cents < 0) { + throw new Error('Money cannot be negative') + } + } + + add(other: Money): Money { + return new Money(this.cents + other.cents) + } + + multiplyBy(percent: number): Money { + return new Money(Math.round(this.cents * percent / 100)) + } + + value(): number { + return this.cents + } +} +``` + +## First-Class Collections + +```typescript +class OrderLine { + constructor(private readonly subtotalAmount: Money) {} + + subtotal(): Money { + return this.subtotalAmount + } +} + +class OrderLines { + constructor(private readonly items: OrderLine[]) {} + + total(): Money { + return this.items.reduce( + (current, item) => current.add(item.subtotal()), + new Money(0), + ) + } + + isEmpty(): boolean { + return this.items.length === 0 + } +} +``` + +## Tell, Don't Ask + +```typescript +class Address { + constructor(private readonly countryCodeValue: string) {} + + isDomestic(): boolean { + return this.countryCodeValue === 'ES' + } +} + +class Shipment { + constructor(private readonly address: Address) {} + + dispatchWindowInDays(): number { + return this.address.isDomestic() ? 2 : 5 + } +} +``` + +## Role-Based Collaboration + +```typescript +interface CurrencyFormatter { + format(amount: Money): string +} + +class OrderSummary { + constructor(private readonly formatter: CurrencyFormatter) {} + + totalLabel(lines: OrderLines): string { + return this.formatter.format(lines.total()) + } +} +``` + +## Dependency Injection + +```typescript +interface Mailer { + send(to: string, body: string): void +} + +class Invoice { + constructor( + private readonly recipient: string, + private readonly bodyText: string, + ) {} + + recipientEmail(): string { + return this.recipient + } + + body(): string { + return this.bodyText + } +} + +class InvoiceSender { + constructor(private readonly mailer: Mailer) {} + + send(invoice: Invoice): void { + this.mailer.send(invoice.recipientEmail(), invoice.body()) + } +} +``` + +## Explicit Interfaces + +```typescript +interface PaymentGateway { + charge(customerId: string, amount: Money): void +} + +class SubscriptionActivator { + constructor(private readonly paymentGateway: PaymentGateway) {} + + activate(customerId: string, fee: Money): void { + this.paymentGateway.charge(customerId, fee) + } +} +``` + +## Duck Typing / Protocol-Style Roles + +```typescript +type StockSource = { + availableUnits(): number +} + +class InventoryReport { + constructor(private readonly source: StockSource) {} + + isAvailable(): boolean { + return this.source.availableUnits() > 0 + } +} + +class WarehouseBin { + constructor(private readonly units: number) {} + + availableUnits(): number { + return this.units + } +} +``` + +## Composition over Inheritance + +```typescript +interface DiscountPolicy { + apply(total: Money): Money +} + +interface TaxPolicy { + apply(total: Money): Money +} + +class CartPricing { + constructor( + private readonly discountPolicy: DiscountPolicy, + private readonly taxPolicy: TaxPolicy, + ) {} + + total(subtotal: Money): Money { + return this.taxPolicy.apply(this.discountPolicy.apply(subtotal)) + } +} +``` + +## Message-Based Design + +```typescript +interface SeatInventory { + reserve(seatCount: number): void +} + +interface PaymentService { + charge(amount: Money): void +} + +class Booking { + constructor( + private readonly seats: number, + private readonly amount: Money, + private readonly inventory: SeatInventory, + private readonly payments: PaymentService, + ) {} + + confirm(): void { + this.inventory.reserve(this.seats) + this.payments.charge(this.amount) + } +} +``` + +## Law of Demeter Violation and Fix + +### Before + +```typescript +class CustomerRecord { + constructor(private readonly address: Address) {} + + shippingAddress(): Address { + return this.address + } +} + +class Order { + constructor(private readonly customer: CustomerRecord) {} + + customerRecord(): CustomerRecord { + return this.customer + } +} + +const domestic = order.customerRecord().shippingAddress().isDomestic() +``` + +### After + +```typescript +class Customer { + constructor(private readonly address: Address) {} + + shipsDomestically(): boolean { + return this.address.isDomestic() + } +} + +class PurchaseOrder { + constructor(private readonly customer: Customer) {} + + shipsDomestically(): boolean { + return this.customer.shipsDomestically() + } +} + +const domestic = order.shipsDomestically() +``` + +## Immutable Objects + +```typescript +class Rooms { + constructor(private readonly items: readonly string[]) {} + + add(room: string): Rooms { + return new Rooms([...this.items, room]) + } + + count(): number { + return this.items.length + } +} +``` + +## Null Object + +```typescript +interface Logger { + info(message: string): void +} + +class NullLogger implements Logger { + info(_message: string): void {} +} +``` + +## Anemic versus Rich Model + +### Anemic + +```typescript +class ScoreData { + constructor(public value: number) {} +} + +function increaseScore(score: ScoreData, points: number): void { + score.value = score.value + points +} +``` + +### Rich + +```typescript +class Score { + constructor(private readonly points: number) {} + + increase(extraPoints: number): Score { + return new Score(this.points + extraPoints) + } + + value(): number { + return this.points + } +} +``` + +## SOLID — Single Responsibility Violation and Fix + +### Before + +```typescript +class Report { + constructor( + private readonly reportTitle: string, + private readonly content: string, + ) {} + + title(): string { + return this.reportTitle + } + + save(): void { + // writing to database — second unrelated responsibility + database.insert('reports', { title: this.reportTitle, content: this.content }) + } +} +``` + +### After + +```typescript +class Report { + constructor( + private readonly reportTitle: string, + private readonly content: string, + ) {} + + title(): string { + return this.reportTitle + } + + body(): string { + return this.content + } +} + +class ReportRepository { + save(report: Report): void { + database.insert('reports', { title: report.title(), content: report.body() }) + } +} +``` + +## Object Calisthenics — Wrap Primitive + +### Before + +```typescript +function applyDiscount(priceInCents: number, discountPercent: number): number { + if (discountPercent < 0 || discountPercent > 100) { + throw new Error('Invalid discount') + } + return Math.round(priceInCents * (1 - discountPercent / 100)) +} +``` + +### After + +```typescript +class Percentage { + constructor(private readonly value: number) { + if (value < 0 || value > 100) { + throw new Error('Percentage must be between 0 and 100') + } + } + + of(amount: number): number { + return Math.round(amount * (this.value / 100)) + } +} + +class Price { + constructor(private readonly cents: number) {} + + applyDiscount(discount: Percentage): Price { + return new Price(this.cents - discount.of(this.cents)) + } + + value(): number { + return this.cents + } +} +``` + +## Object Calisthenics — No Else Rule + +### Before + +```typescript +function shippingCost(order: Order): number { + if (order.isExpress()) { + return 15 + } else { + if (order.totalWeight() > 10) { + return 8 + } else { + return 3 + } + } +} +``` + +### After + +```typescript +function shippingCost(order: Order): number { + if (order.isExpress()) return 15 + if (order.totalWeight() > 10) return 8 + return 3 +} +``` + +## Dependency Direction + +### Before + +```typescript +class InvoiceExporter { + export(invoice: Invoice): void { + const fs = new FileSystem() + fs.write(`invoices/${invoice.id()}.txt`, invoice.body()) + } +} +``` + +### After + +```typescript +interface DocumentStorage { + write(path: string, content: string): void +} + +class InvoiceExporter { + constructor(private readonly storage: DocumentStorage) {} + + export(invoice: Invoice): void { + this.storage.write(`invoices/${invoice.id()}.txt`, invoice.body()) + } +} +``` + +## Composed Method + +### Before + +```typescript +class RegistrationService { + register(email: string, password: string): void { + if (!email.includes('@')) throw new Error('Invalid email') + if (password.length < 8) throw new Error('Password too short') + const hashed = hashPassword(password) + this.userRepository.save(new User(email, hashed)) + this.mailer.send(email, 'Welcome!') + } +} +``` + +### After + +```typescript +class RegistrationService { + register(email: string, password: string): void { + this.validate(email, password) + const user = this.buildUser(email, password) + this.persist(user) + this.welcome(user) + } + + private validate(email: string, password: string): void { + if (!email.includes('@')) throw new Error('Invalid email') + if (password.length < 8) throw new Error('Password too short') + } + + private buildUser(email: string, password: string): User { + return new User(email, hashPassword(password)) + } + + private persist(user: User): void { + this.userRepository.save(user) + } + + private welcome(user: User): void { + this.mailer.send(user.email(), 'Welcome!') + } +} +``` + +## SOLID — Open/Closed Principle + +### Before + +```typescript +class ShippingCalculator { + cost(order: Order): number { + if (order.type() === 'standard') return 5 + if (order.type() === 'express') return 15 + if (order.type() === 'overnight') return 25 + throw new Error('Unknown shipping type') + } +} +``` + +### After + +```typescript +interface ShippingPolicy { + cost(): number +} + +class StandardShipping implements ShippingPolicy { + cost(): number { return 5 } +} + +class ExpressShipping implements ShippingPolicy { + cost(): number { return 15 } +} + +class OvernightShipping implements ShippingPolicy { + cost(): number { return 25 } +} + +class ShippingCalculator { + cost(policy: ShippingPolicy): number { + return policy.cost() + } +} +``` + +## SOLID — Liskov Substitution Principle + +### Before + +```typescript +class Collection { + protected readonly items: string[] = [] + + add(item: string): void { + this.items.push(item) + } + + all(): string[] { + return this.items + } +} + +class ReadOnlyCollection extends Collection { + add(_item: string): void { + throw new Error('Collection is read only') // violates LSP + } +} +``` + +### After + +```typescript +class MutableCollection { + private readonly items: string[] = [] + + add(item: string): void { + this.items.push(item) + } + + all(): readonly string[] { + return this.items + } +} + +class ReadOnlyCollection { + constructor(private readonly items: readonly string[]) {} + + all(): readonly string[] { + return this.items + } +} +``` + +## SOLID — Interface Segregation Principle + +### Before + +```typescript +interface Worker { + work(): void + eat(): void + sleep(): void +} + +class RobotWorker implements Worker { + work(): void { /* ... */ } + eat(): void { throw new Error('Robots do not eat') } + sleep(): void { throw new Error('Robots do not sleep') } +} +``` + +### After + +```typescript +interface Workable { + work(): void +} + +interface Eatable { + eat(): void +} + +interface Sleepable { + sleep(): void +} + +class HumanWorker implements Workable, Eatable, Sleepable { + work(): void { /* ... */ } + eat(): void { /* ... */ } + sleep(): void { /* ... */ } +} + +class RobotWorker implements Workable { + work(): void { /* ... */ } +} +``` + +## SOLID — Dependency Inversion Principle + +### Before + +```typescript +class PostgresDatabase { + persist(data: object): void { /* ... */ } +} + +class OrderProcessor { + private readonly db = new PostgresDatabase() + + process(order: Order): void { + this.db.persist(order) + } +} +``` + +### After + +```typescript +// Interface owned by the high-level module, not the low-level one +interface OrderStore { + save(order: Order): void +} + +class OrderProcessor { + constructor(private readonly store: OrderStore) {} + + process(order: Order): void { + this.store.save(order) + } +} + +class PostgresOrderStore implements OrderStore { + save(order: Order): void { /* ... */ } +} +``` + +## Object Calisthenics — One Level of Indentation + +### Before + +```typescript +function generateReport(orders: Order[]): string { + let result = '' + for (const order of orders) { + if (order.isComplete()) { + for (const item of order.items()) { + if (item.price().value() > 100) { + result += `${item.name()}: ${item.price().value()}\n` + } + } + } + } + return result +} +``` + +### After + +```typescript +function generateReport(orders: Order[]): string { + return completeOrders(orders) + .flatMap(expensiveItems) + .map(formatItem) + .join('\n') +} + +function completeOrders(orders: Order[]): Order[] { + return orders.filter(o => o.isComplete()) +} + +function expensiveItems(order: Order): OrderItem[] { + return order.items().filter(i => i.price().value() > 100) +} + +function formatItem(item: OrderItem): string { + return `${item.name()}: ${item.price().value()}` +} +``` + +## Object Calisthenics — No Getters/Setters + +### Before + +```typescript +class Rectangle { + constructor(private width: number, private height: number) {} + + getWidth(): number { return this.width } + getHeight(): number { return this.height } +} + +const area = rect.getWidth() * rect.getHeight() +const perimeter = 2 * (rect.getWidth() + rect.getHeight()) +``` + +### After + +```typescript +class Rectangle { + constructor( + private readonly width: number, + private readonly height: number, + ) {} + + area(): number { + return this.width * this.height + } + + perimeter(): number { + return 2 * (this.width + this.height) + } + + isSquare(): boolean { + return this.width === this.height + } +} +``` + +## Object Calisthenics — Don't Abbreviate + +### Before + +```typescript +class OrdMgr { + calc(o: Order): number { + return o.itms().reduce((s, i) => s + i.prc(), 0) + } + + proc(o: Order): void { + const amt = this.calc(o) + this.pymt.chg(o.cstmrId(), amt) + } +} +``` + +### After + +```typescript +class OrderManager { + calculateTotal(order: Order): number { + return order.items().reduce((sum, item) => sum + item.price(), 0) + } + + processOrder(order: Order): void { + const amount = this.calculateTotal(order) + this.paymentService.charge(order.customerId(), amount) + } +} +``` + +## Explaining Message + +### Before + +```typescript +class Subscription { + isExpired(): boolean { + return new Date() > new Date(this.startDate.getTime() + this.durationDays * 86400000) + } +} +``` + +### After + +```typescript +class Subscription { + isExpired(): boolean { + return new Date() > this.expirationDate() + } + + private expirationDate(): Date { + return new Date(this.startDate.getTime() + this.durationDays * 86400000) + } +} +``` + +## What to Notice + +- Rich models and clear object responsibilities help keep knowledge close to the concept. +- The same concepts stay recognizable even when the syntax changes. +- Small interfaces and injected collaborators keep dependencies explicit. +- Structural typing lets TypeScript model protocol-style roles without forcing inheritance. +- Composition and message passing keep change local. +- Wrapping primitives and splitting responsibilities keep each class focused on one reason to change. +- SOLID principles, Object Calisthenics rules, and extracted explaining messages each reduce a different kind of coupling or noise. diff --git a/skills/oop-best-practices/references/value-objects-advanced.md b/skills/oop-best-practices/references/value-objects-advanced.md new file mode 100644 index 0000000..347e48e --- /dev/null +++ b/skills/oop-best-practices/references/value-objects-advanced.md @@ -0,0 +1,355 @@ +# Value Objects: Design, Implementation, and Evolution + +Source: corrected synthesis of [CodelyTV/value_objects-course](https://github.com/CodelyTV/value_objects-course), with DDD, language, and production-safety caveats. + +Use this as the canonical detailed Value Object reference. The course is valuable as a progression of refactorings, but several snapshots are intentionally intermediate or technically unsafe. Follow the contracts below rather than copying its implementations verbatim. + +## Core Contract + +A Value Object represents a domain concept whose identity does not matter. Two instances are interchangeable when all semantically defining values are equal. + +A robust Value Object should provide: + +- **Value semantics:** equality depends on meaning, not allocation identity. +- **Complete construction:** every public construction path returns a valid value or fails explicitly. +- **Immutable observation:** callers cannot change the value through aliases or exposed internals. +- **Cohesive behavior:** parsing, normalization, comparison, formatting, or operations live with the value when they use its knowledge. +- **Stable representation:** persistence and transport mappings preserve meaning and absence without leaking mutable domain internals. + +Make Value Objects immutable from creation. If measured performance requires mutable storage, model it as an exclusively owned implementation detail rather than exposing a mutable Value Object contract. + +## When to Introduce One + +Use a Value Object when one or more signals are present: + +- The term exists in the Ubiquitous Language: `EmailAddress`, `Money`, `DateRange`, `CourseId`. +- The value has intrinsic invariants, normalization, comparison, formatting, or operations. +- Two same-typed primitives can be swapped accidentally, such as `UserId` and `CourseId`. +- Validation or interpretation is duplicated across callers. +- Several attributes form one conceptual whole. +- An API becomes clearer by asking for a domain value instead of raw representation details. + +A Value Object can be useful even without validation. Semantic type safety and intention-revealing APIs may justify `UserId` over `string`. + +Do not introduce one merely because: + +- every primitive must be wrapped +- a DTO groups transport fields but has no domain meaning +- unrelated parameters often travel together +- a type alias, enum, branded scalar, record, or discriminated union already provides sufficient guarantees +- the rule depends on current user, time, tenant, repository state, or workflow rather than the value itself +- the abstraction is speculative and has no stable name or behavior + +## Value Object vs. Related Types + +| Type | Distinguishing question | +|---|---| +| Entity | Must two instances with equal attributes still be distinguished over time? | +| DTO | Is the shape primarily for transport between boundaries? | +| Parameter Object | Are fields grouped for call convenience without shared domain semantics? | +| Branded scalar | Is compile-time distinction enough, with no runtime behavior or validation? | +| Enum or sum type | Is the concept only a closed set of alternatives? | +| First-class collection | Does the collection own rules, regardless of whether the collection itself has value semantics? | + +The same concept can be an Entity in one Bounded Context and a Value Object in another. Let domain meaning decide, not the class shape. + +## Put Only Intrinsic Rules Inside + +Keep rules in the narrowest concept that owns them: + +| Rule | Owner | +|---|---| +| Parseable calendar date, rating from 0 to 5 | Value Object | +| Rule involving Aggregate state | Aggregate Root | +| Rule requiring an explicit policy, tenant, role, or current date | Aggregate or named policy/service | +| Existence checks and workflow orchestration | Application Service | +| Global uniqueness and concurrent allocation | Persistence constraint plus application handling | + +Do not hide ambient context in a Value Object constructor. A `BirthDate` can guarantee a real calendar date. Whether a person is old enough must use an explicit reference date and policy; otherwise loading the same stored date can change behavior as the clock advances. + +Use `ddd-best-practices` when deciding whether a rule belongs to a Value Object, Aggregate, policy, application service, or persistence constraint. + +## Construction, Parsing, and Normalization + +Enforce every intrinsic invariant through one canonical implementation shared by all public construction paths. The public API may use: + +- a constructor that throws for programmer-oriented domain construction +- a named factory such as `EmailAddress.create(...)` +- `parse(...)` or `tryParse(...)` for untrusted text +- `Result` when invalid input is expected control flow + +Normalize before validating when canonical representation is part of the concept: + +```typescript +class EmailAddress { + private constructor(private readonly canonical: string) {} + + static parse(raw: string): Result { + const trimmed = raw.trim(); + const separator = trimmed.lastIndexOf("@"); + const canonical = separator < 0 + ? trimmed + : `${trimmed.slice(0, separator)}@${trimmed.slice(separator + 1).toLowerCase()}`; + if (!isSyntacticallyValidEmail(canonical)) { + return Result.err(new InvalidEmailAddress(raw)); + } + return Result.ok(new EmailAddress(canonical)); + } + + equals(other: EmailAddress): boolean { + return this.canonical === other.canonical; + } +} +``` + +This example normalizes only the domain part. Lowercasing the local part is a bounded-context/provider policy, not universally safe SMTP behavior. Decide explicitly whether any normalization changes meaning. Email provider allowlists, tenant policies, and global uniqueness are not email syntax and should not be hidden in a generic `EmailAddress`. + +Reject invalid runtime representations even in typed code: + +- `NaN`, positive/negative infinity, and overflow for numbers +- `Invalid Date` (`!Number.isFinite(date.getTime())`) for JavaScript dates +- malformed Unicode or unsupported normalization where relevant +- impossible ranges such as end before start + +TypeScript types disappear at runtime. Parsing untrusted values still requires runtime checks. + +## Equality and Hashing + +Define equality from all and only the attributes that determine the value. + +An equality implementation must be: + +- reflexive: `a == a` +- symmetric: `a == b` implies `b == a` +- transitive: `a == b` and `b == c` imply `a == c` +- stable while the values are observable +- consistent with hashing: equal values produce equal hashes + +Use semantic comparison for each component: + +- strings after the concept's chosen normalization +- dates by epoch or canonical date-only representation, not object reference +- decimals by exact representation and scale policy +- composite values by recursively comparing their defining components +- collections according to domain order: sequence equality, set equality, or multiset equality + +Do not use JavaScript `===` for separately allocated `Date`, array, or object values. Do not use `constructor.name` as a domain type discriminator; minification and bundling can change it. Prefer explicit per-type equality or a base class deliberately restricted to safe scalar representations. + +Languages with hash-based collections require the matching hash contract (`hashCode`, `GetHashCode`, `__hash__`, etc.). Never override equality without reviewing hashing. + +## Deep Immutability and Aliasing + +`readonly`, `final`, and a read-only interface can still hold mutable objects. Protect invariants at both input and output boundaries: + +- Store immutable scalars where practical, such as epoch milliseconds or a date-only string. +- Defensively copy arrays, dates, maps, sets, buffers, and mutable nested objects. +- Keep collections private and expose iterators, immutable snapshots, or domain queries. +- Freeze/copy nested data where runtime immutability matters; shallow freeze is not deep freeze. +- Return a new value from transformation operations rather than mutating the receiver. + +```typescript +class DateRange { + private readonly startMs: number; + private readonly endMs: number; + + constructor(start: Date, end: Date) { + const startMs = start.getTime(); + const endMs = end.getTime(); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) { + throw new InvalidDateRange(); + } + this.startMs = startMs; + this.endMs = endMs; + } + + start(): Date { + return new Date(this.startMs); + } + + equals(other: DateRange): boolean { + return this.startMs === other.startMs && this.endMs === other.endMs; + } +} +``` + +The defensive copy returned by `start()` prevents callers from mutating internal state with `setDate()`. + +## Behavior and Domain Algebra + +Prefer asking the value for meaningful behavior over extracting primitives and deciding elsewhere: + +```typescript +const total = subtotal.add(tax); +if (period.overlaps(existingPeriod)) { /* ... */ } +const normalized = phoneNumber.inInternationalFormat(); +``` + +For operations, define: + +- compatibility rules, such as matching currencies or units +- precision and rounding policy +- overflow behavior +- ordering and comparison semantics +- whether the operation is closed over the type (`Money + Money -> Money`) + +Do not assume money can never be negative; debts, refunds, and adjustments may require signed values. Avoid binary floating point for exact decimal money unless the domain accepts its error model. + +## Composite Values + +A Value Object may contain several fields or other Value Objects when they form one value: + +```typescript +class Address { + constructor( + readonly street: Street, + readonly city: City, + readonly postalCode: PostalCode, + readonly country: CountryCode, + ) {} + + equals(other: Address): boolean { + return this.street.equals(other.street) + && this.city.equals(other.city) + && this.postalCode.equals(other.postalCode) + && this.country.equals(other.country); + } +} +``` + +Composition does not remove the need for deep immutability or explicit equality. If the object has a lifecycle and identity independent of these attributes, it is an Entity instead. + +## First-Class Collections + +Introduce a first-class collection when membership, uniqueness, ordering, overlap, cardinality, or aggregation has domain meaning. + +```typescript +class JobExperiences { + private readonly items: ReadonlyArray; + + private constructor(items: ReadonlyArray) { + this.items = Object.freeze([...items]); + } + + static create(items: ReadonlyArray): JobExperiences { + ensureNoOverlappingRanges(items); + return new JobExperiences(items); + } + + add(experience: JobExperience): JobExperiences { + return JobExperiences.create([...this.items, experience]); + } +} +``` + +Validate every construction path, including initial factories and update operations. Define open-ended range behavior rather than skipping validation. Require immutable `JobExperience` members; if members are mutable Entities, store immutable snapshots or keep the collection exclusively behind its Aggregate Root. + +A first-class collection is not automatically a Value Object. A collection of mutable Entities may be Aggregate-owned state or an immutable snapshot. Use Entity identity for Entity membership and value equality for Value Object membership. Do not expose mutable child Entities from an Aggregate merely because the array is read-only. + +## Optional Values + +Choose the least powerful representation that preserves meaning: + +| Representation | Use when | +|---|---| +| `T | null` | Absence is simple and one meaning is sufficient | +| `Option` / `Maybe` | Repeated composition should force explicit handling | +| Tagged union | Missing, unknown, not applicable, or withheld are distinct states | +| Null Object | Absence has genuinely neutral, substitutable behavior under the same protocol | + +A real Option distinguishes only nullish absence, not falsiness: + +```typescript +type Option = + | { readonly kind: "some"; readonly value: T } + | { readonly kind: "none" }; + +const fromNullable = (value: T | null | undefined): Option => + value === null || value === undefined + ? { kind: "none" } + : { kind: "some", value }; +``` + +Values such as `0`, `false`, and `""` remain present. Do not use `if (!value)` to detect absence. + +Use Null Object only when substitutability is honest. Never invent a birthday, identifier, or monetary value to stand for missing data. Preserve absence through equality, serialization, and reconstitution. + +## Persistence and Boundary Mapping + +Domain values and serialized representations have different responsibilities: + +- Delivery DTOs and messages normally carry JSON-safe primitives. +- Application/domain boundaries convert primitives into domain values deliberately. +- Domain APIs may accept Value Objects when that makes invalid calls impossible. +- Infrastructure maps storage values without exposing public mutable fields. +- Value Objects normally do not have repositories; they persist as parts of Entities or Aggregates. + +Use explicit representations for dates and decimals: + +- `YYYY-MM-DD` for a date without time or timezone +- ISO instant or epoch for an instant +- integer minor units plus currency, or an exact decimal type, for money + +Do not call a structure "primitives" if it contains `Date`, `Maybe`, or domain classes. Ensure JSON round trips preserve the type's meaning. + +Creation and reconstitution may need different paths when creation emits events or historical data predates current validation. Prefer a mapper when public `toPrimitives()` methods would weaken encapsulation. Translate equivalent-looking values across Bounded Contexts instead of sharing model classes by default. + +## Construction Failures and Domain Errors + +Choose the failure shape by caller needs: + +- Throw a typed domain error when invalid construction is exceptional in trusted domain code. +- Return `Result`/`Either` from parsers when invalid external input is expected. +- Use structured error details when callers map, recover, or display specific failures. +- Use a simple assertion/error for programmer-only impossible states when no recovery contract exists. + +Do not create a dedicated class for every throw automatically. Do not enforce global uniqueness with only `searchByEmail` followed by `save`; concurrent requests can race. Back uniqueness with a persistence constraint and translate the collision. + +Use `ddd-best-practices` for the full domain error taxonomy and boundary translation guidance. + +## Testing Contract + +Test the behaviors the Value Object actually exposes: construction boundaries, semantic equality, normalization, immutable observation, operations, hashing, and serialization only when each is part of its contract. Use deterministic fixtures and explicit policy inputs. + +Use `tdd-best-practices` for the complete Value Object test matrix, property-based test guidance, and deterministic fixture strategy. + +## Safe Evolution + +Introduce Value Objects incrementally beside primitive APIs, migrate one boundary at a time, preserve serialization, and remove duplicated validation only after feedback is green. + +Use `refactoring-best-practices` for the complete safe migration sequence. + +## TypeScript-Specific Guardrails + +- `readonly` is shallow; copy mutable values and collections. +- Avoid a generic equality base for `Date`, arrays, objects, and composites unless it defines semantic comparison explicitly. +- Avoid `constructor.name` as a stable type identifier. +- Structural typing may make two wrappers assignable; use private fields, brands, or opaque types when distinction matters. +- Test transpilation with SWC/Babel does not perform semantic typechecking; run `tsc --noEmit` separately. +- Inherited static factories can accidentally return the base class; test the concrete return type or prefer explicit factories. +- Keep localized display formatting separate from stable machine serialization. +- Redact secrets and sensitive values from `toString()`, logs, and error messages. + +## Course Lessons to Retain + +The course demonstrates these useful progressions: + +- Move duplicated email and identifier knowledge out of `User` and application services. +- Prefer Tell Don't Ask by moving value-specific decisions to the value. +- Extract a policy when behavior gains context or an independent reason to change. +- Introduce composite values and collection objects when rules span several components. +- Use Object Mothers to keep valid defaults readable while overriding relevant values. +- Model domain failures with stable names when callers need to distinguish them. + +## Course Examples Not to Copy + +- Equality implemented as `constructor.name` plus `===`. +- A mutable `Date` stored behind `readonly`. +- Age eligibility hidden behind `new Date()` in a birthdate constructor. +- `Maybe.some()` or `map()` treating `0`, `false`, or `""` as absent. +- A Null Object that substitutes a real birthday for missing data. +- Public mutable arrays or collection constructors that bypass invariants. +- Open-ended date ranges that skip overlap checks. +- "Primitive" persistence shapes containing `Date`, `Maybe`, or domain classes. +- Uniqueness enforced only by check-then-save. +- Unseeded Faker/`Math.random` in fixtures that must be reproducible. +- Assuming the course commit history demonstrates strict Red-Green-Refactor. diff --git a/tests/inventory.test.mjs b/tests/inventory.test.mjs index 38956b3..a47a9f8 100644 --- a/tests/inventory.test.mjs +++ b/tests/inventory.test.mjs @@ -31,11 +31,12 @@ test('live skills are unique and include the three original packages plus BotKit assert.ok(names.includes('setup-bot')); assert.ok(names.includes('retro')); assert.ok(names.includes('simple-as-writing')); + assert.ok(names.includes('oop-best-practices')); assert.ok(names.includes('tdd')); assert.ok(names.includes('matt-tdd')); assert.ok(names.includes('teach')); assert.ok(names.includes('matt-teach')); - assert.equal(names.length, 96); + assert.equal(names.length, 97); }); test('pinned original sources remain present', async () => {