Article
Problem #3/100: Why your developers can't work in parallel
Two PRs, separate classes, no overlap — and a merge conflict on the entity they both mutate. The shared blackboard hidden behind clean service boundaries, and what to pass instead.
You assign two developers to the same feature area. One works on pricing logic. The other works on provisioning. They work on separate classes. No overlap.
They both open a pull request on Friday. Merge conflict — on a file neither of them "owns." The Subscription entity. Both developers mutated the same object in their respective services. Both changed status. Both assumed certain fields were already set by the time their code ran.
The code is in separate classes. The coupling is in the object they share.
The rule
Each step in a flow should return its result, not mutate a shared object.
When a flow passes one entity through a chain of services and each service reads fields the previous one set and writes fields the next one expects — you don't have separate services. You have one spaghetti method distributed across four files. The entity is the shared mutable state, and the order of execution is an invisible contract that exists nowhere in the code.
The example that looks decoupled
A subscription activation flow. User picks a plan, the system validates, calculates pricing, provisions resources, sends a confirmation. Four services, clean separation of concerns — on the surface.
// Controller — entry point
final class ActivateSubscriptionController
{
public function __invoke(int $id): JsonResponse
{
$subscription = $this->subscriptions->find($id);
$this->validationService->prepare($subscription);
$this->pricingService->applyPricing($subscription);
$this->provisioningService->provision($subscription);
$this->notificationService->notify($subscription);
$this->subscriptions->save($subscription);
return new JsonResponse(['status' => 'ok']);
}
}
// ValidationService — mutates subscription
final class ValidationService
{
public function prepare(Subscription $subscription): void
{
$account = $this->accounts->find($subscription->getAccountId());
$subscription->setBillingEmail($account->getEmail());
$subscription->setRegion($account->getRegion());
$subscription->setTaxExempt($account->isTaxExempt());
$subscription->setStatus('validated');
}
}
// PricingService — reads what Validation set, mutates more
final class PricingService
{
public function applyPricing(Subscription $subscription): void
{
$plan = $this->plans->find($subscription->getPlanId());
$taxRate = $subscription->isTaxExempt()
? 0.0
: $this->taxService->rateFor($subscription->getRegion());
$subscription->setBasePrice($plan->getPrice());
$subscription->setTaxRate($taxRate);
$subscription->setTotalPrice($plan->getPrice() * (1 + $taxRate));
$subscription->setStatus('priced');
}
}
// ProvisioningService — reads what Pricing set, mutates more
final class ProvisioningService
{
public function provision(Subscription $subscription): void
{
$resources = $this->infra->allocate(
$subscription->getRegion(),
$subscription->getPlanId(),
);
$subscription->setResourceId($resources->getId());
$subscription->setProvisionedAt(new \DateTimeImmutable());
$subscription->setStatus('provisioned');
}
}
// NotificationService — reads state scattered across three services
final class NotificationService
{
public function notify(Subscription $subscription): void
{
$this->mailer->send($subscription->getBillingEmail(), [
'plan' => $subscription->getPlanId(),
'total' => $subscription->getTotalPrice(),
'region' => $subscription->getRegion(),
]);
$subscription->setStatus('active');
$subscription->setActivatedAt(new \DateTimeImmutable());
}
}
Four classes. Four single-responsibility services. This passes every code review.
Here's why it's still spaghetti:
PricingService calls $subscription->getRegion() — but who set it? ValidationService. How do you know? You don't, unless you trace the flow from the controller and read every service in order. The entity is a shared blackboard: each service writes on it, the next one reads, and the contract between them is invisible.
Now add real problems:
- Provisioning fails. Status is "priced", billingEmail and totalPrice are set, but resourceId is null. Who rolls back? There's no explicit boundary between "completed steps" and "pending steps" — just a half-mutated entity.
- Two developers work in parallel. One refactors PricingService and removes
setTaxRate()(moves it into a value object). The other adds a tax audit log in NotificationService that readsgetTaxRate(). Both PRs pass CI independently. Merged together — runtime error. - You need to reorder steps. Product says: provision before pricing (to check resource availability first). You swap two lines in the controller. Provisioning now calls
getRegion()— but ValidationService hasn't run yet. Null region. No test catches this because the contract is implicit.
After
Each step returns a value object with its result. The entity is assembled once, at the end, from explicit data.
final class ActivateSubscriptionHandler
{
public function handle(int $subscriptionId): void
{
$subscription = $this->subscriptions->find($subscriptionId);
$account = $this->accounts->find($subscription->getAccountId());
$plan = $this->plans->find($subscription->getPlanId());
$billing = $this->billingResolver->resolve($account, $plan);
$resources = $this->provisioner->provision($account->getRegion(), $plan);
$subscription->activate(
billing: $billing,
resourceId: $resources->getId(),
activatedAt: new \DateTimeImmutable(),
);
$this->subscriptions->save($subscription);
$this->mailer->send($account->getEmail(), [
'plan' => $plan->getName(),
'total' => $billing->totalPrice(),
'region' => $account->getRegion(),
]);
}
}
final class BillingResolver
{
public function resolve(Account $account, Plan $plan): BillingDetails
{
$taxRate = $account->isTaxExempt()
? 0.0
: $this->taxService->rateFor($account->getRegion());
return new BillingDetails(
basePrice: $plan->getPrice(),
taxRate: $taxRate,
totalPrice: $plan->getPrice() * (1 + $taxRate),
);
}
}
final readonly class BillingDetails
{
public function __construct(
private float $basePrice,
private float $taxRate,
private float $totalPrice,
) {}
public function totalPrice(): float
{
return $this->totalPrice;
}
public function taxRate(): float
{
return $this->taxRate;
}
}
What changed:
BillingResolvertakes anAccountand aPlan— its inputs are explicit, not pulled from a half-mutated entity. You can read its signature and know exactly what it needs.Provisionertakesregionandplandirectly — no dependency on which service ran before it.Subscription::activate()receives all data at once. The entity is never in a half-valid state. No "priced but not provisioned" limbo.- Two developers can work on
BillingResolverandProvisionersimultaneously — they don't touch the same object, don't share mutable state, don't create merge conflicts. - Reordering is safe. Swap
resolveandprovision— both take explicit inputs, neither depends on the other's side effects.
The test
Open a service class in your flow. Look at how it gets its data. Does it call getters on an entity that was mutated by a previous service? Follow one getter back to its setter. How many files do you cross? If the answer is more than one — you have an invisible contract between services, and reordering, parallelizing, or error-handling that flow is a landmine.