Solid Principles For CSharp Developers - egnomerator/misc GitHub Wiki
Pluralsight course by Steve Smith
S - SRP - Single Responsibility Principle
O - OCP - Open-Closed Principle
L - LSP - Liskov Substitution Principle
I - ISP - Interface Segregation Principle
D - DIP - Dependency Inversion Principle
When to apply these principles?
- attempting to apply always can be a form of premature optimization
- apply based on PDD (pain-driven design)
- start with simplest solution, then when
- as app grows, when it becomes painful to work with code, see if any SOLID principles can help
Each software module should have one and only one reason to change.
SRP key takeaways
- PDD (don't immediately attempt to implement OOP for everything)
- SRP states that each module (e.g. class, method) should only have one reason to change
- SRP enables high cohesion (members belong together) and loose coupling (implementing details in a modular way allowing for easier change, as apposed to tight coupling--binding details in a way that's hard to change)
- keep classes small, focused, and testable
Each software module should have one and only one reason to change.
- A module might refer to a single class or a function
What is a responsibility?
-
a decision our code is making about the specific implementation details of some part of what the application does
- it's the answer to the question of how something is done
- examples of responsibilities:
- persistence, logging, validation, business logic
-
Responsibilities change at different times for different reasons.
Each one is an axis of change.
Broad Insights
- multipurpose tools don't perform as well as dedicated tools
- dedicated tools are easier to use
- a problem with one part of a multipurpose tool can impact all parts
Address with Delegation and Encapsulation
Concepts Related to SRP
- Coupling
- tight coupling is bad
- binding 2 or more details in a way that's hard to change
- loose coupling is good
- a modular way to choose which details are involved in a particular operation
- results in code that's easier to change and test
- tight coupling is bad
- Separation of Concerns
- programs should be separated into distinct sections, each addressing a separate concern
- keep plumbing (low-level implementation) separated from high level business logic
- Cohesion
- class elements that belong together are cohesive
Insurance Demo Console App
- app accepts input in the form of a
Policy.txt
file - app purpose
- evaluate policy (applies custom business logic)
- applies a rating to the policy
- demo exercise:
- make note of how many responsibilities the rating engine class has
- my stab at identifying responsibilities and one idea for each one on how to handle
- logging: could require a logger object parameter and call that logger's log method
- loading policy: could require a policy object parameter
- converting policy file: again, could require the policy object as a parameter so this is already done
- switch on policy Type:
- could have a new PolicyRaterProvider class--it requires a policy.Type and based on that type returns a PolicyRater
- PolicyRater could be a base class with the Rate virtual method and the class requires a policy
- could have a Child PolicyRater class for each type of policy
- RateEngine's Rate method would require a policy and a PolicyRaterProvider
- multiple rating processes: previous idea also address this
Difficult to test one responsibility in isolation when we don't follow SRP
Delegation opportunity examples: logging, persistence (load file) Encapsulation
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
OCP key takeaways
- solve problem first using simple concrete code
- identify kinds of changes the app is likely to continue needing
- modify code to be extensible along the identified axis of change
- and modify it such that, in the future, it is extensible without the need to modify its source each time
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
- it should be possible to change the behavior of a method without editing its source code
Open to extension: new behavior can be added in the future
- Code that is closed to extension has fixed behavior
Closed to modification: changes to source or binary code are not required
- The only way to change the behavior of code that is closed to extension is to change the code itself
- "
new
is glue"- this phrase refers to creating (i.e. new-ing up) an object
- creating an object "glues" that implementation to the containing entity
Why should code be closed to modification?
- less likely to introduce bugs in code we don't touch or redeploy
- less likely to break dependent code when we don't have to deploy updates
- fewer conditionals in code that is open to extension results in simpler code
What about bug fixes?
- bug fix modifications are okay--this principle does not necessarily apply to fixing bugs
Abstraction
- Balance abstraction and concreteness
- abstraction takes time, make sure time and effort is worth it
- Abstraction adds complexity
- some measure of concreteness is needed
- Apply abstraction as needed
- don't start by abstracting everything, wait until it's needed
- One "proven" approach:
- start concrete
- modify code first time or two
- if a need to modify arises a 3rd time, strongly consider opening to extension
How to apply?
- Parameters
- Inheritance
- Composition/Injection
Prefer implementing new features in new classes
- can design class to suit the new feature
- nothing in the current system depends on this new class
- enables adding behavior without touching existing code
- can follow OOP with this new class right out of the gate
- this point is meant to appose an alternative of modifying a class that isn't OOP-y and won't refactor
- the class can be unit testes from the start
back to insurance rating service demo ...
OCP and Packages/Libraries
- Closed for modification
- consumers should be unable to change package/lib contents
- Closed for modification
- should not break consumers when new behavior is added
- Open to extension
- consumers should be able to extend the package/lib to suit their own needs
Barbara Liskov's words:
Let phi(X) be a property provable about objects x of type T. Then phi(y) should be true for objects y of type S where S is a subtype of T.
Uncle Bob's wording:
Derived classes must be usable through the base class interface, without the need for the user to know the difference.
Paraphrase from this Pluralsight course:
Subtypes must be substitutable for their base types.
LSP key takeaways
- the IS-A relationship is insufficient for object oriented design; you must ensure subtypes are substitutable with their base types
- ensure base type invariants are enforced
- problem indicators: type checking, null checking, NotImplementedException
Basic Object-Oriented Design
- Something "IS-A" something else
- an eagle IS-A bird
- this is a way to think about inheritance
- Something "HAS-A" property
- an address HAS-A city
LSP states that the IS-A relationship is insufficient and should be replaced with "IS-SUBSTITUTABLE-FOR"
classic rectangle/square problem
- square is-a rectangle relationship exists in geometry
- but this does not hold in programming
Indicators of LSP violations:
- type checking with is or as in polymorphic code (in code that should be working with a type and any of its subtypes without knowing whether it's currently working with that type or one of its subtypes)
- null checks
- use null object pattern
- NotImplementedException (duh)
Fixing LSP violations
- follow "tell, don't ask" principle
- call a method, don't ask what type and act based on that type
- the the logic should be encapsulated in the type so that you can simple tell the type what to do (simply call its method)
- minimize null checks
- with C# features (e.g. null conditional operator) [my thought here: um that is a null check, how is this "better"??]
- use null object pattern
- follow ISP and be sure to fully implement interfaces
back to Rate engine demo
- null object pattern was used to create an "UnknownPolicyRater" subtype of type Rater
- the factory was returning null if the type wasn't recognized
- we change it to return the "UnknownPolicyRater" subtype instead
- this eliminates the need to check for null on a Rater returned from the factory
Clients should not be forced to depend on methods they don't use.
I.E.: Prefer small, cohesive interfaces to large, "fat" ones.
ISP key takeaways
- prefer small, cohesive interfaces over large, expansive ones
- following ISP helps with following SRP and LSP
- break up large interfaces by using
- interface inheritance
- adapter design pattern
What do we mean by interface?
- any accessible interface of a given type (including interface types in C#)
- a type's interface in this context is whatever members can be accessed by a client working with an instance of that type
What do we mean by a client?
- any code that is interacting with an instance of the interface--it's calling code
More dependencies means
- more coupling
- more brittle code
- more difficult testing
- more difficult deployments
Detecting ISP violations
- large interfaces
- NotImplementedException
- code uses just a small subset of a larger interface
How to avoid ISP violations
- split up interfaces such that the resulting interfaces are more cohesive
What about legacy code that's coupled to original interface?
- take advantage of C# multiple interface inheritance
- of course this only works if you own the original interface
- adaptor design pattern
- this could be the approach if you don't own the original interface
How to fix existing ISP violations
- break up large interfaces you own into smaller ones
- compose fat interfaces from smaller ones for backwards compatibility
- e.g.
IOldFatInterface: INewCohesiveOne, INewCohesiveTwo { /*empty body now since the methods are inherited*/ }
- e.g.
- compose fat interfaces from smaller ones for backwards compatibility
- for large interfaces you don't control
- create small cohesive interface
- use adapter design pattern so your code can work with the adapter
- Clients should own and define their interfaces
Where do interfaces live in our apps?
Interfaces should be declared where both client code and implementations can access it.
back to rating app demo
made code changes following above fixes
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.
DIP key takeaways
- most classes should depend on abstractions, not implementation details
- abstractions shouldn't leak details
- make classes explicit about their dependencies (by requiring in constructor)
- clients should inject dependencies when they create other classes
- structure your solutions to leverage dependency inversion
- [my thoughts here: wow this is crap wording; verbally, he makes it clear that this point is literally about using folders to organize the files that result from structuring the code to use DI]
How to know if something depends on something else?
- compile-time dependency
- references required to compile
- the DI principle has mostly to do with compile-time dependencies
- runtime dependency
- references required to run
Dependencies (references) should point
- AWAY from low-level/infrastructure code and
- TOWARD high-level abstractions and business logic
What's "high-level"?
- more abstract
- business rules
- process-oriented rather than detail-oriented
- further separated from I/O
What's "high-level"?
- closer to I/O
- commonly referred to as "plumbing" code
- interacts with specific external systems and hardware
What is an abstraction in C#?
- interfaces
- abstract base classes
- "Types you can't instantiate"
- a definition of ways to interact (e.g. methods) without specifying implementation (e.g. no method bodies)
Abstractions shouldn't be coupled to details
What are details?
Details specify how; i.e. implementations
Example:
-
Bad
public interface IOrderDataAccess { // exposing implementation details by specifying the Sql return and param types // client must now take a dependency on ADO.NET to use these this interface SqlDataReader ListOrders(SqlParameterCollection params); }
-
Good
public interface IOrderDataAccess { List<Order> ListOrders(Dictionary<string, string> params); }
Low level dependencies examples
- Database, file system, email send/receive, web API consumption/hosting, config details (e.g. reading setting from file), read system clock
Hidden Direct Dependencies
- Direct use of low level dependencies
- Static calls and new (used for low-level types)
- Cause pain
- tight coupling
- difficult to isolate and unit test
- duplication
"New is glue"
- new isn't bad, just remember that it creates coupling and be deliberate about deciding when that is okay
- do you need to specify the implementation? or could you use an abstraction?
Explicit Dependencies Principle
- Your classes shouldn't surprise clients with dependencies
- List dependencies up front, in the constructor
- Think of dependencies as ingredients in a cooking recipe
- you would hate to have gone to the store and got all the ingredients listed at the top, and then get home and start cooking following the recipe steps, and then find that half-way through the steps, an unlisted ingredient is used!
Dependency Inversion
2 graphs that illustrate the difference DI makes on direction of dependencies:
- https://docs.microsoft.com/en-us/dotnet/standard/modern-web-apps-azure-architecture/architectural-principles#dependency-inversion
- this article is worth reading--not just valuable for its 2 graphs in that section
Dependency Injection (DI)
- Don't create your own dependencies; this creates coupling
- depend on abstractions
- request dependencies from client
- Client injects dependencies as
- ctor args preferably or
- method args
- properties
The DI technique is an implementation of the Strategy Design Pattern
Prefer Constructor Injection
- follows explicit dependencies principle (by specifying dependencies up front)
- ensures classes are never in uninitialized state when dependencies are needed
- allows leveraging IoC containers (a.k.a. DI containers or services containers)
back to rater demo
- mention of ctor chaining as an approach to backwards compatibility if legacy code uses the class in many places
- better to NOT use a default constructor at all though, and instead go to all places that use the ctor and pass the dependencies (or use an IoC container)
Doesn't all this SOLID refactoring require too many files in the project making it hard to organize?
- use folders to organize
- one approach is folders by thing (e.g. controllers, models, services)
- another approach is features folders