Software design principles serve as the bedrock for software development. As a software engineer, you'll encounter these principles in your tools, languages, frameworks, paradigms, and patterns. They are the essential elements that make up "good" and "readable" code. Once you grasp these principles, you'll start to recognize them in all aspects of your work.
What sets a good engineer apart from a bad one is the ability to recognize and utilize these skills. Even the best frameworks or tools can't enhance your code quality unless you grasp the basics. Additionally, lacking this foundational understanding makes you overly dependent on those tools.
This article isn’t a reference guide; it's my attempt to organize a list of essential principles that should be revisited periodically.
Abstraction stands as one of the pivotal concepts overall. When we abstract something, we concentrate on the essential elements, disregarding other details. Abstraction can be understood through two primary perspectives: the act of generalizing and the outcome of this generalization itself.
In the realm of software development, encapsulation is typically paired with abstraction. Encapsulation is a technique used to conceal the details of an abstracted component. Abstraction manifests in many ways. For instance, when you define a datatype, you are abstracting away from the actual memory representation of a variable. Likewise, when you establish an interface or a function’s signature, the emphasis is on the most crucial aspect: the contract for interaction. When crafting a class, you choose the relevant attributes for your specific domain and business scenarios. There are numerous other instances, but the primary goal of abstraction is that you don't need to understand the implementation details to use something; thus, allowing you to concentrate on what truly matters.
This concept isn't limited to just application development. As a programmer, you use language syntax to abstract the fundamental actions of an operating system. The OS abstracts your language translator from the basic operations involving the CPU, memory, NIC, and other components. The deeper you delve, the more you realize that it's all about layers of abstraction.
As you can observe, abstraction can appear in various shapes — ranging from data (implementation) abstraction to hierarchical. A fundamental guideline for employing abstraction is the principle: “Encapsulate what varies.” Identify the part that is likely to change and establish a definite interface for it. In this manner, even if the internal logic alters, the client will continue to interact in the same way.
Imagine you need to figure out a currency conversion. Right now, you have access to just two currencies. You might end up with something like this:.
if (baseCurrency == "USD" and targetCurrency == "EUR") return amount * 0.90;
if (baseCurrency == "EUR" and targetCurrency == "USD") return amount * 1.90;
However, a new kind of currency could be introduced down the line, necessitating updates to the client code. Rather than taking this approach, it’s more efficient to encapsulate all the relevant logic within a distinct method. The client side can then invoke this method as needed.
function convertCurrency(amount, baseCurrency, targetCurrency) {
if (baseCurrency == "USD" and targetCurrency == "EUR") return amount * 0.90;
if (baseCurrency == "EUR" and targetCurrency == "USD") return amount * 1.90;
if (baseCurrency == "USD" and targetCurrency == "UAH") return amount * 38.24;
…
}
DRY (don’t repeat yourself), also referred to as DIE (duplication is evil), suggests that information or knowledge in your code base should not be duplicated.
"Every bit of information needs to be represented in a clear, definitive, and authoritative manner within a system," — Andy Hunt and Dave Thomas, The Pragmatic Programmer.
"Each piece of information should have one clear and definitive representation within a system," say Andy Hunt and Dave Thomas in The Pragmatic Programmer.
The advantage of minimizing repeated code lies in the ease of updates and maintenance. When you replicate logic across multiple areas and subsequently discover a bug, it's easy to miss updating one of those instances, resulting in inconsistent behavior for what should be the same functionality. To prevent this, identify any repetitive functionality and encapsulate it into a procedure, class, or similar structure, giving it a descriptive name for clarity. Use this single, centralized point wherever that functionality is required. This approach supports a single point of modification and reduces the risk of inadvertently affecting unrelated features.
The phrase KISS (keep it simple, stupid) was introduced by aircraft engineer Kelly Johnson. He tasked his engineering team with designing a jet aircraft that could be repaired by an everyday mechanic in combat situations using only basic tools.
The primary concept is to emphasize the simplicity of a system, enhancing comprehension and minimizing overengineering by utilizing only the essential tools.
When designing a solution to a problem, you need to focus on two main aspects: how to best integrate it with the current system and how to make it adaptable for future needs. In the latter case, creating an advanced feature prematurely for the sake of future-proofing is often a mistake. Even if it seems like it will lower integration costs now, maintaining and debugging such code can be unclear and overly complex. This violates the earlier principle by adding unnecessary complexity to the current solution. Additionally, remember that the functionality you anticipate might not be required in the future, leading to a waste of resources.
That’s the essence of YAGNI, or “You aren’t gonna need it.” Don't misunderstand; it's important to consider the future of your solution. However, you should only integrate code when it becomes necessary.
The Law of Demeter (LoD), also known as the principle of least knowledge, recommends avoiding interactions with “strangers”. In the realm of OOP, a “stranger” pertains to any object that isn't directly linked with the current one.
The advantage of applying Demeter’s Law lies in its ability to enhance maintainability. This is achieved by preventing direct interactions between objects that are not directly related.
Thus, if you engage with an object and any of the following conditions are not satisfied, this principle is breached:.
- When the object refers to the current instance of a class (accessed with
this
). Maintain the original text's mood and tone, including phrases and HTML tags. - When the item belongs to a class.
- When an object is provided to a method via the parameters.
- When the object is created within a method.
- When the object can be accessed globally...
For instance, imagine a scenario where a customer needs to deposit money into a bank account. In this case, we could have three classes — Wallet
, Customer
, and Bank
.
class Wallet {
private decimal balance;
public decimal getBalance() {
return balance;
}
public void addMoney(decimal amount) {
balance += amount
}
public void withdrawMoney(decimal amount) {
balance -= amount
}
}
class Customer {
public Wallet wallet;
Customer() {
wallet = new Wallet();
}
}
class Bank {
public void makeDeposit(Customer customer, decimal amount) {
Wallet customerWallet = customer.wallet;
if (customerWallet.getBalance() >= amount) {
customerWallet.withdrawMoney(amount);
//...
} else {
//...
}
}
}
The violation of the Demeter law is evident in the makeDeposit
method. While accessing a customer's wallet aligns with the Law of Demeter, it's somewhat peculiar from a logical standpoint. However, the real issue arises when a bank object calls getBalance
and withdrawMoney
on the customerWallet
object, essentially communicating with a stranger (wallet) rather than a friend (customer).
Here’s how to fix it:
class Wallet {
private decimal balance;
public decimal getBalance() {
return balance;
}
public boolean canWithdraw(decimal amount) {
return balance >= amount;
}
public boolean addMoney(decimal amount) {
balance += amount
}
public boolean withdrawMoney(decimal amount) {
if (canWithdraw(amount)) {
balance -= amount;
}
}
}
class Customer {
private Wallet wallet;
Customer() {
wallet = new Wallet();
}
public boolean makePayment(decimal amount) {
return wallet.withdrawMoney(amount);
}
}
class Bank {
public void makeDeposit(Customer customer, decimal amount) {
boolean paymentSuccessful = customer.makePayment(amount);
if (paymentSuccessful) {
//...
} else {
//...
}
}
}
From now on, any interaction with a customer's wallet will be handled through the customer object. This approach promotes loose coupling and simplifies modifications to the logic within the Wallet
and Customer
classes (a bank object shouldn't need to be concerned with how the customer is represented internally), and it also makes testing easier.
In general, LoD doesn't work well when you see more than two dots referring to a single object, such as object.friend.stranger
rather than object.friend
.
The Separation of Concerns (SoC) principle encourages dividing a system into smaller segments based on its individual concerns. Here, a "concern" refers to a specific aspect or feature of the system.
For instance, when you are defining a domain, each object represents a unique focus. In a tiered system, each layer has its distinct responsibility. Similarly, within a microservice architecture, each service fulfills a specific role. This concept can be extended endlessly.
The key takeaway regarding the SoC is:.
- Determine the system's issues;. Maintain the atmosphere and style of the text and don't alter phrases or HTML tags.
- Break the system down into individual components that address these issues separately from one another.
- Link these components using a clear and precise interface.
In this manner, organizing concerts closely mirrors the abstraction principle. Following SoC leads to code that is straightforward, modular, reusable, built on reliable interfaces, and easy to test.
The SOLID principles, introduced by Robert Martin, are a collection of five design guidelines intended to enhance the foundational aspects of object-oriented programming, making software systems more flexible and easier to adapt.
Single responsibility principle
A class ought to have a single, specific reason to undergo changes.
A class should possess just one reason for modification and no more.
In other words:
Group items that change for the same reasons. Distinguish items that change for different reasons."
Group items that shift for similar reasons. Distinguish those that vary for distinct reasons.
This closely resembles SoC, doesn't it? The distinction between these concepts lies in their scope: SRP focuses on separating responsibilities at the class level, whereas SoC is a broader strategy that applies to both high-level (like layers, systems, services) and low-level (such as classes, functions) abstractions.
The single responsibility principle benefits from all the perks of SoC. Specifically, it encourages high cohesion, reduces coupling, and prevents the god object anti-pattern.
Open-closed principle
"Software entities should be designed to allow for expansion without altering the original code."
"Software entities ought to be designed so they can be expanded upon without needing to be altered."
When adding a new feature, it's important to ensure that the existing code remains unaffected by changes.
A class is labeled as open if it allows for extensions and necessary modifications. On the other hand, a class is deemed closed when it possesses well-defined interfaces and is not subject to future alterations, meaning it is ready for use by other code components.
Consider a typical OOP inheritance scenario: you’ve designed a parent class and subsequently extended it with a child class that has additional features. Now, suppose you decide to alter the internal structure of the parent class, perhaps by introducing a new field or removing a method, which consequently affects the child class. This scenario violates the principle, as you’re forced to modify both the parent and child classes to accommodate these changes. This issue arises due to improper application of information hiding. Conversely, by providing the child class with a stable contract through a public property or method, you can freely modify your internal structure without impacting that contract.
This approach promotes client reliance on abstraction (e.g., interface or abstract class) instead of a specific implementation (a concrete class). By doing so, a client that relies on abstraction is deemed as closed, yet it remains open for extension. Any new changes that align with that abstraction can be effortlessly incorporated for the client.
Here's another illustration. Suppose we're working on the logic for calculating discounts. At this stage, we have just two discount types. Prior to implementing the open-closed principle:
class DiscountCalculator {
public double calculateDiscountedPrice(double amount, DiscountType discount) {
double discountAmount = 15.6;
double percentage = 4.0;
double appliedDiscount;
if (discount == 'fixed') {
appliedDiscount = amount - discountAmount;
}
if (discount == 'percentage') {
appliedDiscount = amount * (1 - (percentage / 100)) ;
}
// logic
}
}
At this moment, the client (DiscountCalculator
) relies on the external DiscountType
. Introducing a new discount type would require us to modify this client's code to incorporate it, which is not an ideal situation.
After applying the open-closed principle:
interface Discount {
double applyDiscount(double amount);
}
class FixedDiscount implements Discount {
private double discountAmount;
public FixedDiscount(double discountAmount) {
this.discountAmount = discountAmount;
}
public double applyDiscount(double amount) {
return amount - discountAmount;
}
}
class PercentageDiscount implements Discount {
private double percentage;
public PercentageDiscount(double percentage) {
this.percentage = percentage;
}
public double applyDiscount(double amount) {
return amount * (1 - (percentage / 100));
}
}
class DiscountCalculator {
public double calculateDiscountedPrice(double amount, Discount discount) {
double appliedDiscount = discount.applyDiscount(amount);
// logic
}
}
In this context, you utilize the open-closed principle coupled with polymorphism, rather than including numerous if statements to identify the type and subsequent actions of an entity. Every class that implements the Discount
interface is encapsulated with regard to the public applyDiscount
method. However, they remain adaptable for changes in their internal data.
Liskov substitution
Subclasses need to be usable in place of their parent classes.
"Subclasses should be able to replace their parent classes seamlessly."
Or, more formally:
According to Barbara Liskov and Jeannette Wing (1994), consider φ(x) as a provable characteristic of objects x that belong to type T. Consequently, φ(y) needs to hold true for objects y that are of type S, provided that S is a subtype of T.
According to Barbara Liskov and Jeannette Wing (1994), if φ(x) stands for a provable quality of objects x belonging to type T, then φ(y) must also hold true for any object y that belongs to subtype S of T.
To put it simply, when you extend a class, you must adhere to the established contract. "Breaking a contract" refers to not meeting one of the following requirements:
- It's important not to modify the parameters in derived classes. Child classes should stick to the method signatures of their parent classes, meaning they should either accept the same parameters or more generalized ones.
- Avoid altering the return type in subclasses: child classes should either return the same type as the parent class or a more specific subtype.
- Avoid throwing exceptions in derived classes: unless the parent class does, child classes should not throw exceptions in their methods. If an exception must be thrown, the type should either match or be a subtype of the exception used by the parent class.
- Avoid adding stricter preconditions in child classes: subclasses shouldn’t alter the expected behavior for clients by imposing additional constraints. For instance, if a base class accepts a string, the child class shouldn't restrict this to a string of no more than 100 characters.
- Avoid reducing postconditions in derived classes: child classes must not alter the expected behavior for clients by neglecting certain tasks, such as failing to clean up the state after an operation or leaving a socket open.
- Avoid undermining invariants in subclassing: derived classes must maintain the conditions established by the parent class. For instance, refrain from altering the fields defined in the parent class, as this might inadvertently disrupt the broader logic tied to them.
Interface segregation
“Make fine-grained interfaces that are client-specific.”
“Make fine-grained interfaces that are client-specific.”
Code should not rely on methods it doesn’t require. If a client isn’t utilizing certain behaviors of an object, there’s no reason it should be compelled to depend on them. In the same vein, if a client isn’t making use of some methods, why should the implementer be obligated to include this functionality?
Divide "fat" interfaces into more targeted ones. If you modify a specific interface, these changes won't impact clients that are not connected.
Dependency inversion
“Depend on abstractions, not on concretions.”
“Depend on abstractions, not on concretions.”
Uncle Bob articulated this concept as a rigorous adherence to OCP and LSP:.
In this column, we delve into the structural consequences of the OCP and the LSP. When these principles are diligently applied, the resulting structure can be interpreted as a principle in its own right. I refer to this as “The Dependency Inversion Principle” (DIP). — Robert Martin
In this column, we explore how the OCP and the LSP impact structure. The consistent application of these principles can lead to the formation of a new, overarching principle. I refer to this as "The Dependency Inversion Principle" (DIP). — Robert Martin
Dependency inversion consists of two main statements:
- High-level modules shouldn’t rely on low-level modules. Instead, both should rely on abstractions.
- Abstractions shouldn't rely on specifics. Instead, specifics should be based on abstractions.
For example, let’s imagine we are creating a user service that handles user management tasks. To save changes, we opted to use PostgreSQL.
class UserService {
private PostgresDriver postgresDriver;
public UserService(PostgresDriver postgresDriver) {
this.postgresDriver = postgresDriver;
}
public void saveUser(User user) {
postgresDriver.query("INSERT INTO USER (id, username, email) VALUES (" + user.getId() + ", '" + user.getUsername() + "', '" + user.getEmail() + "')");
}
public User getUserById(int id) {
ResultSet resultSet = postgresDriver.query("SELECT * FROM USER WHERE id = " + id);
User user = null;
try {
if (resultSet.next()) {
user = new User(resultSet.getInt("id"), resultSet.getString("username"), resultSet.getString("email"));
}
} catch (SQLException e) {
e.printStackTrace();
}
return user;
}
// ...
}
Currently, the UserService
is closely integrated with its dependency, the PostgresDriver
. However, we have decided to transition to the MongoDB database in the future. Since MongoDB operates differently compared to PostgreSQL, it will be necessary to revise all the methods within our UserService
class.
To solve this issue, you should incorporate an interface:.
interface UserRepository {
void saveUser(User user);
User getUserById(int id);
// ...
}
class UserPGRepository implements UserRepository {
private PostgresDriver driver;
public UserPGRepository(PostgresDriver driver) {
this.driver = driver;
}
public void saveUser(User user) {
// ...
}
public User getUserById(int id) {
// ...
}
// ...
}
class UserMongoRepository implements UserRepository {
private MongoDriver driver;
public UserPGRepository(MongoDriver driver) {
this.driver = driver;
}
public void saveUser(User user) {
// ...
}
public User getUserById(int id) {
// ...
}
// ...
}
class UserService {
private UserRepository repository;
public UserService(UserRepository database) {
this.repository = database;
}
public void saveUser(User user) {
repository.saveUser(user);
}
public User getUserById(int id) {
return repository.getUserById(id);
}
// ...
}
Now, the high-level module (UserService
) relies on abstraction (UserRepository
), meaning the abstraction itself isn't tied to specific details like the SQL API for PostgreSQL or the Query API for MongoDB. Instead, it is based on the interface created for the client.
The General Responsibility Assignment Principles (GRASP) consist of nine guidelines for object-oriented design introduced by Craig Larman in his book "Applying UML and Patterns."
Much like SOLID, these principles aren't created from the ground up; instead, they are formed by integrating established programming guidelines specific to OOP.
High cohesion
“Keep related functionalities and responsibilities together.”
“Keep related functionalities and responsibilities together.”
The high cohesion principle aims to keep complexity under control. Here, cohesion refers to how closely related the responsibilities of an object are. When a class has low cohesion, it indicates that it’s performing tasks that stray from its main purpose or handling duties that could be assigned to a different subsystem.
Typically, a class with high cohesion contains a few methods that are closely linked by their purpose. This approach enhances the maintainability, comprehension, and reusability of the code.
Low coupling
“Reduce relations between unstable elements.”
“Reduce relations between unstable elements.”
This principle seeks to minimize the interdependence between elements, thereby avoiding unwanted consequences from modifying the code. In this context, coupling refers to the degree to which one entity relies on, or has knowledge of, another entity.
Program elements with high coupling are heavily dependent on one another. If you have a class characterized by high coupling, any modifications in it will cause corresponding changes in various parts of the system or the other way around. This kind of design approach restricts code reuse and requires more effort to grasp. Conversely, low coupling fosters a design where classes are more autonomous, minimizing the effects of changes.
The principles of coupling and cohesion are closely interrelated. When two classes exhibit high cohesion, their connection is generally weak. Conversely, if these classes have minimal coupling, it means they inherently possess high cohesion.
Information expert
“Place responsibilities with data.”
“Place responsibilities with data.”
The information expert pattern addresses the issue of assigning responsibility for acquiring specific knowledge or performing tasks. According to this pattern, an object that has direct access to the necessary information is deemed the information expert for that particular data.
Recall the instance of applying Demeter's Law in the relationship between a customer and a bank? It’s basically the same principle:
- The
Wallet
class is the go-to authority for understanding and handling balance. - The
Customer
class holds extensive knowledge about its own internal layout and actions. - The
Bank
class serves as a knowledgeable resource in the banking sector.
Carrying out a responsibility typically involves collecting data from various areas within a system. To facilitate this, it is essential to have intermediate information experts. These experts help objects maintain their internal data, enhancing encapsulation and reducing coupling.
Creator
Give the task of creating objects to a class that is closely related.
Delegate the duty of creating an object to a class that is closely connected.
Who is best suited to handle the creation of a new object instance? As per the Creator pattern, the job of instantiating a new x
class object should fall to a creator class that possesses one of these characteristics:.
- Combine
x
;. - Include
x
; - Documents
x
;. - Be sure to utilize
x
;. Maintain the style and emotion of the original text and preserve phrases and HTML tags as they are. - Ensure that the necessary initialization data for
x
is available.
This principle encourages low coupling since identifying the appropriate creator for an object, meaning a class that is already connected to the object in some way, will prevent an increase in their interconnectedness.
Delegate the responsibility of managing system messages to a designated class.
Designate a particular class to manage system messages.
A controller is a system component tasked with handling user events and directing them to the appropriate domain layer. It's the initial contact point for any service request from the user interface. Typically, a single controller manages similar types of operations. For instance, a UserController
is used to oversee interactions involving user entities.
Remember that the controller's role is not to handle the business logic. It should remain minimal, delegating tasks to the appropriate classes instead of managing them directly.
An illustration of this can be seen in MVC-like design patterns, where the controller plays a crucial role. In this setup, instead of the model and view communicating directly, the controller acts as a mediator, managing the interaction between them. This arrangement allows the model to remain detached from any external interaction.
Indirection
To promote low coupling, delegate the responsibility to an intermediate class.
To maintain low coupling, delegate the responsibility to an intermediary class.
There is a well-known saying by Butler Lampson: “All problems in computer science can be solved by another level of indirection.”
The indirection principle aligns with the dependency inversion principle by inserting an intermediary between two components to make them indirect. This approach promotes weak coupling and offers all the associated benefits.
Polymorphism
To manage different related behaviors that change based on type, utilize polymorphism to delegate the responsibility to the respective types.
"When different types exhibit varied alternative behaviors, apply polymorphism to delegate the responsibility to the specific types that showcase the differing behaviors."
If you've come across code that uses if
/switch
statements to check object types, there's likely a minimal use of polymorphism. When you need to add new functionality to this code, you'll have to find where the condition is being checked and insert an additional if statement. This approach is a sign of poor design.
By applying the principle of polymorphism to various classes that share similar behavior, you can unify diverse types, resulting in software components that are interchangeable and each focus on specific tasks. The approach to achieving this varies depending on the programming language, but commonly involves either implementing a shared interface or leveraging inheritance. Particularly, this often means assigning the same method names to different objects. Ultimately, this practice yields modular, extendable elements that can be updated without modifying unrelated code.
Just as with the open-closed principle, using polymorphism correctly is crucial. It should only be applied when there's a clear indication that certain components will, or might, change. Implementing polymorphism unnecessarily, such as creating abstractions over stable language-internal classes or frameworks, simply adds extra work with no real benefit.
Pure fabrication
“To maintain high cohesion, assign the responsibility to the appropriate class.”
To maintain strong cohesion, allocate the responsibility to the appropriate class.
Occasionally, adhering to the principles of high cohesion and low coupling means there might not be a relevant real-world counterpart. In such cases, you're essentially inventing something that doesn't exist within the domain. This invention is pure because its responsibilities are purposefully and clearly defined. Craig Larman recommends employing this approach when the application of an information expert is logically incorrect.
For instance, a controller pattern is an example of pure fabrication. Similarly, a DAO or a repository falls under the same category. These classes aren't specific to any particular domains but are designed for the convenience of developers. While it's possible to embed data access logic directly within domain classes (considering they're experts in their respective areas), this approach would breach the principle of high cohesion since data management logic doesn't directly pertain to the behavior of domain objects. Additionally, coupling would increase due to the dependency on the database interface. Another issue is the high chance of code duplication since the logic for managing data across different domain entities tends to be similar. Basically, this practice results in mingling different abstractions within a single place.
Using pure fabrication classes offers the advantage of clustering related actions into objects that don't have a real-world counterpart. This approach leads to a well-organized design, promoting code reuse and minimizing dependency on varying responsibilities.
Protected variations
"Maintain consistency in anticipated changes by implementing a stable contract."
"Safeguard forecasted changes with a consistent contract in place."
To ensure that future adjustments don't disrupt other segments, it's crucial to establish a stable contract that prevents unexpected effects. This concept underscores the significance of previously discussed principles for delegating responsibilities among various objects: employing indirection for seamless switching between implementations, leveraging the information expert to determine accountability for a requirement's execution, and incorporating polymorphism into your design to allow for diverse, interchangeable solutions, and so forth.
The core idea of the protected variations principle serves as a foundation that supports various other design patterns and principles.
As developers and architects grow in their careers, their understanding of diverse strategies to achieve protection variability (PV) broadens. They learn to identify the PV challenges worth tackling and to select the most fitting PV solutions. Initially, the focus is on fundamental concepts like data encapsulation, interfaces, and polymorphism, which are essential for achieving PV. With experience, they move on to more advanced methods such as rule-based languages, rule interpreters, reflective and metadata designs, and virtual machines, which offer varied means of ensuring protection against different variations.
As developers and architects mature, their expanding knowledge of various mechanisms aids in achieving PV. They become proficient in discerning which PV challenges are worth tackling and selecting the most effective PV solutions. Initially, they delve into essentials like data encapsulation, interfaces, and polymorphism — fundamental techniques for achieving PV. As they advance, they explore more sophisticated methods such as rule-based languages, rule interpreters, reflective and metadata designs, and virtual machines, among others — each offering a way to guard against different types of variation. — Craig Larman, Applying UML and Patterns.
I bet you’ve already applied some of the concepts mentioned here without even realizing it. Now, knowing what they’re called makes it simpler to talk about them.
You might have observed that a few of these principles share a common foundation. That's just the way it is. Take for instance, information expert, SoC, high cohesion & low coupling, SRP, interface segregation, and so on; they all aim to segregate concerns across various software elements. The goal behind this is to achieve protected variations, dependency inversion, and indirection, which in turn make the code more maintainable, extensible, understandable, and testable.
Just like any tool, this serves as a guideline rather than a rigid rule. The essential part is to grasp the trade-offs and make well-informed choices. I purposely chose not to delve into the misuse of software principles, allowing you the space to think independently and discover answers. Being unaware of the opposing viewpoint can be just as detrimental as not knowing these principles altogether.