# 0012 — Modules read each other through directories, not relations

**Status:** Accepted · 2026-08-02

## Context

Appointments is the first module that needs data owned by two others. Every row
in the diary names a patient and a clinician; neither belongs to it.

The architecture forbids importing another module's models, and an architecture
test enforces it. That rule had been cheap up to now because nothing needed to
cross. Appointments made the cost real, and the honest options were:

1. **Relax the rule** and let Appointments declare `belongsTo(Patient::class)`.
   Eager loading works, everything is easy, and the boundary becomes decorative —
   Appointments can no longer boot without Patients, and next year somebody
   writes `$appointment->patient->allergies` from a billing screen.
2. **Keep the rule** and pay for it in plumbing.

There is a failure mode inside option 2 that matters more than the choice
itself. A contract offering only `find(int $id)` produces an N+1 on every
listing: fifty rows, fifty queries. A developer who hits that does not file a
performance ticket — they reach past the boundary for the Eloquent relation,
and the rule dies quietly.

## Decision

Keep the rule, and design the contracts so the fast path is also the correct
one.

Each module publishes a **directory**: `PatientDirectory` from Patients,
`DoctorDirectory` from Auth. Each returns **summary DTOs** — `PatientSummary`,
`DoctorSummary` — carrying only what a consumer legitimately needs, and each
includes **batch lookup** (`summariesFor(array $ids)`) as part of the interface
rather than as an optimisation somebody might add later.

`AppointmentQuery::hydrateParticipants()` collects the ids from a page and makes
two calls. Two extra queries for a page of any size.

The summaries are deliberately narrow. `DoctorSummary` carries name, specialty,
licence number and slot length — not the User model with its password hash,
two-factor secret and login history. `PatientSummary` carries name, MRN, age,
phone and the *count* of active allergies: a warning marker beside a name on a
scheduler is a legitimate safety cue and does not require sight of the record.

Integer ids appear in the DTOs because other modules hold foreign keys to them.
Referential integrity within one database is normal and is not a boundary
violation; the ULID is what gets rendered.

Doctor profiles live in **Auth**, not Appointments. A doctor is a user with a
registration number. Putting the profile in Appointments would force
Prescriptions and Billing to depend on Appointments to learn a clinician's
licence number — precisely the accidental coupling this is all for.

## Consequences

**Good.** The boundary survived contact with a module that genuinely needed to
cross it. A test asserts the batch path is one query for five patients, so the
property that keeps the rule alive is itself pinned.

**Bad.** More code than a relation: two contracts, two DTOs, two
implementations, and an explicit hydration step in the query object. That is the
real, ongoing price of the boundary, and it should be judged again when a module
needs something a summary cannot express.

**Bad.** No eager loading, no `whereHas` across modules. Filtering appointments
by a patient attribute means asking the directory first and passing ids in.

**Amended.** The architecture test now exempts `Tests/` directories. A test for
cross-module behaviour has to build the other module's fixtures — asserting that
archiving a patient cancels their appointments requires creating a patient — and
the rule exists to prevent *runtime* coupling, not to make the behaviour
untestable.

## Related

The reverse direction uses events, not contracts: Patients dispatches
`PatientArchived` and knows nothing about diaries; Appointments listens and
cancels future bookings. Remove Appointments and archiving still works. Events
for notification, contracts for lookup — and the dependency only ever points the
way `requires` in the manifest says it does.
