OTAKU / Protect the core

Track A — Armor the Core

Armor: protect the core

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 ──────────────────▶  Domain

Arrows 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

SymptomWhat it meansFix
Entity extends a framework base classThe framework owns lifecycle and statePlain object + adapter mapping
Domain imports EntityManager, DbContext, SessionPersistence is in the coreRepository port in the domain
Validation annotations on domain fieldsTransport rules became business rulesValidate in the use case or in the value object
Domain throws HTTP exceptionsThe adapter leaked inwardsDomain 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
LanguageArchitecture test
PHPdeptrac, PHPStan custom rules
C#NetArchTest, NDepend
JavaArchUnit
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.