# Event-Driven Architecture Foundation

Date: 2026-05-21

Status: Phase 3 foundation, gradual wiring.

## Why This Exists

ECOLE ECOIN already spans enrollment, finance, attendance, messaging, WhatsApp, Corporate, and tracking workflows. The EventBus foundation prevents future CRM and automation work from attaching side effects directly to Livewire screens or provider calls.

The Phase 3 decision is deliberate:

- Build lifecycle event contracts now.
- Wire only safe extracted Actions now.
- Document planned emitters for legacy workflows.
- Migrate coverage gradually after workflow tests protect each sensitive slice.

## Execution Model

```mermaid
flowchart LR
    A["Owned Action"] --> B["DB transaction"]
    B --> C["After-commit Domain Event"]
    C --> D["Queued Listener"]
    D --> E["Messaging, tracking, reminder, or CRM Action"]
```

An owned Action remains responsible for the business write. An event announces the fact after the write is durable. A listener reacts through an owned API instead of becoming a second hidden workflow owner.

## Event Contract Rules

Phase 3 event classes live under `app/Domain/{Module}/Events`.

Each event must:

- Be readonly.
- Carry stable IDs and safe scalar metadata.
- Carry `occurredAt`.
- Avoid Eloquent model payloads, request bags, provider payload dumps, and secrets.
- Describe a completed business fact, not a button click.

Examples:

- `B2bLeadConverted`
- `WhatsAppReplySent`
- `AttendanceSessionCreated`
- `CorporateInvoiceIssued`

## After-Commit Rule

Transactional business events dispatch after commit. Current safe Actions use explicit `DB::afterCommit()` so nested transactions and outer rollbacks do not leak automation facts.

Use this pattern for future extracted Actions:

```php
DB::afterCommit(fn () => SomeLifecycleEvent::dispatch(
    entityId: $entity->id,
    actorUserId: $actor?->id,
    occurredAt: now()->toISOString(),
));
```

Event classes also implement Laravel's after-commit event contract to keep the contract explicit at the event boundary.

## Current Safe Wiring

Phase 3 currently wires:

- B2B conversion to `B2bLeadConverted`.
- WhatsApp-created B2B leads to `WhatsAppLeadCreated`.
- WhatsApp replies to `WhatsAppReplySent`.
- Manual attendance session creation to `AttendanceSessionCreated`.

Only `B2bLeadConverted` has a first listener in this phase:

- `TrackB2bLeadConverted`

This keeps the first listener seam small, queued, testable, and non-mutating.

## Listener Boundaries

Listeners may:

- Send notifications.
- Queue reminders.
- Dispatch tracking updates.
- Build projections.
- Create future CRM follow-up tasks.

Listeners must not:

- Write unrelated module state directly.
- Create or update enrollment or payment state as a hidden side effect.
- Bypass tenant/policy expectations when they query data.
- Rehydrate secrets or forward raw provider payloads through events.

If a listener needs a business mutation, it calls the Action owned by that module.

## Idempotency

Queued listeners must expect retries and duplicate deliveries. Phase 3 introduces `ListenerIdempotency` as the first shared guard:

- Key format is listener identity plus event identity.
- Successful execution keeps the key for a bounded cache window.
- Failure clears the key before rethrow so a retry can execute.

`TrackB2bLeadConverted` demonstrates the pattern. Messaging, reminders, analytics, and CRM listeners must use a duplicate guard before external side effects or one-way projections.

## Notification Orchestration Direction

The preferred shape is:

```text
EnrollmentConfirmed
  -> SendEnrollmentConfirmedWhatsApp
  -> SendEnrollmentConfirmedEmail
  -> TrackEnrollmentConversion
  -> ScheduleAttendanceReminder
  -> NotifyAdminEnrollmentConfirmed
```

Those listeners remain independent. Enrollment code should not become a notification router, and a notification listener should not perform finance or enrollment state transitions itself.

## Tracking Direction

Tracking reacts to lifecycle facts. It does not read UI internals to infer business state and it never mutates enrollment, payment, attendance, or Corporate aggregates.

The first Phase 3 example is:

```text
B2bLeadConverted -> TrackB2bLeadConverted -> ServerSideEventDispatcher
```

## Retry And Failure Posture

Queued listeners and provider jobs should:

- Use finite retries and backoff.
- Log safe context only.
- Fail loudly into the queue failure path when retries are exhausted.
- Avoid duplicate external side effects through idempotency keys.

Follow-up work can add alerting or dead-letter dashboards without changing event payload contracts.

## Architecture Guardrails

Run:

```bash
php artisan app:architecture-check
```

Phase 3 adds baseline findings for:

- Direct notification/provider side effects from Livewire.
- Direct tracking dispatch from Livewire.
- Direct WhatsApp provider calls from non-WhatsApp Actions.
- Listener writes that attempt to mutate enrollment or payment state directly.

Baseline mode reports debt. Strict mode is intended for guarded change sets after existing debt is triaged.

## Gradual Wiring Roadmap

Do not blanket-refactor legacy sensitive workflows. Wire planned emitters only when:

1. The owning Action or narrow service seam is clear.
2. The workflow has focused regression tests.
3. The after-commit point is unambiguous.
4. The downstream listener is idempotent.

The full contract inventory and emitter status live in `docs/event_catalog.md`.

