“Good design is like evolution—it thrives through small, independent compositions. Inheritance builds rigidity, while composition creates life’s beautiful adaptability.” – Richard Dawkins
The quote above paints a picture of composability over inheritance in good (software) design. Composition over Inheritance is a phrase I have encountered many times throughout my career.
What it truly meant and why it was so widely advocated remained a mystery to me—until it wasn’t.
I’m not going to spend time discussing why inheritance is bad or why composition is better. Instead, I’d like to highlight the beauty of composition as a foundation for building flexible software.
Composition in Practice
In the beginning there was composition, and from it all things crafted piece by piece, taking shape as intended by all.
Inversion of Control (IoC), the Dependency Inversion Principle (DIP), and Dependency Injection (DI) are all offshoots of Object-Oriented Programming (OOP)—big terms often tossed around as though they carry some mystic weight. But at the heart of all these concepts is the same principle of flexibility and modularity—what some might call the “gospel of Composition over Inheritance.”
Let us take a simple example, Integrating Payment Providers. In this scenario, our application supports multiple providers that more or less perform the same operations.
At first glance, you might think of inheritance—creating a base class and deriving specific provider classes. However, this tightly couples the implementation, making future changes or integrations harder. Instead, what you should seek is composition as it allows us to swap out these providers seamlessly without affecting other parts of the code-base.
Let’s start by describing the various functionalities of our processors.
interface PaymentProcessor {
processPayment(amount: number): Promise<boolean>;
refundPayment(transactionId: string): Promise<boolean>;
getTransactionDetails(transactionId: string): Promise<any>;
}
Quite straightforward, isn’t it? Now we know what our providers can do, as long as they obey this interface of course.
Now we can have our various payment providers implement the PaymentProcessor
interface.
class FincraProcessor implements PaymentProcessor {
async processPayment(amount: number): Promise<boolean> {
console.log(`Processing $${amount} payment through Fincra`);
// Fincra-specific implementation
return true;
}
async refundPayment(transactionId: string): Promise<boolean> {
console.log(`Refunding Fincra transaction ${transactionId}`);
// Fincra-specific implementation
return true;
}
async getTransactionDetails(transactionId: string): Promise<any> {
console.log(`Fetching details for Fincra transaction ${transactionId}`);
// Fincra-specific implementation
return { id: transactionId, processor: 'Fincra' };
}
}
class PayStackProcessor implements PaymentProcessor {
async processPayment(amount: number): Promise<boolean> {
console.log(`Processing $${amount} payment through PayStack`);
// PayStack-specific implementation
return true;
}
async refundPayment(transactionId: string): Promise<boolean> {
console.log(`Refunding PayStack transaction ${transactionId}`);
// PayStack-specific implementation
return true;
}
async getTransactionDetails(transactionId: string): Promise<any> {
console.log(`Fetching details for Paystack transaction ${transactionId}`);
// PayStack-specific implementation
return { id: transactionId, processor: 'PayStack' };
}
}
Next on our agenda is how to make proper use of both provider implementations. For that, we need a service that utilizes the providers irrespective of their implementation. A PaymentService
will do just fine for our application.
class PaymentService {
private _processor: PaymentProcessor;
constructor(processor: PaymentProcessor) {
this._processor = processor;
}
get processor() {
return this._processor;
}
set processor(processor: PaymentProcessor) {
this._processor = processor;
}
async makePayment(amount: number): Promise<boolean> {
return this._processor.processPayment(amount);
}
async refund(transactionId: string): Promise<boolean> {
return this._processor.refundPayment(transactionId);
}
async getPaymentDetails(transactionId: string): Promise<any> {
return this._processor.getTransactionDetails(transactionId);
}
}
With this, we have a composable system that allows us to easily switch between payment processors in our application without any dependency on one service over another.
async function main() {
const fincraProcessor = new FincraProcessor();
const paystackProcessor = new PayStackProcessor();
const paymentService = new PaymentService(fincraProcessor);
// Process payment with Fincra
await paymentService.makePayment(100);
// Switch to PayStack and process another payment
paymentService.processor = paystackProcessor;
await paymentService.makePayment(150);
// Refund the PayStack payment
await paymentService.refund('paystack-transaction-id');
}
main().catch(console.error);
Now we have a decent composable payment system, but wait, there’s a small chink in our implementation armor.
In a real scenario, most payment providers do not use the same parameters to perform the same operations, and our implementation does not account for that.
An interesting problem but one with a simple solution: we set up a simple Adapter
that acts as a means of standardization of our interface. The code gets a little makeover but it’s still the same idea. So let us start with the interface.
interface PaymentProcessor<P, R> {
processPayment(args: P): Promise<boolean>;
refundPayment(args: R): Promise<boolean>;
getTransactionDetails(transactionId: string): Promise<any>;
}
Yeah, I know, generics probably aren’t your favorite, but they do work very well for our use case.
It’s usually best to define the types for our various payment gateways, so that should follow next.
type FincraPaymentArgs = {
amount: number;
currency: string;
source: string;
};
type PayStackPaymentArgs = {
total: number;
currency: string;
paymentMethod: string;
};
type FincraRefundArgs = {
charge: string;
amount?: number;
};
type PayStackRefundArgs = {
saleId: string;
amount: {
total: number;
currency: string;
};
};
With that out of the way, we can now add our provider-specific implementation as we did earlier.
class FincraProcessor implements PaymentProcessor<FincraPaymentArgs, FincraRefundArgs> {
async processPayment(args: FincraPaymentArgs): Promise<boolean> {
console.log(`Processing ${args.amount} ${args.currency} payment through Fincra`);
// Fincra-specific implementation
return true;
}
async refundPayment(args: FincraRefundArgs): Promise<boolean> {
console.log(`Refunding Fincra charge ${args.charge}`);
// Fincra-specific implementation
return true;
}
async getTransactionDetails(transactionId: string): Promise<any> {
console.log(`Fetching details for Fincra transaction ${transactionId}`);
// Fincra-specific implementation
return { id: transactionId, processor: 'Fincra' };
}
}
class PaystackProcessor implements PaymentProcessor<PaystackPaymentArgs, PaystackRefundArgs> {
async processPayment(args: PaystackPaymentArgs): Promise<boolean> {
console.log(`Processing ${args.total} ${args.currency} payment through Paystack`);
// Paystack-specific implementation
return true;
}
async refundPayment(args: PaystackRefundArgs): Promise<boolean> {
console.log(`Refunding Paystack sale ${args.saleId}`);
// Paystack-specific implementation
return true;
}
async getTransactionDetails(transactionId: string): Promise<any> {
console.log(`Fetching details for Paystack transaction ${transactionId}`);
// Paystack-specific implementation
return { id: transactionId, processor: 'Paystack' };
}
}
With our provider-specific implementation done, we can now go ahead with actually setting up our adapter.
interface PaymentAdapter {
processPayment(amount: number, currency: string, paymentDetails: any): Promise<boolean>;
refundPayment(transactionId: string, amount: number, currency: string): Promise<boolean>;
getTransactionDetails(transactionId: string): Promise<any>;
}
The PaymentAdapter
and PaymentProcessor
interfaces are similar in the sense that they share similar functionalities but with different parameters. We will see why this is the case when we look at the adapter implementations.
class FincraAdapter implements PaymentAdapter {
private _processor: FincraProcessor;
constructor(processor: FincraProcessor) {
this._processor = processor;
}
async processPayment(amount: number, currency: string, paymentDetails: any): Promise<boolean> {
const fincraArgs: FincraPaymentArgs = {
amount,
currency,
source: paymentDetails.token
};
return this._processor.processPayment(fincraArgs);
}
async refundPayment(transactionId: string, amount: number, currency: string): Promise<boolean> {
const refundArgs: FincraRefundArgs = {
charge: transactionId,
amount
};
return this._processor.refundPayment(refundArgs);
}
async getTransactionDetails(transactionId: string): Promise<any> {
return this._processor.getTransactionDetails(transactionId);
}
}
class PaystackAdapter implements PaymentAdapter {
private _processor: PaystackProcessor;
constructor(processor: PaystackProcessor) {
this._processor = processor;
}
async processPayment(amount: number, currency: string, paymentDetails: any): Promise<boolean> {
const paystackArgs: PaystackPaymentArgs = {
total: amount,
currency,
paymentMethod: paymentDetails.method
};
return this._processor.processPayment(paystackArgs);
}
async refundPayment(transactionId: string, amount: number, currency: string): Promise<boolean> {
const refundArgs: PaystackRefundArgs = {
saleId: transactionId,
amount: {
total: amount,
currency
}
};
return this._processor.refundPayment(refundArgs);
}
async getTransactionDetails(transactionId: string): Promise<any> {
return this._processor.getTransactionDetails(transactionId);
}
}
The adapter implementation for each payment provider/gateway provides each service with a unified interface for passing required parameters used in carrying out their respective functionalities. This allows ease of use when switching between payment processors in the code. While this is all great, the downsides of composable application design have already begun to show their ugly head, but that will be discussed later. Now it is time to implement the actual PaymentService
that will be used in the application. We’ll rely on Dependency Injection here to make the service composable, providing the flexibility of changing the PaymentAdapter
even at runtime.
class PaymentService {
private _adapter: PaymentAdapter;
constructor(adapter: PaymentAdapter) {
this._adapter = adapter;
}
set adapter(adapter: PaymentAdapter) {
this._adapter = adapter;
}
async makePayment(amount: number, currency: string, paymentDetails: any): Promise<boolean> {
return this._adapter.processPayment(amount, currency, paymentDetails);
}
async refund(transactionId: string, amount: number, currency: string): Promise<boolean> {
return this._adapter.refundPayment(transactionId, amount, currency);
}
async getPaymentDetails(transactionId: string): Promise<any> {
return this._adapter.getTransactionDetails(transactionId);
}
}
Now that’s all done, let us see how it all comes together.
async function main() {
const fincraProcessor = new FincraProcessor();
const paystackProcessor = new PaystackProcessor();
const fincraAdapter = new FincraAdapter(fincraProcessor);
const paystackAdapter = new PaystackAdapter(paystackProcessor);
const paymentService = new PaymentService(fincraAdapter);
// Process payment with Fincra
await paymentService.makePayment(100, 'CFA', { token: 'fincra_token_123' });
// Switch to Paystack and process another payment
paymentService.adapter = paystackAdapter;
await paymentService.makePayment(150, 'NGN', { method: 'paystack' });
// Refund the Paystack payment
await paymentService.refund('paystack-transaction-id', 150, 'NGN');
}
main().catch(console.error);
The composability of the design is evident above, allowing us to switch between processors at runtime without any fear of breaking our application. This level of flexibility isn’t quite possible with inheritance.
A few things can also be added to improve the composability of our design, for example, an AdapterFactory
. I would have implemented that too, but sadly I came down with a case of “laziness” while working on this article, so as a result, further implementation to improve the application composability is an assignment for the reader.
Composition in Hindsight
Composition over Inheritance
, we did well to keep Inheritance away from our design and prevent any tight coupling and rigid dependencies whatsoever, providing us with flexibility and loose coupling, offering ease when writing tests. Such are the benefits of Composable
software design.
That being said, it doesn’t come without its drawbacks, and at a glance, the drawbacks are very obvious. If you haven’t caught on yet, let me help you out… there's too much f**king code
.
Exactly, this design approach tends to require a lot of code and thus brings along its favorite companion complexity, especially for use cases similar to our example.
These drawbacks imply that composition isn’t a silver bullet, nor a hammer that turns every problem into a nail, and as with every aspect of software design, the right design is needed at the right time. What situations beckon the use of composition is a discussion for another day.
For now, I hope you see the message I have tried to pass across and, more importantly, it raises questions in your mind. Till next time.
Leave a Reply