Hello, fellow developers!π§πΌβπ»
In the realm of software engineering, design patterns serve as tried-and-true solutions to common problems. They provide a universal language that helps both in visualizing how to structure our code and in discussing its architecture with colleagues. Among these, Structural Design Patterns hold a special place. They allow us to form complex structures by combining objects and classes, essentially serving as the building blocks for resilient, scalable software.
This article aims to delve into two such indispensable Structural Design Patterns: the Adapter and the Composite patterns. While these patterns are not unique to any programming language, our focus will be on their application in Rust, a language renowned for its focus on performance and safety. We'll explore how these patterns are leveraged in a real-world Rust project, Hyperswitch β an open-source library that offers a wide array of functionalities.
So, whether you're a Rust aficionado looking to deepen your understanding of design patterns or a software architect curious about how Rust handles structural concerns, this article has something for you. Let's bridge the gap between theory and practice, and see how the Adapter and Composite patterns come to life in Rust!
The Adapter Pattern
The Adapter pattern serves as a bridge between two incompatible interfaces, enabling one class to work with methods and properties of another class that it otherwise wouldn't be able to. Think of it as a real-world adapter: just like you'd use a power adapter to connect your laptop's plug to a foreign electrical outlet, the Adapter pattern lets you connect unfamiliar interfaces in your code.
In software engineering, the Adapter pattern is commonly used in scenarios like:
- Legacy Code Integration: When you need to integrate new features with legacy systems that have a different interface.
- Third-Party Library Usage: When using external libraries that offer useful functionalities but have a different interface than what your application expects.
- Code Reusability: When you want to reuse code that was designed for a different purpose and has a different interface.
How it's implemented in Hyperswitch (focusing on the Transformers modules)
In Hyperswitch, each payment connector, like fiserv
, might have its own transformer module. This module could contain functions to adapt Hyperswitch's internal request and response objects to what the fiserv
service expects and vice versa.
Here's a simplified example:
// Inside transformers/fiserv.rs
// Importing Hyperswitch's generic payment request and response objects
use crate: :core: :PaymentRequest;
use crate: :core: :PaymentResponse;
// Importing Fiserv's specific types
use fiserv_sdk: :FiservPaymentRequest;
use fiserv_sdk: :FiservPaymentResponse;
pub struct FiservTransformer;
impl FiservTransformer {
// Adapting Hyperswitch's generic PaymentRequest to Fiserv's specific request type
pub fn adapt_request(hs_request: PaymentRequest) ->
FiservPaymentRequest {
// Transformation logic here
// ...
}
// Adapting Fiserv's specific response type to Hyperswitch's generic PaymentResponse
pub fn adapt_response(fiserv_response: FiservPaymentResponse) -> PaymentResponse {
// Transformation logic here
// ...
}
}
In this example, FiservTransformer
contains methods that adapt a generic PaymentRequest
into a FiservPaymentRequest
and a FiservPaymentResponse
back into a generic PaymentResponse
.
Code Explanation:
-
adapt_request
takes in aPaymentRequest
object from Hyperswitch's core module and transforms it into aFiservPaymentRequest
that thefiserv
service can understand. -
adapt_response
performs the opposite transformation, taking a response from thefiserv
service and transforming it into a standardPaymentResponse
object that Hyperswitch can handle.
Benefits:
- Flexibility: The Adapter pattern enables you to incorporate new functionalities without altering existing code, thereby adhering to the Open/Closed Principle.
- Reduced Complexity: By creating an intermediary layer, the Adapter pattern simplifies interaction between different interfaces.
- Increased Reusability: It allows code that was originally designed for different interfaces to be reused, saving both time and effort.
The Composite Pattern
The Composite pattern allows you to compose objects into tree-like structures to represent part-whole hierarchies. This pattern treats both individual objects and their compositions uniformly, meaning you can interact with them in the same way regardless of their complexity.
Common use-cases for the Composite pattern include:
- Graphics Systems: Representing shapes and groups of shapes in a 2D or 3D graphics system.
- File Systems: Representing files and directories.
- UI Frameworks: Handling complex UI elements made up of smaller components.
How it's implemented in Hyperswitch (focusing on Enums and Structs)
In Rust, and particularly in Hyperswitch, enums and structs are often used to implement the Composite pattern. Enums can represent a choice between multiple types, while structs can be used to build more complex types by composing simpler ones.
Here's a simplified code example to illustrate:
// Inside core/models.rs or a similar file
// A simple enum representing different types of payment methods
enum PaymentMethod {
CreditCard,
DebitCard,
BankTransfer,
}
// A struct representing a customer
struct Customer {
name: String,
email: String,
payment_method: PaymentMethod, // Composing enum into struct
}
// A struct representing a payment request
struct PaymentRequest {
customer: Customer, // Composing struct into another struct
amount: f64,
currency: String,
}
Code Explanation:
PaymentMethod
is an enum that represents different types of payment methods.Customer
is a struct that contains various fields, includingPaymentMethod
, effectively composing the enum into the struct.PaymentRequest
is another struct that includes aCustomer
object, thereby forming a part-whole hierarchy.
Benefits:
- Uniformity: The Composite pattern allows you to treat individual objects and compositions of objects uniformly.
- Simplicity: It simplifies client code, as clients can treat complex compositions and individual objects the same way.
- Extensibility: Adding new components or compositions becomes easier, as they all adhere to a common interface.
Comparison: When to Use Adapter vs. Composite
Understanding design patterns is just the first step; knowing when to apply them is equally important. The Adapter and Composite patterns, while both structural, serve different purposes and are best suited for different scenarios. Let's break down some key differences to help you decide when to use which.
Adapter Pattern
- Purpose: To allow two incompatible interfaces to work together.
-
Best Suited For:
- Legacy code integration.
- Third-party library usage.
- Code reusability across different interfaces.
-
Characteristics:
- Adds an extra layer of abstraction.
- Usually involves transforming data or functions from one interface to another.
Composite Pattern
- Purpose: To compose objects into tree-like structures to represent part-whole hierarchies.
-
Best Suited For:
- Hierarchical object structures like graphics systems, file systems, or complex UI components.
- Situations where you want to treat individual objects and compositions of objects uniformly.
-
Characteristics:
- Simplifies client code by treating individual and composite objects similarly.
- Can make the design overly general if not managed carefully.
Key Differences
Nature of Composition: Adapter involves composition at a functional level (i.e., adapting functions or methods), while Composite involves composition at an object level (i.e., building complex objects from simpler ones).
Client Interaction: In Adapter, the client usually interacts with the adapted interface only. In Composite, the client may need to interact with both individual and composite objects.
Flexibility vs. Structure: Adapter provides more flexibility in integrating different interfaces but can add complexity. Composite provides a structured way to build object hierarchies but can become overly complex if not managed well.
Conclusion
In the evolving landscape of software engineering, design patterns remain a constantβproviding proven solutions to recurring problems. In this article, we've delved into two such indispensable Structural Design Patterns: the Adapter and Composite patterns. Through real-world examples from the Hyperswitch library, we've seen how these patterns are more than just theoretical constructs; they are practical tools that can simplify code, enhance reusability, and pave the way for scalable and maintainable software.
Understanding the Adapter pattern has shown us the power of flexibility, enabling us to integrate disparate interfaces seamlessly. On the other hand, the Composite pattern has demonstrated the strength of structured object composition, allowing for easier management and extension of complex object hierarchies.
While both patterns have their own sets of benefits and drawbacks, recognizing when to use each can be a game-changer in your software development journey. Whether you're integrating with a third-party service or building a complex hierarchical structure, these design patterns offer invaluable insights.
As you continue to explore the world of Rust or any other programming language, keep these design patterns in your toolkit. They are, after all, the building blocks that help bridge the gap between problems and solutions, theory and practice.
Top comments (3)
Interesting π€...
This was nicely done. Easy to understand.
Thank you, I want public a few more articles about Rust and you can subscribe on my blog.