[Implementation 7] Reviews and Operations Start the Next Development Cycle

The first six articles covered state machines, availability, global optimization, constraints, and incident rescheduling. Those features passed tests and became callable APIs, but that did not finish the project. Reviews found missing consumer contracts. Serving multiple clinic groups changed every access boundary. Multiple application instances introduced scheduler duplication, and an H2 migration needed independent PostgreSQL and MySQL evidence.
This final article is not a feature inventory. It follows how a small finding becomes a requirement, moves through design, implementation, tests, and lessons, and returns as a stronger baseline for the next change.
Turn Review Findings into the Smallest Observable Contract
Section titled “Turn Review Findings into the Smallest Observable Contract”The early appointment API performed its major transitions but did not expose every contract a consumer needed. Review and issue work added several focused requirements.
| Missing contract | New behavior | Implementation lesson |
|---|---|---|
| State changes cannot be inspected externally | State-history endpoint | Flatten state names in an API DTO instead of exposing persistence types |
| Cancellation uses a fixed string | Optional reason with a compatible default | Send one effectiveReason to state machine, history, and domain event |
| Lists grow without bounds | ExposedPage<T> pagination | Update frontend consumers when the response shape changes |
| DTO constraints do not execute | @Valid on request bodies | Kotlin needs @field: targets and separate malformed-JSON handling |
| Behavior is hard to discover outside source | OpenAPI annotations and responses | Document meaningful ambiguity rather than annotating every parameter |
Each change turns an assumption into observable behavior. The history endpoint, for example, checks appointment existence
and reads history in one transaction. It orders by changedAt DESC, id DESC so entries with the same timestamp still have
a deterministic order.
Cancellation reason also crosses several layers. The controller parameter is useful only if the same effective value reaches transition validation, audit history, and the cancellation event. Review creates durable value when it identifies these cross-cutting contracts and locks them with regression tests.
Multitenancy Changes the Meaning of Every Identifier
Section titled “Multitenancy Changes the Meaning of Every Identifier”Adding tenant_group_id was not the hardest part of serving multiple clinic groups. The application had to connect the
tenant in the URL, tenants allowed by the JWT, the clinic in the path, and doctor, treatment, and equipment identifiers
inside the request to one ownership boundary.
The API now uses /api/{tenantCode}/....
TenantAuthorizationManager
checks whether the URL code is present in the JWT allowedTenants claim. An unauthenticated request does not query the
tenant database and proceeds to the 401 path. An authenticated request gets 404 for an unknown tenant and 403 for a known
but disallowed tenant.
val tenantCode = context.variables["tenantCode"] ?: TenantPathResolver.resolve(context.request) ?: return AuthorizationDecision(false)
return AuthorizationDecision(tenantCode in principal.allowedTenants)Passing authorization does not make arbitrary resource IDs safe.
TenantClinicAccessChecker.verifySchedulingResources() confirms that clinic, doctor, treatment type, and optional
equipment belong to both the same tenant and the same clinic. Repository methods such as findByIdAndTenant() use JOIN
guards, turning a cross-tenant ID into the same 404 shape as a missing resource and reducing enumeration leakage.
The design also keeps TenantContext at the controller boundary. Suspend services and repositories receive
tenantGroupId explicitly instead of depending on implicit ThreadLocal state. Function parameters remain traceable when
coroutines and asynchronous work cross execution contexts.
Notifications Need Event Boundaries, Deduplication, and Isolation
Section titled “Notifications Need Event Boundaries, Deduplication, and Isolation”Created, confirmed, cancelled, and rescheduled appointments can trigger notifications. Calling SMS or email providers
directly from the API service would couple state changes to external channel failures. AppointmentService publishes
Spring application events, and NotificationEventListener consumes them in the same process.
That scope matters. The current @EventListener boundary is not a broker or transactional outbox. It does not guarantee
delivery if the process stops immediately after commit. Stronger delivery semantics remain a separate design problem.
Reminders add a multi-instance problem. Three application instances could otherwise send the same message three times.
AppointmentReminderScheduler executes its today/tomorrow query inside Redis-backed
LettuceLeaderGroupElector.runIfLeader(). It also skips an appointment when successful notification history already
contains the same event type.
ResilientNotificationChannel wraps external calls with Resilience4j CircuitBreaker, Retry, and Bulkhead. Retry absorbs
transient faults, CircuitBreaker reduces calls into a repeatedly failing provider, and Bulkhead limits concurrent blockage.
The wrapper logs and absorbs the final exception, however, so a returned method call alone is not delivery evidence.
Notification history and provider responses must remain observable.
Database Compatibility Requires Execution Evidence
Section titled “Database Compatibility Requires Execution Evidence”H2 is convenient during development, but an H2 migration does not prove production DDL safety. Tenant migrations exposed differences in existing unique constraints, index names, and drop syntax. H2 required table recreation in one case, while MySQL and PostgreSQL needed engine-specific constraint and index operations.
Tenant migrations V3 through V6 therefore have separate h2, mysql, and postgresql directories. Verification is also
separate through FlywayMigrationTest, FlywayMySQLMigrationTest, and FlywayPostgreSQLMigrationTest. Executing the
migration against each engine is stronger evidence than visually comparing similar SQL.
The Exposed Gradle plugin later added generateMigrations tasks to core, API, event, and notification modules. Generation
does not replace review. Engine-specific handwritten migrations, generated diffs, and real container results must be
compared before changing an operational schema.
Dependency Migration Must Preserve Meaning, Not Only Compilation
Section titled “Dependency Migration Must Preserve Meaning, Not Only Compilation”Timefold Solver 2.1 changed score, ConstraintVerifier, score-director, and move APIs together. Compilation fixes alone
could not prove schedule behavior. Constraint weights changed to Long, constraint identifiers gained stricter character
rules, and move tests adopted preview APIs and SequencedCollection types.
The migration objective was to retain the existing scheduling semantics while adopting the new API. ConstraintVerifier and solver tests were rerun, then the local benchmark baseline compared input size, score, and duration. Because benchmark tests are excluded from normal CI, a dependency PR needs explicit benchmark evidence when performance behavior matters.
The April 2026 Apple M4 Pro and JDK 25 measurements for 10, 30, and 100 appointments are a regression starting point, not a permanent promise. Clinic data distribution, pinned ratios, equipment quantity, JVM, and dependency versions all change the baseline.

Living Documentation Belongs in the Same Feedback Loop
Section titled “Living Documentation Belongs in the Same Feedback Loop”The project keeps current behavior in docs/requirements, change design and execution in docs/superpowers/specs and
plans, and implementation findings in docs/lessons. Their roles are different:
- Requirements describe the behavior and boundary a user can expect now.
- Specs and plans record agreed design, sequence, and verification before a change.
- Lessons record what implementation and review changed, failed alternatives, and future guards.
More documents do not automatically mean living documentation. A README that still uses /api/... after tenant routes
move to /api/{tenantCode}/... is actively dangerous. Current docs should change with behavior, while historical design
artifacts should remain clearly identified as decision records from their time.
A useful lesson is more specific than “this was difficult.” It preserves the root cause, chosen correction, rejected path, verification commands, and a guard for future work. The next developer can then begin with source and evidence instead of reconstructing the same failure.
What We Have Not Solved Yet: Start the Next Cycle with Integrity
Section titled “What We Have Not Solved Yet: Start the Next Cycle with Integrity”This series did not solve every appointment problem. It concentrated on state transitions, availability, rescheduling, tenant isolation, and notification fault isolation. Before adding more features, a production service needs stronger boundaries in several areas.
| Priority | Incomplete boundary | Next step |
|---|---|---|
| 1. Booking integrity | Capacity races between slot lookup and creation | Make availability validation and persistence atomic; serialize concurrent writes with a database lock or constraint strategy |
| 2. Event delivery | In-process events do not survive every process failure after a database commit | Add a transactional outbox, event IDs and schema versions, consumer deduplication, retries, dead letters, and reconciliation |
| 3. Operability | Logs alone cannot reconstruct one flow from patient request to notification provider | Connect SLOs and trace/correlation IDs to conflict rate, delivery success and latency, leader changes, and solver duration and score |
| 4. Patient workflow | An administrator-oriented flow does not cover the real experience after self-service booking | Define patient authentication and portal, waitlists, no-show recovery, notification consent, accessibility, localization, and data-retention policy |
| 5. Optimization governance | A good score does not guarantee an assignment that patients and operators can understand | Record constraint versions and recommendation reasons; validate fairness, manual overrides, and policy changes through shadow runs |
The first item comes before feature growth. The current
AppointmentService.create()
now returns the original appointment when the same Idempotency-Key and request body are replayed, and rejects a different
request that reuses the key. It stores the appointment and idempotency record in one transaction, then recovers concurrent
unique-key conflicts by rereading the committed record. Network-retry idempotency is therefore an implemented boundary.
Capacity integrity remains separate. If two distinct requests have both observed “one slot remaining,”
the earlier lookup cannot decide which concurrent write owns that capacity. A rule such as resolveMaxConcurrent() chooses
the allowed quantity; it does not serialize competing writes. The server needs to revalidate availability and persist in
one transaction and translate conflicts into a consistent 409 response.
Adding a broker alone does not solve event delivery either. A database commit can succeed while event publication fails, creating a dual-write gap. Writing an outbox record in the appointment transaction and making publishers and consumers tolerant of duplicate delivery closes part of that gap. Operators still need reconciliation that compares appointment state with notification history and repairs omissions, turning “retry it” into a specific recovery procedure.
The next question is what to measure. Average response time does not describe the health of an appointment service. Blocked double-booking attempts, notification delay, time to approve a reschedule, per-tenant error rates, and the manual adjustment rate of solver proposals connect technical signals to clinical operations. Recovery exercises for backups, Redis loss, provider failure, and a failed database migration belong at the same requirements level.
Finally, broader optimization creates a larger duty to explain decisions. Emergency priority, patient preference, and clinician workload can conflict. The system should preserve which rule and weight produced a recommendation, compare new constraints in shadow mode before applying them, and keep the operator’s reason for overriding a proposal. A solver result remains advice; the explanation and override history should become evidence for the next constraint change.
Seven Principles from the Series
Section titled “Seven Principles from the Series”The implementation repeatedly reinforced these principles:
- An appointment is a stateful commitment over time and resources, not a CRUD row.
- Clinic-specific policy belongs in data, with calculation boundaries kept explicit.
- Fast candidate search for one request and global optimization solve different use cases.
- Solver constraints are operating policy that must be explainable and tested.
- Closures and equipment downtime require lineage, candidates, approval, and communication.
- Tenant, transaction, event, and external-channel boundaries must be revalidated at each identifier and call.
- Review and operations should feed small requirements and regression tests back into development.
“Done” does not mean the code will never change. It means the current contract is explainable, representative failures are tested, and the boundaries to revisit during the next change are known. This first clinic appointment development cycle closes here; the next cycle has already begun with real operational evidence.
Explore the Implementation and Lessons
Section titled “Explore the Implementation and Lessons”- State-history endpoint lesson: Response DTO, single transaction, and deterministic ordering.
- Cancellation-reason lesson: Sends one effective reason through state machine, history, and event.
- Multitenancy lesson: URL and JWT guards, child-resource ownership, and database-specific migrations.
- Tenant access checker: Validates tenant, clinic, doctor, treatment, and equipment as one boundary.
- Reminder scheduler: Uses Redis leader election and notification history to reduce duplicates.
- Resilient notification channel: Isolates external calls with CircuitBreaker, Retry, and Bulkhead.
- Appointment creation service: Shows implemented idempotent request persistence and recovery alongside the remaining capacity race.
- Project backlog: Tracks follow-up candidates including the patient portal and asynchronous messaging.
- Timefold Solver 2 migration lesson: Records verification used to preserve solver meaning across API changes.
- Solver benchmark report: Keeps environment, input scale, score, and time together as a local baseline.
Comments
Leave a note or reaction with your GitHub account.