In case you are into web development, you might be familiar with the concept of HTTP middleware, which is common in microframeworks (Slim PHP, Silex), custom stack APIs and even some of the full stack frameworks (Laravel, CakePHP) and can be added to most other frameworks too.
But have you ever thought of using middleware in your business logic? Does it make sense? Let's find out.
Imagine a fairly simple calculation of price of an order in an e-commerce solution:
function calculateOrderSum(Order $order)
{
// calculate the sum of the item prices
$subtotal = array_reduce(
$order->items,
fn($carry, $item) => $carry + $item->price,
0
);
// calculate coupon discount
$discount = CouponService::discountForCoupon($order->coupon);
// calculate VAT
$vat = $subtotal * 0.1;
// calculate shipping cost
$shipping = ShippingService::calculateShipping(
$order->delivery->region
);
// calculate total
return $subtotal + $discount + $vat + $shipping;
}
This is quite simple and looks OK.
Now imagine the same calculation, but for a multi-tenant app, or even a SAAS solution.
Imagine a single code base deployed to the cloud serving multiple domains with different e-shops.
Each tenant can use certain addons or implement specific way to calculate the price:
function calculateOrderSum(Order $order, Tenant $tenant)
{
// calculate the sum of the items
$subtotal = array_reduce(
$order->items,
fn($carry, $item) => $carry + $item->price,
0
);
// apply coupon discount
$discount = 0;
if ($tenant->usesCoupons) {
$discount += CouponService::discountForCoupon($order->coupon);
}
if ($tenant->usesPriceBasedDiscounts) {
$discount += DiscountService::calculateForSubtotal($subtotal);
}
// apply VAT
$vat = VatService::calculateVat($subtotal + $discount);
// calculate shipping cost
$shipping = 0;
if ($subtotal < $tenant->shippingFreeMinimum) {
$shipping = ShippingService::calculateShipping(
$order->delivery->region
);
}
// calculate total
return $subtotal + $discount + $vat + $shipping;
}
Above, we only added 2 simple conditions and already the solution became less legible.
We needed to add the Tenant
parameter to a calculation that should not care for tenants and such, it's not its concern ✋.
In reality, tenants will offer loayalty discounts, bundle discounts and whatnot. Tenants in different regions will calculate VAT differently and may even include some other tax than VAT. That's a lot of ifs and thens!
What is more, this tangled mess becomes very hard to test.
Decomposition
The above algorithm can be decomposed to several simple blocks:
$subtotalCalc = function (Order $order, callable $next) {
$price = array_reduce(
$order->items,
fn($carry, $item) => $carry + $item->price,
0
);
return $next($order) + $price;
};
$couponDiscountCalc = function (Order $order, callable $next) {
return $next($order) + CouponService::discountForCoupon(
$order->coupon
);
};
$discountCalc = function (Order $order, callable $next) {
$subtotal = $next($order);
return $subtotal + DiscountService::calculateForSubtotal($subtotal);
};
$shippingCalc = function (Order $order, callable $next) {
return $next($order) + ShippingService::calculateShipping(
$order->delivery->region
);
};
// a parameterized factory:
$vatCalcFactory = function (float $vat): callable {
return function (Order $order, callable $next) use ($vat) {
return $next($order) * (1 + $vat / 100);
};
};
Each of them is independent and can very simply be unit tested. What is more, we can create them on the fly and parameterize them as we need.
A huge benefit is that the complexity of the algorithm does not increase with added logic, since you are only adding independent blocks.
Blocks of code like these are called middleware. Observe the base structure of a middleware:
function middleware(mixed $passable, callable $next): mixed {
// 1. code that happens before the next middleware
// Note: this code may alter the argument
// 2. invoke the next middleware
// Note: this step is optional too
$result = $next($passable);
// 3. code that happens after the next middleware returns
// Note: this code may alter the result
return $result;
}
When composed, a middleware stack may be perceived as layers of an onion added one on top of the other. Note that the last added (outer-most) layer is executed first.
Composition
Now we can elegantly build different computation pipelines for each tenant.
$bikeShop = Pipeline::onion([
$subtotalCalc,
$couponDiscountCalc,
$vatCalcFactory(10.0),
$shippingCalc,
]);
$europeanToolsShed = Pipeline::onion([
$subtotalCalc,
$couponDiscountCalc,
$discountCalc,
LoyaltyService::forCustomer($currentlyAuthenticatedUser),
$vatCalcFactory(20.0),
$shippingCalc,
]);
To calculate the order's total, we only need to invoke the proper pipeline, like so:
$total = $bikeShop($order);
// or
$total = $europeanToolsShed($order);
I'm using the Pipeline
class from my own package dakujem/cumulus
, but there are other middleware/pipeline dispatchers out there. Just make sure not to confuse them with HTTP middleware dispatchers, they are different animals.
Wrapping Up
Using middleware pattern and composition, it is possible to reduce complexity of a complex calculation resulting in easier to understand and easier to test codebase.
This pattern is especially useful when the actual steps of a calculation are not known beforehand (avoiding heaps of if
s!).
If you are new to middleware, be sure to google some info on HTTP middleware and PSR-15.
To build and dispatch simple pipelines within your domain logic, check out the Pipeline
dispatcher.
Top comments (0)