In real-world applications, good feature design is crucial for the health of your application in the long run.
Avoiding classes with unclear intents, bloated structures, and an excessive load of responsibilities is crucial to ensure our application remains maintainable and adaptable, not resistant to change. Thus saving us and the customers time and money.
In this article, I aim to share with you my favoured approach to designing fine-grained, autonomous features that promotes extension over change, and isolate change is needed.
Fine-Grained features
Fine-grained features are designed with a singular purpose, adapting only when their requirements change. They function independently of other features and strictly adhere to the Single Responsibility Principle (the ‘S’ in SOLID).
To apply this principle, I prefer utilizing handlers for each distinct feature. Handlers serve as domain-specific public interfaces, existing solely for the duration of the feature’s existence.
For example, for a payment processor handler public interface:
<?php
namespace App\Domain\Payment;
use App\Entity\Client;
interface PaymentProcessor
{
public function supports(Client $client): bool;
public function handle(float $amount, Client $client): float;
}
Crafting “fine-grained” features doesn’t imply overlooking the significance of reusability. Your handlers can leverage generic, reusable services. The primary objective is to express the intent of each feature clearly and avoid unnecessary complexity. This approach ensures that each feature operates independently. Consequently, if a bug is introduced in one feature, the rest of the application remains unaffected. I like to draw a parallel: A highway — Hence the feature image — designed for high-speed traffic and long distances, minimizing accidents compared to standard roads; Also Fine-grained features promotes features independence, readability, and reduces bugs probability.
Use case
Consider a user payment feature according to the following business requirements:
business-requirement-payment-processor.md
Consider the following business requirements:
1. **Loyalty Discount:**
- **Condition:** The system should provide a loyalty discount based on the client's loyalty tier.
- **Requirement:** Clients with a "Gold" loyalty tier should receive a 10% discount, and clients with a "Silver" loyalty tier should receive a 5% discount.
2. **Purchase History Discount:**
- **Condition:** The system should offer an additional discount based on the client's purchase history.
- **Requirement:** Clients with a purchase history greater than 5 should receive an extra 8% discount.
3. **Order Total Discount:**
- **Condition:** The system should apply discounts based on the total amount of the order.
- **Requirement:** Orders over $1000 should receive a 15% discount, and orders over $500 should receive a 5% discount.
4. **Payment Method Discount:**
- **Condition:** The system should provide discounts based on the chosen payment method.
- **Requirement:** Credit card payments should receive a 10% discount, and digital wallet payments should receive a 7% discount.
5. **Referral Discount:**
- **Condition:** The system should apply a referral discount based on the existence of a referral code and the client's purchase history.
- **Requirement:** If a referral code is provided, clients with a purchase history over 10 should receive a 20% referral discount.
6. **Promotional Discount:**
- **Condition:** The system should offer discounts during promotional periods.
- **Requirement:** During the "HolidaySale" promotional period, a 20% discount should be applied.
7. **First Purchase Discount:**
- **Condition:** The system should provide a discount for the client's first purchase.
- **Requirement:** Clients making their first purchase should receive a 15% discount.
8. **Subscription Discount:**
- **Condition:** The system should apply discounts based on the type of subscription chosen.
- **Requirement:** Clients with a premium subscription should receive a 20% discount, and clients with a standard subscription should receive a 10% discount.
9. **Customer Segment Discount:**
- **Condition:** The system should offer discounts based on the customer segment.
- **Requirement:** VIP customers should receive a 15% discount, and corporate customers should receive a 12% discount.
An implementation aligned with these requirements could be represented as follows:
OriginalComplexPaymentService.php
<?php
namespace App\Service;
use App\Entity\Client;
use Service\TransactionService;
class PaymentService
{
public function __construct(private TransactionService $transactionService)
{
}
public function processPayment(Client $client, float $orderTotal): void
{
$finalAmount = $orderTotal;
// Condition 1: Customer Loyalty Tier
if ($client->getLoyaltyTier()->isGold()) {
$finalAmount *= 0.9; // 10% discount for Gold tier
} elseif ($client->getLoyaltyTier()->isSilver()) {
$finalAmount *= 0.95; // 5% discount for Silver tier
}
// Condition 2: Purchase History
if ($client->getPurchaseHistory() > 5) {
$finalAmount *= 0.92; // Additional 8% discount for loyal customers
}
// Condition 3: Order Total
if ($orderTotal > 1000) {
$finalAmount *= 0.85; // 15% discount for large orders
} elseif ($orderTotal > 500) {
$finalAmount *= 0.95; // 5% discount for medium-sized orders
}
// Condition 4: Payment Method
switch ($client->getPaymentMethod()) {
case 'Credit Card':
$finalAmount *= 0.9; // 10% discount for credit card payments
break;
case 'Digital Wallet':
$finalAmount *= 0.93; // 7% discount for digital wallet payments
break;
// Add more cases for other payment methods
}
// Condition 5: Referral Program
if (!empty($client->getReferralCode())) {
$referralDiscount = ($client->getPurchaseHistory() > 10) ? 0.2 * 1.5 : 0.2;
$finalAmount *= (1 - $referralDiscount); // Apply referral discount
}
// Condition 6: Promotional Period
if ($client->getPromotinalPeriod() == 'HolidaySale') {
$finalAmount *= 0.8; // 20% discount during holiday sale
}
// Condition 7: First Purchase
if ($client->isFirstPurchase()) {
$finalAmount *= 0.85; // 15% discount for the first purchase
}
// Condition 8: Subscription-based Discounts
if ($client->getSubscription()->isPremium()) {
$finalAmount *= 0.8; // 20% discount for premium subscriptions
} elseif ($client->getSubscription()->isBasic()) {
$finalAmount *= 0.9; // 10% discount for basic subscriptions
}
// Condition 9: Customer Segment
if ($client->getCustomerSegment() == 'VIP') {
$finalAmount *= 0.85; // 15% discount for VIP customers
} elseif ($client->getCustomerSegment() == 'Corporate') {
$finalAmount *= 0.88; // 12% discount for corporate customers
}
// we create the transaction
$this->transactionService->create($finalAmount, $client);
}
}
The readability of this code is compromised due to its complexity, and it lacks maintainability while carrying an excessive load of responsibilities. When the functionality evolves and new requirements are introduced, the complexity intensifies. Consequently, making changes to the existing code becomes imperative, posing a potential risk to the current functionality and unintentionally introducing new bugs.
Improving code readability
We could refactor these requirements to be more expressive.
PaymentServiceFirstRefactoring.php
<?php
namespace App\Service;
use App\Entity\Client;
use App\Entity\LoyaltyTier;
use App\Entity\Subscription;
use Service\TransactionService;
class PaymentService
{
public function __construct(private TransactionService $transactionService)
{
}
public function processPayment(Client $client, float $orderTotal): void
{
$finalAmount = $this->applyLoyaltyDiscount($orderTotal, $client->getLoyaltyTier());
$finalAmount = $this->applyPurchaseHistoryDiscount($finalAmount, $client->getPurchaseHistory());
$finalAmount = $this->applyOrderTotalDiscount($finalAmount, $orderTotal);
$finalAmount = $this->applyPaymentMethodDiscount($finalAmount, $client->getPaymentMethod());
$finalAmount = $this->applyReferralDiscount($finalAmount, $client->getReferralCode(), $client->getPurchaseHistory());
$finalAmount = $this->applyPromotionalDiscount($finalAmount, $client->getPromotionalPeriod());
$finalAmount = $this->applyFirstPurchaseDiscount($finalAmount, $client->isFirstPurchase());
$finalAmount = $this->applySubscriptionDiscount($finalAmount, $client->getSubscription()->getType());
$finalAmount = $this->applyCustomerSegmentDiscount($finalAmount, $client->getCustomerSegment());
$this->transactionService->create($finalAmount, $client);
}
private function applyLoyaltyDiscount(float $amount, LoyaltyTier $loyaltyTier): float
{
if ($loyaltyTier->isGold()) {
return $amount * 0.9; // 10% discount for Gold tier
}
if ($loyaltyTier->isSilver()) {
return $amount * 0.95; // 5% discount for Silver tier
}
return $amount;
}
private function applyPurchaseHistoryDiscount(float $amount, int $purchaseHistory): float
{
if ($purchaseHistory > 5) {
return $amount * 0.92; // Additional 8% discount for loyal customers
}
return $amount;
}
private function applyOrderTotalDiscount(float $amount, float $orderTotal): float
{
if ($orderTotal > 1000) {
return $amount * 0.85; // 15% discount for large orders
}
if ($orderTotal > 500) {
return $amount * 0.95; // 5% discount for medium-sized orders
}
return $amount;
}
private function applyPaymentMethodDiscount(float $amount, string $paymentMethod): float
{
switch ($paymentMethod) {
case 'Credit Card':
return $amount * 0.9; // 10% discount for credit card payments
case 'Digital Wallet':
return $amount * 0.93; // 7% discount for digital wallet payments
}
return $amount;
}
private function applyReferralDiscount(float $amount, string $referralCode, int $purchaseHistory): float
{
if (!empty($referralCode)) {
$referralDiscount = ($purchaseHistory > 10) ? 0.2 * 1.5 : 0.2;
return $amount * (1 - $referralDiscount); // Apply referral discount
}
return $amount;
}
private function applyPromotionalDiscount(float $amount, string $promotionalPeriod): float
{
if ($promotionalPeriod == 'HolidaySale') {
return $amount * 0.8; // 20% discount during holiday sale
}
return $amount;
}
private function applyFirstPurchaseDiscount(float $amount, bool $isFirstPurchase): float
{
if ($isFirstPurchase) {
return $amount * 0.85; // 15% discount for the first purchase
}
return $amount;
}
private function applySubscriptionDiscount(float $amount, Subscription $subscription): float
{
if ($subscription->isPremium()) {
return $amount * 0.8; // 20% discount for premium subscriptions
}
if ($subscription->isBasic()) {
return $amount * 0.9; // 10% discount for basic subscriptions
}
return $amount;
}
private function applyCustomerSegmentDiscount(float $amount, string $customerSegment): float
{
if ($customerSegment == 'VIP') {
return $amount * 0.85; // 15% discount for VIP customers
} elseif ($customerSegment == 'Corporate') {
return $amount * 0.88; // 12% discount for corporate customers
}
return $amount;
}
}
The code is enhanced in readability, allowing us to readily grasp the class’s purpose at first glance. It can be comprehended effortlessly, akin to reading book titles.
While there is a notable improvement in readability, the code lacks extensibility. Despite the enhancement, adding new features or deactivating certain discounts requires modifying and testing this class. Additionally, the code still carries the same set of responsibilities.
Strategy Pattern to the rescue
The Strategy Pattern is a behavioural design pattern that defines a family of algorithms, encapsulates each algorithm, and makes them interchangeable
We will leverage this pattern to reduce the complexity of this class, extend it’s reusability, and isolate each behaviour from the rest.
We will first define and interface that abstract the payment processing behaviour
<?php
namespace App\Domain\Payment;
use App\Entity\Client;
interface PaymentProcessor
{
public function handle(float $amount, Client $client): float;
}
We then leverage our paymentProcessor interface to handle payment processing logic.
<?php
namespace App\Service;
use App\Entity\Client;
use App\Domain\Payment\PaymentProcessor;
use Service\TransactionService;
class PaymentService
{
/**
* @param iterable $processors<PaymentProcessor>
*/
public function __construct(
private TransactionService $transactionService,
private iterable $processors
) {
}
public function processPayment(Client $client, float $orderTotal): void
{
$finalAmount = $orderTotal;
foreach ($this->processors as $processor) {
$finalAmount = $processor->handle($finalAmount, $client);
}
$this->transactionService->create($finalAmount, $client);
}
}
For each Business requirement or feature, we can define a new Concrete Payment Processor :
PurchaseHistoryDiscountProcessor.php
<?php
namespace App\Domain\Payment;
use App\Entity\Client;
class PurchaseHistoryDiscountProcessor implements PaymentProcessor
{
public function handle(float $amount, Client $client): float
{
if ($client->getPurchaseHistory() > 5) {
return $amount * 0.92; // Additional 8% discount for loyal customers
}
return $amount;
}
}
<?php
namespace Domain\Payment;
use App\Entity\Client;
use App\Domain\Payment\PaymentProcessor;
class LoyaltyDiscountProcessor implements PaymentProcessor
{
public function handle(float $amount, Client $client): float
{
$loyaltyTier = $client->getLoyaltyTier();
if ($loyaltyTier->isGold()) {
return $amount * 0.9; // 10% discount for Gold tier
}
if ($loyaltyTier->isSilver()) {
return $amount * 0.95; // 5% discount for Silver tier
}
return $amount;
}
}
To use our service we need define the processors, we may define it in basic way like the following (Nothing inherently wrong about it).
SimpleUsageWithoutSymfonyDI.php
<?php
// Given $transactionService is already instanciated
$processors = [
new LoyaltyDiscountProcessor(),
new PurchaseHistoryDiscountProcessor(),
// ... Other processors
];
$paymentService = new PaymentService($transactionService, $processors);
$paymentService->processPayment();This is not optimal, the best solution would be leveraging the Dependency Injection mechanism available in your favourite framework to inject our processors.
The magic of Symfony
My favourite PHP framework Symfony 💕 provides us with a great feature easing the implementation of the strategy pattern in most elegant way.
We only need to configure our services, using symfony tags to tags all PaymentProcessor instances:
services:
_instanceof:
App\Domain\Payment\PaymentProcessor:
tags: ['app.payment.processor']
Using the TaggedIterator Attribute, we can inject our processors elegantly:
<?php
namespace App\Service;
use App\Entity\Client;
use App\Domain\Payment\PaymentProcessor;
use Service\TransactionService;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
class PaymentService
{
/**
* @param array $processors<PaymentProcessor>
*/
public function __construct(
#[TaggedIterator(tag: 'app.payment.processor')]
private iterable $processors,
private TransactionService $transactionService,
) {
}
public function processPayment(Client $client, float $orderTotal): void
{
$finalAmount = $orderTotal;
foreach ($this->processors as $processor) {
$finalAmount = $processor->handle($finalAmount, $client);
}
$this->transactionService->create($finalAmount, $client);
}
}
We can simplify the tag configuration using the PaymentProcess interface as the following:
<?php
namespace App\Service;
use App\Entity\Client;
use App\Domain\Payment\PaymentProcessor;
use Service\TransactionService;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
class PaymentService
{
/**
* @param iterable $processors<PaymentProcessor>
*/
public function __construct(
#[TaggedIterator(PaymentProcessor::class)]
private iterable $processors,
private TransactionService $transactionService,
) {
}
// ...
}<?php
namespace App\Domain\Payment;
use App\Entity\Client;
#[AutoconfigureTag]
interface PaymentProcessor
{
public function handle(float $amount, Client $client): float;
}As Adamo Crespi suggested in the comments below, It is convenient to auto configure the PaymentProcess using #[AutoConfigureTag] to get rid of the service.yaml configuration. It is much cleaner this way👌.
<?php
namespace App\Domain\Payment;
use App\Entity\Client;
#[AutoconfigureTag]
interface PaymentProcessor
{
public function handle(float $amount, Client $client): float;
}The PaymentService now has a significantly reduced scope and responsibility. It no longer concerns itself with the details of how the payment has been calculated; its sole responsibility is to facilitate the payment process.
The feature is now extensible 🎉. To add or remove a new processor, we simply include a new Concrete Processor or delete the existing one. The PaymentService remains unaware of these changes.
The feature is now maintainable, and isolates change ✅. If we wish to modify the `LoyaltyDiscountProcessor`, we only need to alter this specific class. This implies that we only need to test the behavior of this class, as the other processors remain unaffected by this change.
✅ These features are now fine-grained, highly specific, articulate their intent clearly, and each performs a singular task. They adhere to the Single Responsibility Principle.
Improving Extensibility
to improve extensibility and readability even further. We may define a new method, in the paymentProcessor interface, supports:
<?php
namespace App\Domain\Payment;
use App\Entity\Client;
interface PaymentProcessor
{
public function supports(Client $client): bool;
public function handle(float $amount, Client $client): float;
}
Processor must implement the supports method:
<?php
namespace Domain\Payment;
use App\Entity\Client;
use App\Domain\Payment\PaymentProcessor;
class LoyaltyDiscountProcessor implements PaymentProcessor
{
public function supports(Client $client): bool
{
return $client->getLoyaltyTier()->isGold() || $client->getLoyaltyTier()->isSilver();
}
public function handle(float $amount, Client $client): float
{
$loyaltyTier = $client->getLoyaltyTier();
if ($loyaltyTier->isGold()) {
return $amount * 0.9; // 10% discount for Gold tier
}
if ($loyaltyTier->isSilver()) {
return $amount * 0.95; // 5% discount for Silver tier
}
return $amount;
}
}
The payment service will be transformed to:
<?php
namespace App\Service;
use App\Entity\Client;
use App\Domain\Payment\PaymentProcessor;
use Service\TransactionService;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
class PaymentService
{
/**
* @param iterable $processors<PaymentProcessor>
*/
public function __construct(
#[TaggedIterator(tag: 'app.payment.processor')]
private iterable $processors,
private TransactionService $transactionService,
) {
}
public function processPayment(Client $client, float $orderTotal): void
{
$finalAmount = $orderTotal;
foreach ($this->processors as $processor) {
if ($processor->supports($client)) {
$finalAmount = $processor->handle($finalAmount, $client);
}
}
$this->transactionService->create($finalAmount, $client);
}
}
This is crucial for two main reasons: it enhances readability and clearly articulates business requirements, while also offering a mechanism to disable the feature when necessary, without requiring code changes — feature flags could be an implementation of this concept.
Feature flags
Feature flags, alternatively referred to as feature toggles or feature switches, represent a software development approach enabling developers to activate or deactivate particular features or functionalities within an application while it is running.
The recently added method enables the deactivation of features at runtime. However, we won’t delve deeply into this topic as it is not the primary focus of this article. Furthermore, there is an insightful article available on this matter: Feature Flag and Strategy pattern with the Symfony framework by Smaine Milianni. The article discusses the use of the Strategy pattern in implementing feature flags. Smaine recommends a distinct method, `isEnabled(string $feature)`, with a singular purpose: to check if a specific feature is enabled. This approach expresses intent more precisely 👌.
Wrapping up
- Embrace the use of handlers to construct features that adhere to the Single Responsibility Principle within a well-defined domain.
- Use handlers to invoke generic reusable services in your application. Handlers mainly emphasizes the clarity of intent.
- Leverage Dependency Injection (DI) for the seamless injection of your handlers (processors in the use case detailed above).
- The Strategy pattern stands out as an excellent solution for designing extensible features.
- Symfony’s Dependency Injection provides an elegant and smooth implementation of the Strategy pattern.
- Consider incorporating feature flags for additional extensibility.
Thanks for reading, consider clapping and sharing 🔗 if you like the content.