Separation of concerns: untangle the request handler
Keep the booking rule apart from HTTP and storage so every caller reuses it.
Watch the narrated video
Every step
Problem
Steps 1–3Refactor
Steps 4–9Payoff
Steps 10–11Recap
Steps 12–13The code: before and after
Before
declare(strict_types=1);
final class BookingHandler
{
private const RATES = ['Golf' => 40, 'Polo' => 35];
/** @var list<string> */
private array $saved = [];
/** @param array<string, string> $params */
public function handle(array $params): string
{
$car = $params['car'];
$days = (int) $params['days'];
if (!isset(self::RATES[$car]) || $days < 1) {
return '400 Bad Request';
}
$total = self::RATES[$car] * $days;
if ($days >= 7) {
$total = intdiv($total * 90, 100);
}
$this->saved[] = "$car,$days,$total";
return "200 OK: $car for $days days, $total USD";
}
public function savedCount(): int
{
return count($this->saved);
}
}
$web = new BookingHandler();
echo $web->handle(['car' => 'Golf', 'days' => '7']), "\n";
echo $web->savedCount(), " saved\n";After
declare(strict_types=1);
final class BookingHandler
{
public function __construct(private readonly BookingService $service) {}
/** @param array<string, string> $params */
public function handle(array $params): string
{
$car = $params['car'];
$days = (int) $params['days'];
try {
$total = $this->service->book($car, $days);
} catch (InvalidArgumentException) {
return '400 Bad Request';
}
return "200 OK: $car for $days days, $total USD";
}
public function savedCount(): int
{
return $this->service->savedCount();
}
}
final class BookingImport
{
public function __construct(private readonly BookingService $service) {}
public function importLine(string $line): void
{
[$car, $days] = explode(',', $line);
$this->service->book($car, (int) $days);
}
}
final class BookingService
{
private const RATES = ['Golf' => 40, 'Polo' => 35];
public function __construct(private readonly BookingStore $store) {}
public function book(string $car, int $days): int
{
if (!isset(self::RATES[$car]) || $days < 1) {
throw new InvalidArgumentException('bad input');
}
$total = $this->price($car, $days);
$this->store->save($car, $days, $total);
return $total;
}
private function price(string $car, int $days): int
{
$total = self::RATES[$car] * $days;
if ($days >= 7) {
$total = intdiv($total * 90, 100);
}
return $total;
}
public function savedCount(): int
{
return $this->store->count();
}
}
final class BookingStore
{
/** @var list<string> */
private array $saved = [];
public function save(string $car, int $days, int $total): void
{
$this->saved[] = "$car,$days,$total";
}
public function count(): int
{
return count($this->saved);
}
}
$service = new BookingService(new BookingStore());
$web = new BookingHandler($service);
$partner = new BookingImport($service);
echo $web->handle(['car' => 'Golf', 'days' => '7']), "\n";
$partner->importLine('Polo,3');
echo $web->savedCount(), " saved\n";Transcript, step by step
Problem
Step 1: One method, one request Before
This is the same car rental desk from last time, and now it takes bookings over the web. A request handler is the method that takes one web request and returns the response, and here that method is handle. It reads the car and the days, checks them, prices the rental with ten percent off for a week or more, keeps the booking, and writes the response. The script at the bottom is the caller, so it sends one web request and prints how many bookings were kept. The run prints Golf for seven days at two hundred fifty two dollars, then one saved. With only one way in, one method that does the whole request is the shortest path, and that's why it was written this way.
Step 2: A nightly partner file Change request
A partner now sends a nightly file of bookings, one line each, like Polo for three days. The quick way adds import Line to the handler, and it splits the line into a car and a number of days. Calling handle would mean building a fake web request and reading two hundred OK or four hundred Bad Request back out of the text, so copying the rule looks shorter. So it repeats the check, then the price with its discount, and then the save. PHPStan is happy with it, and the run now ends with two saved.
Step 3: Tangled concerns Code smell
A concern is one job the code is responsible for, like reading the request, pricing the rental, or keeping the booking. Here the concerns are tangled, because the pricing rule sits inside the code that reads a web request, so a second way in can't reach the rule without copying it. You can see the check here, the discount here, and the save here, and each one is now written twice. The next time the discount starts at five days, someone edits one copy, and partner bookings get a different price than web bookings.
Refactor
Step 4: Refactor first Back it out
So let's take the import back out, refactor first, and then add it. The handler is back to one way in, and the run prints one saved again, so every step from here keeps that output.
Step 5: Name the price Extract Method
A business rule is what the rental desk decides, here which cars exist and the weekly discount. We extract the pricing lines into a method called price, which takes the car and the days and returns the total. Handle now calls price, and the run prints the same two lines as before.
Step 6: One call books it Extract Method
Next we extract book, which holds the check, the price and the save, and returns the total. An unknown car or fewer than one day now throws an invalid argument exception instead of returning text. Handle catches it and returns four hundred Bad Request, so the web response stays the same. That leaves handle with two jobs, reading the request and writing the response, around one call to book.
Step 7: The rule gets a class Extract Class
Now we extract a class, Booking Service, and move the rates, book and price into it. The handler builds a service in its constructor and calls its book method. Saved Count follows the list, so the handler's version now asks the service. The handler's concern is now the web request, and the service's concern is the business rule.
Step 8: Storage moves out Extract Class
We extract one more class, Booking Store, and move the saved list into it. It has save, which takes the car, the days and the total, and count, which returns the size of the list. Book now hands the booking to the store, and the service's Saved Count asks the store too. Keeping the booking is now the store's concern and nobody else's.
Step 9: Pass the service in Constructor Injection
Each class built its own helper inside its constructor, so nothing outside could share the same service. With constructor injection, a form of dependency injection, the handler takes its service as a promoted constructor parameter, and the service takes its store the same way. The script builds all three and passes each one in, and the run still prints the same two lines.
Payoff
Step 10: Add the import again Payoff
Now the partner file gets its own class, Booking Import. Import Line splits the line and calls book on the service, and that's all it does. The script builds one service and passes it to both the handler and the import. The run prints the Golf line and then two saved, because the partner booking lands in the same store. The service, the store and the handler didn't change at all to make room for it.
Step 11: One owner for the rule Payoff
The check is here, the discount here, and the save here, each written once, in Booking Service. That's separation of concerns, where each concern lives in its own place, so a change to one doesn't touch the others. The booking rule now has one owner, Booking Service, and every way in only turns its own input into a call to book. So when the discount moves to five days, you edit this one line, and web and partner bookings get the same price. You may know the class level version as the Single Responsibility Principle, where each class has one reason to change.
Recap
Step 12: Where we started, where we ended Before / after
On the left, one handle method read the request, held the business rule and kept the booking. A second way in had nothing it could call, so it had to copy those lines. On the right, book holds the check and hands the save to the store, and price holds the discount. The web handler and the partner import, left out here, both turn their input into one call to book.
Step 13: When to reach for it Takeaways
Reach for it when the same rule is needed from more than one way in, like a web request, a file import or a scheduled job. Skip it for a small script with one way in that nobody else calls, because splitting it adds classes to read and nothing to reuse. You'll spot it by a request handler that parses input, holds a business rule and writes storage, all in one method. Laravel keeps these concerns apart for you, because its service container injects a bound service into each controller that asks for one.