Skip to content

Harden clean architecture and domain workflows - #42

Merged
MartinKalema merged 5 commits into
mainfrom
codex/clean-architecture-hardening
Jul 12, 2026
Merged

Harden clean architecture and domain workflows#42
MartinKalema merged 5 commits into
mainfrom
codex/clean-architecture-hardening

Conversation

@MartinKalema

@MartinKalema MartinKalema commented Jul 12, 2026

Copy link
Copy Markdown
Owner

What this PR fixes

This PR fixes several cases where the system could save conflicting or invalid data. Authentication is intentionally not part of this change.

Borrowing a book

Previously, a client could create a loan directly and provide its own patron email, book title, and loan length. This allowed loans for missing patrons or books and could bypass borrowing limits.

Now:

  • borrowing always starts through the book-borrowing workflow
  • the system loads the real patron and book information instead of trusting client-provided copies
  • the part of the system that stores loans decides whether the patron may borrow, how many books they may have, and how long the loan lasts
  • borrowing limits are checked while the database prevents two requests from taking the same final slot

When this PR says Lending is authoritative, it means Lending is the single source of truth for loan decisions. Catalog tracks whether a physical book is available, but it does not independently decide whether a loan exists or has ended.

Protecting a newer reservation from old messages

Borrowing happens through background messages, so an old delayed message could previously confirm or cancel a newer patron's reservation.

Every reservation attempt now has:

  • a unique reservation ID
  • the patron who owns it
  • an increasing attempt number
  • the exact loan ID created for it

Every later step must match all of those values before changing the book or loan.

This safety check is sometimes called fencing. A simple way to think about it is a numbered ticket: a message holding ticket 1 cannot change work that now belongs to ticket 2.

Returning a book

Previously, the book endpoint and loan endpoint could each record only half of a return. That could leave an available book with an active loan, or a returned loan with a book still marked as borrowed.

Now the loan return is the only public return command:

  1. Lending records the loan as returned.
  2. A background message asks Catalog to make the book available.
  3. Catalog changes the book only when the message contains the exact current loan and reservation details.

A delayed return message for an older loan cannot return a book that has since been borrowed again.

Reliable background processing

Background messages now have stable names and version numbers. This lets newer code understand older saved messages and prevents a class rename from silently breaking message delivery.

For every message handler, the database records whether that exact handler has already completed the message. This means:

  • retrying a message does not create a second loan or repeat the same state change
  • one successful handler does not hide another handler that still needs to run
  • temporary problems, such as a database outage or timeout, are retried
  • permanently invalid or unsupported messages are saved for investigation instead of being silently discarded
  • email sending runs separately, so an email-provider failure cannot block book and loan updates

Database and deployment safety

  • The API no longer creates database tables when it starts.
  • Alembic migrations are now the required way to create or update the schema.
  • Database rules prevent invalid reservation, loan, date, status, and identity combinations.
  • Pending messages are preserved during the old-outbox migration.
  • The event worker and expired-reservation worker start in the normal Docker Compose setup because borrowing cannot finish correctly without them.
  • Cleanup jobs keep processed messages and outbox records for a safe recovery period instead of growing forever.

API retries and read performance

  • Commands accept idempotency keys, so retrying a timed-out request does not repeat the operation.
  • A borrow request returns an operation ID that can be checked while background work completes.
  • Cache entries are invalidated after successful changes.
  • Search uses bounded, stable cursor pagination.
  • PostgreSQL fallback queries and indexes were improved for when Elasticsearch is unavailable.
  • Reindexing protects concurrent updates instead of losing changes made during a rebuild.
  • Expired-reservation cleanup and other maintenance work run in small batches.

Why this matters

After these changes, Catalog and Lending cannot be changed independently through public commands. Old background messages cannot overwrite newer work, request retries are safe, invalid data is rejected at both the domain and database levels, and correctness-critical workers are part of the normal deployment.

Validation

  • GitHub Actions migrated PostgreSQL test suite: 356 tests passed
  • Local domain, application, infrastructure, and presentation suite: 325 tests passed
  • mypy: no issues in 163 source files
  • Alembic legacy upgrade tests: passed
  • Docker Compose topology validation: passed
  • Alembic head: revision 009

Authentication remains unchanged and is outside the scope of this PR.

@MartinKalema
MartinKalema merged commit 0b33044 into main Jul 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant