DDD

Domain Communication

The three sanctioned shapes for calling another domain — direct Contract call, event, queued job — and the rule for picking the right one.

What this is

When code in one domain needs work from another, there are three sanctioned shapes the call can take: a direct Contract call (synchronous, blocking), an event (asynchronous, fan-out), or a queued job (background, retry-able). Each one ships work across a domain boundary differently, and each one comes with its own trade-offs.

Picking the wrong shape produces real bugs. A synchronous call that should have been queued slows the response. A queued job that should have been synchronous commits the outer transaction before its downstream work runs. An event used where a direct call belongs hides the dependency graph and turns every "who calls this?" question into a repo-wide search. The decision is small, but worth making consciously.

The default, when in doubt, is a direct Contract call. Move to an event or a job only once you have evidence — a slow controller, an actual second subscriber, a real retry requirement.

The shape

The same CreateOrder Action, showing all three in one place:

final class CreateOrder
{
    public static function handle(CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($data) {
            // 1. Direct Contract call — synchronous, blocks the request, lives in the transaction.
            $provider = app(ProviderContract::class)->getProvider($data->provider_id);

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

            // 2. Direct Contract call — must succeed inside this transaction, so still synchronous.
            app(AccountingContract::class)->recordTransaction($order->id, $data->amount);

            // 3. Event — other domains may want to react; CreateOrder shouldn't know who.
            OrderCreated::dispatch($order->id, $order->provider_id, $order->amount);

            // 4. Job — slow downstream work (PDF, external API) that shouldn't block the response.
            ProcessOrder::dispatch($order->id);

            return $order;
        });
    }
}

Four hops, three patterns. The Provider fetch and the Accounting write both have to complete before the order returns — direct calls. OrderCreated is a fact other domains may care about; the publisher doesn't list them — event. ProcessOrder is heavy work that mustn't slow the HTTP response — job.

Pattern 1 — Direct Contract call (synchronous)

For operations that must complete in the current request. The caller blocks on the result, and any failure rolls back the surrounding transaction.

$provider = app(ProviderContract::class)->getProvider($data->provider_id);
app(AccountingContract::class)->recordTransaction($order->id, $data->amount);

This is the default when you're unsure. The trade-off you accept: the caller pays the latency of the downstream call, and the whole flow lives in one transaction. That's usually what you want — fewer moving parts, no eventual-consistency window, easy to test.

If you can write the call as a one-liner that just returns a value or throws, it's almost certainly a direct Contract call. Don't reach for events or jobs to feel "more decoupled."

Pattern 2 — Events (asynchronous, decoupled)

For facts that other domains may opt into without the publisher knowing who listens. The event carries scalar data only — no Eloquent Models. Listeners rehydrate state by resolving the upstream Contract themselves.

✅ Correct — scalar payload, listener re-fetches

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

// In the publishing Action:
OrderCreated::dispatch($order->id, $order->provider_id, $order->amount);

The listener lives in the subscribing domain:

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

❌ Wrong — Model in the event payload

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

OrderCreated::dispatch($order);

The wrong version serialises the Model (and its loaded relations) through the queue, couples the listener to the upstream's storage schema, and produces stale-data bugs when the job retries minutes after the data has moved on. Scalars don't have those problems.

Use events when the publisher genuinely should not know about the subscribers. The price you pay: the dependency graph is no longer visible in the import list — you discover it by searching for listeners. Don't reach for events when a direct call would do.

Pattern 3 — Jobs (background, queued)

For heavy work that must survive process death and supports retry. Pass IDs, not Models, and rehydrate inside handle().

✅ Correct

final class ProcessOrder implements ShouldQueue
{
    public function __construct(private readonly int $order_id) {}

    public function handle(): void
    {
        app(OrderContract::class)->processOrder($this->order_id);
    }
}

// Dispatched from an Action:
ProcessOrder::dispatch($order->id);

❌ Wrong — Model serialised into the job

final class ProcessOrder implements ShouldQueue
{
    public function __construct(private readonly Order $order) {}

    public function handle(): void
    {
        $this->order->status = 'processing';
        $this->order->save();   // stale; another process may have moved it.
    }
}

The wrong version is a stale-data factory. By the time the worker picks the job up — seconds to minutes later, longer on retry — the Order's row may have changed. The serialised copy doesn't know. Pass $order->id, re-fetch in handle(), work on fresh state.

Dispatch jobs from an Action, never from a controller. The controller's job is to translate HTTP into a domain call; the domain decides whether the work is synchronous or queued. A controller that calls SomeJob::dispatch() directly bypasses the domain's decision-making.
For lightweight post-response work that doesn't need retry, prefer Laravel's defer() over a queued job. A job has overhead (serialise, hop to Redis, schedule a worker) that doesn't pay off for a sub-second one-shot operation like firing an analytics ping.

Picking the right pattern

A useful rule of thumb:

  • The caller needs the result, or the work must succeed inside the same transaction → direct Contract call.
  • Other domains may want to react and the publisher should not know who listens → event.
  • The work is slow (PDF render, external API, bulk write), can fail and retry, or shouldn't slow the response → job.

Side-by-side:

PatternLatencySurvives process deathOther domains can subscribeUse when
Direct Contract callinlinen/anocaller blocks on the result, or it must be in the same transaction
Eventinline (sync listener) or queued (queue listener)depends on listener configyesother domains may want to react and the publisher shouldn't know who
Jobqueuedyesnowork is slow, can fail and retry, or shouldn't block the response

When in doubt, start with a direct call. It's the easiest to reason about and the easiest to refactor. Move to an event or a job once the evidence justifies it.

The ID-not-Model rule (events and jobs)

Both events and jobs serialise their payload — events because queue listeners are common, jobs because that's what ShouldQueue does. Eloquent Models do not serialise cleanly:

  • They drag loaded relations along with them, bloating the payload.
  • They snapshot the row at dispatch time; by the time the worker runs, the row may have moved.
  • The job/listener becomes coupled to the upstream's storage schema, defeating the boundary.

The rule: payloads contain int IDs, string enum values, floats, bools, and arrays of the same. The handler re-fetches the live entity via the upstream Contract on the way in.

// Dispatch — scalars only.
OrderCreated::dispatch($order->id, $order->provider_id);
ProcessOrder::dispatch($order->id);

// Handler — re-fetch via Contract.
public function handle(OrderCreated $event): void
{
    $order = app(OrderContract::class)->getOrder($event->order_id);
    // ... work on fresh state.
}

Avoiding circular dependencies

If Domain\A's Contract calls Domain\B's Contract, and Domain\B's Contract calls back into Domain\A's Contract, you have a cycle. At best it's a confusing dependency graph; at worst it deadlocks under transactions or recurses infinitely when both sides try to "stay consistent."

Three fixes, roughly in order of preference:

  1. Move the shared concept into a third domainCore or a new bounded context that owns the bridge.
  2. Use an event — one side publishes, the other subscribes. The cycle breaks because the publisher no longer imports the subscriber.
  3. Model the relationship explicitly with a join entity that owns the bridging behaviour.

The reviewer agent flags cycles as #16 — Circular dependencies between domains.

  • Domain boundaries — the rule for who can call whom and what they're allowed to import.
  • DDD overview — the bigger map of domains, panels, and what lives where.
  • Actions — where the app(SomeContract::class), event dispatch, and Job::dispatch calls actually live.
  • Services and contracts — how to add a method to a Contract and bind its Service.
  • Anti-patterns — items covering Model-in-payload, controller-dispatched jobs, and circular dependencies.