A Unix tool does one thing and speaks through stdin and stdout. A component here does one thing and speaks through a contract. Everything else — how it is invoked, where it runs, who owns it — is configuration.
One part, one job
The test is not “is it small?” but “can I describe it without and?”. A class that validates a transfer and persists it has two reasons to change and two failure modes to test. Split it at the and.
Replaceability follows automatically. A part with one job has a narrow interface, and a narrow interface has few callers to update.
The data pipe
Input arrives through an adapter (HTTP controller, CLI command, queue consumer), traverses the core as plain data, and leaves through another adapter. The core never knows which adapter called it.
final class TransferMoney
{
public function __construct(
private Accounts $accounts,
private Ledger $ledger,
) {}
public function __invoke(Transfer $transfer): Receipt
{
$source = $this->accounts->get($transfer->source());
$target = $this->accounts->get($transfer->target());
$debited = $source->debit($transfer->amount());
$credited = $target->credit($transfer->amount());
$this->accounts->save($debited, $credited);
return $this->ledger->record($transfer);
}
}public sealed class TransferMoney
{
private readonly IAccounts accounts;
private readonly ILedger ledger;
public TransferMoney(IAccounts accounts, ILedger ledger)
{
this.accounts = accounts;
this.ledger = ledger;
}
public Receipt Execute(Transfer transfer)
{
var source = accounts.Get(transfer.Source);
var target = accounts.Get(transfer.Target);
var debited = source.Debit(transfer.Amount);
var credited = target.Credit(transfer.Amount);
accounts.Save(debited, credited);
return ledger.Record(transfer);
}
}Notice what this class does not import: no HTTP, no ORM, no container. It knows two ports named after business concepts, and nothing else. That is the pipe.
Composition happens at the edge
Someone has to decide which implementation is real. That decision belongs to a composition root — one place, usually the entry point, where infrastructure is wired to the core.
// Composition root — infrastructure decides, once.
$transferMoney = new TransferMoney(
new DoctrineAccounts($entityManager),
new DoctrineLedger($entityManager),
);
$receipt = $transferMoney(new Transfer($source, $target, $amount));In C# that place is Program.cs; in Java, the module or a framework configuration class; in C++, main. One file, not scattered through the domain.
Parts must be replaceable, not just small
Replaceability requires three things:
- A contract that describes behaviour in domain terms.
- No leaked types from the implementation (no
QueryBuilderin a signature). - One implementation per role in production, and an in-memory one in tests.
The in-memory implementation is the cheapest architecture test you will ever write: if the use case cannot run against a fake, it is not composed — it is fused to a framework.
Cycles are the failure signal
Composition makes dependencies visible. When the graph contains a cycle, two parts are actually one, or a responsibility is in the wrong place. Tools that render the graph — deptrac, ArchUnit, jdeps,include-what-you-use — turn that signal into a build failure.
| Language | Composition mechanism | Dependency graph check |
|---|---|---|
| PHP | constructor injection, PSR-11 container at the edge | deptrac |
| C# | constructor injection, IServiceCollection | NetArchTest |
| Java | constructor injection, manual wiring or CDI | ArchUnit, jdeps |
| C++ | references/pointers in constructors, manual wiring in main | build graph, IWYU |
What composition is not
It is not “extract an interface for everything”. An interface with one implementation and no test fake is ceremony. Composition is justified by a second implementation — real or in-memory.