BeMart

G-14: Ray.Di `bind(Iface)->to(Impl)` does NOT consult `bind(Impl)->in(SINGLETON)`

G-14: Ray.Di bind(Iface)->to(Impl) does NOT consult bind(Impl)->in(SINGLETON)

Context

Discovered during Pilot 5 (doCheckout) of the EC-CUBE -> Be Framework migration, while wiring stateful Fakes (FakeMailer, FakePaymentGateway, FakeInventoryAllocator) that both the Becoming chain and test introspection need to see as the same instance.

Problem

When a Fake (or any class) holds in-memory state, two pieces of code typically need to reach it:

  1. The Becoming chain resolves it via the interface (MailerInterface).
  2. The test asserts on captured state via the concrete class (FakeMailer).

The naive Ray.Di pattern that “looks right”:

$this->bind(MailerInterface::class)->to(FakeMailer::class);
$this->bind(FakeMailer::class)->in(Scope::SINGLETON);

silently produces two instances. The to(FakeMailer::class) is a linked binding — it instantiates a fresh FakeMailer independent of the bind(FakeMailer::class) scope. The test sees an empty FakeMailer::$sent[] even though the Becoming chain sent a mail through the interface-resolved instance.

Symptom: tests that assert “mailer was called once” fail with assertCount(1, $mailer->sent) — Failed asserting that an array has 1 elements (0 found).

Solution / Convention

Bind a single object reference to both keys via toInstance:

$mailer = new FakeMailer();
$this->bind(FakeMailer::class)->toInstance($mailer);
$this->bind(MailerInterface::class)->toInstance($mailer);

This forces both lookups to return ===-identical objects. Use this whenever:

If only the interface side is consulted, the simple bind(Iface)->to(Impl); bind(Impl)->in(SINGLETON) pattern still works — because no other code reaches the Impl key.

Keep service Fake implementations out of the contract namespace. Query/Command/Storage interfaces are not backed by app-local Fake concrete classes; fake DB reads use Ray.FakeQuery fixtures.

Reason/
  Query/                 # interfaces, factories, BDR/Param types
  Service/               # interfaces and production-neutral services
  Fake/
    Service/             # Fake* service/generator implementations

Reason\Query and Reason\Service should read as the domain/infra boundary. Concrete dev/test doubles belong under Reason\Fake\Service; Query/Command/Storage doubles are replaced by Ray\FakeQuery\FakeQueryModule + JSON/JSONL fixtures.

Code example

// src/Module/AppModule.php (BeMart)

// Stateful Fakes — must share one instance across Iface and Impl bindings.
$inventory = new FakeInventoryAllocator();
$gateway   = new FakePaymentGateway();
$mailer    = new FakeMailer();

$this->bind(FakeInventoryAllocator::class)->toInstance($inventory);
$this->bind(InventoryAllocatorInterface::class)->toInstance($inventory);

$this->bind(FakePaymentGateway::class)->toInstance($gateway);
$this->bind(PaymentGatewayInterface::class)->toInstance($gateway);

$this->bind(FakeMailer::class)->toInstance($mailer);
$this->bind(MailerInterface::class)->toInstance($mailer);

// Query/Command/Storage interfaces are handled by Ray.FakeQuery.
$this->install(new FakeQueryModule($fakeDir, $queryClasses));

Anti-pattern

// Looks correct, silently broken for stateful Fakes:
$this->bind(MailerInterface::class)->to(FakeMailer::class);
$this->bind(FakeMailer::class)->in(Scope::SINGLETON);

// Even adding ->in(SINGLETON) to the linked binding does NOT fix it —
// you get two distinct singletons, one keyed on Iface, one keyed on Impl.
$this->bind(MailerInterface::class)->to(FakeMailer::class)->in(Scope::SINGLETON);
$this->bind(FakeMailer::class)->in(Scope::SINGLETON);

Where this matters

Production adapters that delegate to external services (real SMTP, real gateway HTTP client) are typically stateless — the simple bind(Iface)->to(Impl)->in(SINGLETON) pattern is enough there.