OOP and the Tactical Patterns — Where the Principles Show Up
Up to the previous chapter, you worked through the tactical patterns from value objects to domain events, implementing them as you went. To close Part 2, we reread that code from a different angle.
Value objects, aggregates, and domain services are all written as PHP classes. Object-oriented principles lie directly underneath them, and at the same time none of the patterns is settled by the principles alone. This chapter lays out the correspondence, and the decision each pattern adds on top.
This chapter does not explain the principles themselves. What encapsulation and composition are is covered by the PHP Class Design Guide. This chapter gives each principle a single paragraph and hands it straight to the tactical pattern.
The arrows read as "this principle shows up in this pattern." The patterns are not derived from the principles. Even where the same encapsulation is at work, a value object adds a separate decision about when a value gets checked. That addition is what separates class design from DDD's tactical design. Only the third correspondence is conditional: a domain service reaches for polymorphism only once the kinds of rule multiply.
Where to read about the principles
| Principle | Where to read |
|---|---|
| Encapsulation, tell, don't ask | PHP Class Design Guide Chapter 3 |
| Inheritance and composition | Chapter 5 of the same guide |
| Interfaces and depending on abstractions | Chapter 6 of the same guide |
| Cohesion, coupling, and SOLID | Chapter 7 of the same guide |
This chapter takes up three of them. Inheritance, which usually sits beside encapsulation in an introduction to object orientation, is replaced by composition here because the tactical patterns barely use inheritance. Value objects are closed off with final in Chapter 8, and neither entities nor aggregates in the domain layer build inheritance hierarchies. Hierarchy in a domain model is expressed by objects holding objects, not by classes extending classes.
Encapsulation — putting the decision inside the object
Chapter 3 of the PHP Class Design Guide explains tell, don't ask with the example of reducing stock. Rather than the caller asking for the quantity and subtracting from it, the caller tells the object to reduce(5), and the decision about whether there is enough sits inside Stock.
The Money of Chapter 8 has the same shape.
public function add(Money $other): Money
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return new Money($this->amount + $other->amount, $this->currency);
}
The caller never checks whether the currencies match. That decision sits on the Money side. Up to here, this is encapsulation that holds outside a domain model too.
Where a value object goes one step further is in when the check happens. Stock::reduce() checks the stock at the moment the method is called, while Money also checks in its constructor, so a Money holding a negative amount cannot even be created.
public function __construct(
private readonly int $amount,
private readonly string $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException('Amount must be 0 or greater');
}
}
It checks at creation, seals writes with readonly, and returns a new instance from every operation. With those three together, "any Money that exists is valid" holds, and the receiving side can skip its own check. A rule an object has to satisfy at all times, such as the amount never going negative, is called an invariant. A value object finishes checking it at creation, so an instance that breaks it cannot be built in the first place.
Encapsulation on its own is satisfied by a mutable object with setters. A value object adds immutability on top of it, which concentrates the check at the single point of creation.
Composition — how far an aggregate reaches
Chapter 5 of the PHP Class Design Guide compares inheritance (is-a) with composition (has-a), and shows that inheritance runs into a combinatorial explosion of classes once the axes multiply. The question there is whether to inherit or to hold.
The aggregate of Chapter 10 comes after that question. Having decided to hold, it decides how far to hold.
| Position of the other side | How it is held | Example |
|---|---|---|
| Inside the aggregate | The whole object | Order holds OrderLine and ShippingAddress |
| Outside the aggregate | The identifier only | OrderLine holds ProductId, not Product |
That OrderLine does not hold Product itself is not a conclusion that follows from object-oriented principles. Holding Product would be valid composition, and the types would check. It does not hold it because the aggregate is a consistency boundary. If saving an Order wrote Product along with it, which aggregate holds the correct state of the product would stop being decided.
Deciding the reach of has-a is also deciding what a single transaction rewrites. The wider the reach, the more rows are updated together and the more lock contention appears (Chapter 19). Where composition is judged on the class diagram, the aggregate boundary is judged with saving and contention included.
Polymorphism — when the kinds of rule multiply
Chapters 5 and 6 of the PHP Class Design Guide cover this mechanism through interfaces and swapped implementations. Change what sits behind a type without changing the type the caller sees, and the same method call runs different code depending on which implementation was handed in.
The shipping fee service in Chapter 11 splits the regional surcharge with a match.
private function calculateRegionFee(ShippingAddress $address): Money
{
return match ($address->prefecture()) {
'Hokkaido', 'Okinawa' => new Money(500, 'JPY'),
default => new Money(0, 'JPY'),
};
}
Once you know polymorphism, you want to replace that match with an interface and implementation classes. Whether the replacement pays off depends on how the branches are going to change.
Leaving the match alone is fine while every branch does nothing but return an amount and the conditions read at a glance. The code above fits in five lines, and adding a region takes one more.
Splitting into an interface is what you do once the branches start changing for separate reasons. When remote islands move to weight-based tiers and Okinawa gains a campaign-period check, one match gets rewritten again and again for unrelated reasons.
interface ShippingFeeRule
{
public function supports(ShippingAddress $address): bool;
public function fee(Order $order): Money;
}
The shipping fee service picks the matching rule and calls it, and the per-region conditions disappear from it. Judgments that do not depend on the region, such as the free-shipping threshold, stay in the service. With a class per rule, adding one becomes adding one implementation, and you no longer read the existing rules to do it. It is the same shape as Chapter 5 of the PHP Class Design Guide pulling the notification channel out as NotificationChannel.
Splitting does not change the criteria in Chapter 11. A rule implementation holds no state and touches no repository or external API. Once it needs to, that processing belongs to a use case rather than a domain service.
Two correspondences this chapter leaves out
Two of the correspondences around interfaces are sent to later chapters.
- Depending on abstractions (DIP): having the upper side depend on an interface rather than a concrete class is a relationship between layers, not the design of a single class. Chapter 14 covers it together with the four-layer structure
- Where the repository interface lives: placing the interface in the domain layer and the implementation in the infrastructure layer is DIP mapped onto Laravel's directory structure. Chapter 17 covers it
Both use the same tool of defining an interface, but what they decide is the direction of dependency, which is a different question from the three in this chapter.
Method calls and messages
All three of the above had objects calling methods directly inside the same process. The caller knows the class on the other side, or at least its interface.
The domain events of Chapter 12 take that one step out. Whatever publishes OrderConfirmed does not know who receives it. Receivers are added later as listeners.
Laravel's events and listeners still run inside the same application. Putting them on a queue defers the execution, but the sender and the receiver stay in the same codebase.
When you want to notify a different application, or you want the message to arrive later even if the receiver is down, something has to sit in between. That something is a message broker. Where the three principles in this chapter are about how to build the inside of a class, that one is about communication across processes.
Summary
| Principle | Where it shows up | What the pattern decides on top |
|---|---|---|
| Encapsulation | Value objects (Chapter 8) | Moves the check to creation, keeping every instance that exists valid |
| Composition | Aggregates (Chapter 10) | Sets the reach of has-a as the consistency and transaction boundary |
| Polymorphism | Domain services (Chapter 11) | Decides whether to split by whether the branches change independently |
Knowing the principles does not settle where the lines go. The tactical patterns carry those lines as ready-made shapes.
From the next chapter, Part 3 begins: which Laravel directory each piece of the domain model goes into, and in which direction the dependencies point.
Further reading
- PHP Class Design Guide — the object-oriented principles this chapter assumes
- Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, Part II — the original source for the tactical patterns