The domain is the only part of the system that cannot be replaced. Armor is not about hiding it — it is about making sure nothing else can dictate its shape.
The dependency rule
Source dependencies point inwards. Nothing in an inner circle may know the name of anything in an outer circle: not a class, not an annotation, not a framework interface, not a database type.
Interface ──▶ Application ──▶ Domain
Infrastructure ──────────────────▶ DomainArrows are compile-time imports. If Domain/Account.php importsDoctrine\ORM\Mapping\Entity, the arrow points the wrong way and the framework now owns your business rules.
Ports and adapters
The domain declares what it needs as a port. Infrastructure implements it as an adapter. The domain keeps the vocabulary; the adapter keeps the vendor.
namespace App\Domain\Account;
interface Accounts
{
public function get(AccountId $id): Account;
public function save(Account ...$accounts): void;
}
interface Ledger
{
public function record(Transfer $transfer): Receipt;
}namespace App.Domain.Account;
public interface IAccounts
{
Account Get(AccountId id);
void Save(params Account[] accounts);
}
public interface ILedger
{
Receipt Record(Transfer transfer);
}package app.domain.account;
public interface Accounts {
Account get(AccountId id);
void save(Account... accounts);
}
public interface Ledger {
Receipt record(Transfer transfer);
}namespace app::domain::account {
class Accounts {
public:
virtual ~Accounts() = default;
virtual Account get(const AccountId& id) = 0;
virtual void save(const Account& account) = 0;
};
class Ledger {
public:
virtual ~Ledger() = default;
virtual Receipt record(const Transfer& transfer) = 0;
};
}export interface Accounts {
get(id: AccountId): Account;
save(...accounts: Account[]): void;
}
export interface Ledger {
record(transfer: Transfer): Receipt;
}The C++ version uses an abstract class with a virtual destructor because the language has no interfaces — the shape is identical: a named contract owned by the domain, with no implementation detail in sight.
A domain entity that knows nothing
namespace App\Domain\Account;
final class Account
{
private function __construct(
private AccountId $id,
private Money $balance,
) {}
public function withdraw(Money $amount): self
{
if ($this->balance->isLessThan($amount)) {
throw new InsufficientFunds($this->id);
}
return new self($this->id, $this->balance->subtract($amount));
}
}namespace App\Domain\Account;
interface Accounts
{
public function get(AccountId $id): Account;
public function save(Account ...$accounts): void;
}No annotations, no base class, no active record. The entity can be constructed in a unit test with no database, no container and no framework boot.
The anti-patterns
| Symptom | What it means | Fix |
|---|---|---|
| Entity extends a framework base class | The framework owns lifecycle and state | Plain object + adapter mapping |
Domain imports EntityManager, DbContext, Session | Persistence is in the core | Repository port in the domain |
| Validation annotations on domain fields | Transport rules became business rules | Validate in the use case or in the value object |
| Domain throws HTTP exceptions | The adapter leaked inwards | Domain exceptions + mapping at the edge |
Make the arrow a build failure
A dependency rule enforced by review decays. Enforce it with a tool that fails the pipeline:
# deptrac.yaml
parameters:
paths:
- ./src
layers:
- name: Domain
collectors:
- type: directory
value: src/Domain/.*
- name: Application
collectors:
- type: directory
value: src/Application/.*
- name: Infrastructure
collectors:
- type: directory
value: src/Infrastructure/.*
ruleset:
Domain: ~
Application:
- Domain
Infrastructure:
- Domain
- Application| Language | Architecture test |
|---|---|
| PHP | deptrac, PHPStan custom rules |
| C# | NetArchTest, NDepend |
| Java | ArchUnit |
| C++ | target-level dependency rules in CMake, include-what-you-use |
What “armor” is not
It is not a four-layer folder structure with the same model copied four times. If the domain entity and the database entity are identical, the boundary is ceremonial — you moved code, not dependencies. The test is the import graph, not the directory tree.