DDD

Domain Boundaries

The rule for calling code in another domain — Actions directly inside the same domain, Contracts across domains, and never Models or internals.

What this is

A bounded context (a domain — Order, Provider, Accounting) is only useful if something stops the rest of the codebase from reaching across it. A Contract is the gateway: an interface in src/Domain/{Domain}/Contracts/ that lists every method outside callers are allowed to use. Everything behind it — Actions, internal Services, Models — is an implementation detail.

Without that gateway, every domain ends up importing every other domain's Models. An internal refactor in one place silently breaks four others. There's no single seam where you can add caching, validation, or an event for a cross-domain read. The Contract gives you one.

The rule has two halves: inside the same domain you own the code and import Actions directly, and across domains you resolve the Contract via app(...) and never reach further.

The shape

// You're in Domain\Order, calling another Order Action — direct import.
use Domain\Order\Actions\CalculateOrderTotal;

$total = CalculateOrderTotal::handle($order);

// You're in Domain\Order, calling into Domain\Provider — resolve its Contract.
$provider = app(ProviderContract::class)->getProvider($providerId);

Two patterns, picked by namespace. If the class you're calling sits in the same Domain\X\ namespace as the file you're writing, import it directly. If it sits in any other domain, resolve that domain's Contract and call a method on it.

In-domain calls go through Actions directly

You own the code. Use the Action.

✅ Correct

// File: src/Domain/Order/Actions/PlaceOrder.php
namespace Domain\Order\Actions;

use Domain\Order\Actions\CalculateOrderTotal;

final class PlaceOrder
{
    public static function handle(Order $order): Order
    {
        $order->total = CalculateOrderTotal::handle($order);
        $order->save();

        return $order;
    }
}

❌ Wrong — unnecessary indirection

// File: src/Domain/Order/Actions/PlaceOrder.php
$total = app(OrderContract::class)->calculateOrderTotal($order);

Going through your own Contract for an in-domain call adds a layer that proves nothing. The Contract exists to hide internals from the outside; from the inside you're already past it.

Cross-domain calls go through the Contract

The other domain's Contract is the only thing you're allowed to import.

✅ Correct

// File: src/Domain/Order/Actions/CreateOrder.php
namespace Domain\Order\Actions;

use Domain\Provider\Contracts\ProviderContract;

final class CreateOrder
{
    public static function handle(CreateOrderData $data): Order
    {
        $provider = app(ProviderContract::class)->getProvider($data->provider_id);

        return Order::create([
            'provider_id' => $provider->id,
            'customer_id' => $data->customer_id,
        ]);
    }
}

❌ Wrong — boundary breach

// File: src/Domain/Order/Actions/CreateOrder.php
use Domain\Provider\Models\Entities\Provider;          // reaching into Provider's internals
use Domain\Provider\Actions\GetProvider;               // also a breach — Actions are internal

$provider = Provider::find($providerId);
$provider = GetProvider::handle($providerId);
The use Domain\Provider\… line inside a file under Domain\Order\ is the smell. Any import from another domain that isn't a Contracts\ interface is a critical violation. The reviewer agent flags it as #1 — Direct model access across modules.

The fix is always the same shape: find or add a method on the upstream Contract that returns what you need, then call it with app(SomeContract::class).

What goes on the Contract

A method belongs on the Contract only if it has at least one caller outside the owning domain — another domain or the Presentation layer. Internal-only Actions stay off the interface and are imported directly by their siblings.

The Contract is a published API. Once a method appears there, every other domain may now depend on it, and removing it becomes a breaking change. The smaller the Contract surface, the easier the domain is to refactor.

Before adding a method to a Contract, search the repo for who would call it. If every caller is inside the same domain, import the Action directly instead — the Contract method is dead weight.

The cost of breaching a boundary

What happens when a cross-domain call goes straight to the upstream's Model:

  • The upstream domain can no longer rename, restructure, or add validation to that read path without breaking unrelated code.
  • N+1 queries appear when relationships lazy-load inside the downstream code — the upstream had no chance to add whenLoaded guards or eager-load defaults.
  • There is no longer a single place to add an event, a cache, or a metric for that read.
  • Tests in the downstream domain end up faking the upstream's Model rather than its Contract — they're now coupled to the storage schema.

These costs compound over months and are hard to unwind once they've spread. The reviewer agent catches the import on the way in for that reason.

Models in events and jobs follow the same rule

Events and jobs cross domains too. The boundary rule takes a slightly different shape there: pass IDs, not Models. Rehydrate inside the listener or job by resolving the upstream Contract.

✅ Correct — scalar payload

final class OrderCreated
{
    public function __construct(
        public readonly int $order_id,
        public readonly int $provider_id,
        public readonly float $amount,
    ) {}
}

OrderCreated::dispatch($order->id, $order->provider_id, $order->amount);

final class RecordOrderTransaction
{
    public function handle(OrderCreated $event): void
    {
        app(AccountingContract::class)->recordTransaction($event->order_id, $event->amount);
    }
}

❌ Wrong — Model serialised through the queue

final class OrderCreated
{
    public function __construct(public readonly Order $order) {}
}

OrderCreated::dispatch($order);

Serialising Eloquent Models couples the payload to the upstream's schema, drags relations through the queue, and produces stale-data bugs on retry. IDs serialise cleanly and force the listener to fetch fresh state. See cross-module communication for the full pattern catalogue.

Quick decision flow

You're in Domain\Order\ and you need to call something:

Target namespacePattern
Domain\Order\Actions\…use the Action, call ::handle()
Domain\Order\Models\Entities\…use the Model directly
Domain\Provider\Contracts\ProviderContractapp(ProviderContract::class)->…
Domain\Provider\Actions\…❌ stop — add a method to ProviderContract instead
Domain\Provider\Models\Entities\…❌ stop — go through the Contract
  • Cross-module communication — when to call synchronously, when to dispatch an event, when to queue a job.
  • DDD overview — the bigger map of where Actions, Contracts, Services, and Models sit.
  • Services and contracts — how to define a Contract and bind its Service in the domain provider.
  • Actions — the canonical Action shape and where the app(SomeContract::class) call lives inside handle().
  • Anti-patterns — items #1, #15, and #16 cover boundary breaches and circular dependencies.