The first time I heard about abstraction was in my programming classes during my degree. We were studying the four pillars of object-oriented programming: Abstraction, Encapsulation, Polymorphism, and Inheritance.
I was fascinated by the idea of modeling the real world in the software we were building. It was a new concept for me, and it took a lot of practice to get used to thinking this way.
However, my understanding of abstraction has grown. It's more than just replicating the real world in software, as we learned in our object-oriented programming classes. Abstraction can be applied at different levels, and while it might seem complex at first, it doesn't have to be.
In this article, I'll explain how abstraction applies to code design and how it can help you gain a better understanding of the technology world. By using effective abstractions, you can build better software that is easier to maintain and understand.
The levels of abstraction
As software engineers, it's crucial to handle abstraction appropriately for different scenarios. We need to adjust our communication and focus based on the context we are in.
When meeting with stakeholders, we can't use the same technical language that we use within our engineering team. In these situations, the main goal is to understand the business, domain, and strategy as thoroughly as possible.
On the other hand, when discussing solutions, infrastructure, and architecture with the team, we should avoid focusing on specific code or programming languages. These are high-level conversations aimed at determining the best ways to manage our microservices and applications.
Therefore, there are various levels of abstraction to consider, and we must tailor our questions and thinking to the right level at the right time with the right people. Recognizing these levels of abstraction has helped me understand the power of context.
But what about low-level solutions? How can we identify abstraction in the software we are building, and how does it work?
Abstraction is life
When designing software, we deal with use cases that represent user actions within our system. Abstraction is essential. By defining the responsibility of an abstraction, we shape it based on the context and purpose we've established.
Creating a new abstraction is like bringing something to life. We need to clearly define its responsibility and purpose. This is why I particularly enjoy working with interfaces. They allow us to define clear contracts for our abstractions, ensuring that each part of the system has a specific role and interacts with others.
Working with interfaces
A well-defined interface acts as a contract for our abstraction. It's like bringing something to life and saying, "Your purpose is to only do this, and do it well." We don't need to worry about the details of how it will be done; the interface provides a stable contract that defines what the abstraction is and what it offers.
This approach aligns with the "D" in the SOLID principles: the Dependency Inversion Principle (DIP). DIP states that we should depend on abstractions rather than concretions. By following this principle, we create flexible and maintainable systems that can adapt to change more easily.
The combination of pieces
In software design, I think of interfaces like puzzle pieces. Each piece has a well-defined contract and purpose, allowing us to use them precisely where needed.
Working with interfaces provides stability when making changes to the code. No matter how much a concrete class changes, as long as it adheres to the contract, the consumers of that interface remain unaffected.
Using interfaces to define abstractions also supports TDD. With the mockist approach, I can mock the behavior of external abstractions and focus on the expected behavior of the current abstraction I am building. This ensures that each piece of the puzzle fits perfectly and functions as intended.
Whose responsibility is this ?
When designing use cases, we often tend to centralize all behaviors and business rules in a single service or use case. However, a well-designed use case is actually a combination of multiple abstractions working together.
Example:
In a finance software, consider a use case that generates an invoice. The flow might look like this:
- Verify if the requested user has permission
- Get the invoice
- Calculate the tax
- Generates the invoice
It's not ideal for this use case to handle all business rules. Instead, it should delegate specific responsibilities to abstractions designed for those tasks.
Building a high quality software requires high quality questions.
To create cohesive use cases, we need to ask the right questions:
- Whose responsibility is this ?
- How many reasons does this use case have to change ?
- Should we create an abstraction to handle this specific business rule?
- Is this business rule within the scope of the abstraction I am building?
- Is this cohesive?
- Can someone without much context understand this use case?
A possible solution:
- Have a dependency on an abstraction that handles permission verification
- Get the invoice value
- Have a dependency on an abstraction that handles tax calculation
- Have a dependency on an abstraction that generates the invoice in the required format
Conclusion
The beauty of this approach is that it allows you to see things from different perspectives and at various levels of abstraction. Understanding which level to focus on helps in establishing better communication and a clearer understanding of both problems and opportunities within the business.
Good design begins with asking the right questions, and the ultimate goal is simplicity. Designing software is an ongoing process; there is always room for improvement. We can continuously refine our designs by learning from our mistakes and through regular practice.
Top comments (0)