# Architecture

## Component layout

Standard layered architecture: HTTP depends on services, services depend on repositories, nothing depends back upward. `internal/models` and `internal/apperror` are shared vocabulary every layer can import without creating a cycle.

```mermaid
flowchart TB
    Client(["HTTP client"])

    subgraph HTTP["internal/handler + internal/middleware"]
        MW["Middleware chain:<br/>RequestID → Logging → Recovery → CORS → RateLimit → MaxBodySize"]
        Auth["RequireAuth<br/>(JWT, mutating routes only)"]
        Router["ServeMux<br/>(Go 1.22+ method+pattern routing)"]
        Handlers["Handlers<br/>(decode → validate → call one service method → respond)"]
    end

    subgraph Biz["internal/service"]
        AuthorSvc["AuthorService"]
        BookSvc["BookService"]
        BorrowerSvc["BorrowerService"]
        LoanSvc["LoanService<br/>(Borrow / Return — transactional)"]
    end

    subgraph Data["internal/repository"]
        Store["Store<br/>(bundles all repositories,<br/>WithinTx for cross-repo transactions)"]
        AuthorRepo["AuthorRepository"]
        BookRepo["BookRepository"]
        BorrowerRepo["BorrowerRepository"]
        LoanRepo["LoanRepository"]
    end

    DB[("SQLite<br/>(modernc.org/sqlite, pure Go)")]

    Client --> MW --> Auth --> Router --> Handlers
    Handlers --> AuthorSvc & BookSvc & BorrowerSvc & LoanSvc
    AuthorSvc & BookSvc & BorrowerSvc & LoanSvc --> Store
    Store --> AuthorRepo & BookRepo & BorrowerRepo & LoanRepo
    AuthorRepo & BookRepo & BorrowerRepo & LoanRepo --> DB

    Validator["internal/validator<br/>(struct-tag validation)"] -.-> Handlers
    AppError["internal/apperror<br/>(typed errors → HTTP status)"] -.-> Handlers
    AppError -.-> Biz
    AuthPkg["internal/auth<br/>(JWT + bcrypt)"] -.-> Auth
    AuthPkg -.-> Handlers
```

## Data model

```mermaid
erDiagram
    AUTHOR ||--o{ BOOK : writes
    BOOK ||--o{ LOAN : "has loan history"
    BORROWER ||--o{ LOAN : borrows

    AUTHOR {
        int64 id PK
        string name
        string bio
        datetime created_at
        datetime updated_at
    }
    BOOK {
        int64 id PK
        string isbn UK
        string title
        int64 author_id FK
        string genre
        int published_year
        int total_copies
        int available_copies "0 <= available <= total"
        datetime created_at
        datetime updated_at
    }
    BORROWER {
        int64 id PK
        string name
        string email UK
        string phone
        datetime membership_date
        datetime created_at
        datetime updated_at
    }
    LOAN {
        int64 id PK
        int64 book_id FK
        int64 borrower_id FK
        datetime borrowed_at
        datetime due_at
        datetime returned_at "nullable"
        datetime created_at
    }
```

`LOAN` deliberately has no `status` column. Status (`active` / `overdue` / `returned`) is *derived* — `models.Loan.Status(now)` computes it from `returned_at` and `due_at` every time, and `MarshalJSON` includes it in every API response. A stored status column would be redundant data that could drift from the timestamps that are the real source of truth; deriving it makes that class of bug structurally impossible.

`AUTHOR.id` and `BOOK.id`/`BORROWER.id` are referenced with `ON DELETE RESTRICT` — you cannot delete an author who still has books, or a book/borrower with loan history. The API surfaces this as a `409 Conflict`, not a `500`.

## Request lifecycle

Every request — public or authenticated — flows through the same middleware chain. `RequireAuth` is the only middleware applied selectively, per-route, at registration time in `router.go` rather than globally.

```mermaid
sequenceDiagram
    participant C as Client
    participant MW as Middleware chain
    participant R as ServeMux
    participant H as Handler
    participant V as Validator
    participant S as Service
    participant Repo as Repository
    participant DB as SQLite

    C->>MW: HTTP request
    MW->>MW: assign/forward X-Request-ID
    MW->>MW: (if mutating route) verify JWT
    MW->>R: route by method + path pattern
    R->>H: dispatch
    H->>V: decode JSON body into DTO, validate tags
    alt validation fails
        V-->>H: apperror.Validation (field map)
        H-->>C: 400 {"error": {"code": "validation_error", "fields": {...}}}
    else valid
        H->>S: call exactly one service method
        S->>Repo: repository call(s)
        Repo->>DB: SQL
        DB-->>Repo: rows / result
        Repo-->>S: domain model or apperror
        S-->>H: domain model or apperror
        H-->>C: 200/201/204 {"data": ...} or mapped error status
    end
    MW->>MW: log method, path, status, duration, request ID
```

## Borrow / return — the one multi-step transaction in the system

This is the only place two repositories are written together, so it's the only place `Store.WithinTx` is used. Both steps commit or neither does.

```mermaid
sequenceDiagram
    participant H as LoanHandler
    participant LS as LoanService
    participant Store as Store.WithinTx
    participant BookRepo
    participant LoanRepo
    participant DB as SQLite

    H->>LS: Borrow(bookID, borrowerID, period)
    LS->>Store: WithinTx(fn)
    activate Store
    Store->>BookRepo: GetByID(bookID) — exists?
    Store->>LoanRepo: (via BorrowerRepo) GetByID(borrowerID) — exists?
    Store->>BookRepo: DecrementAvailable(bookID)
    Note over BookRepo,DB: UPDATE books SET available_copies = available_copies - 1<br/>WHERE id = ? AND available_copies > 0
    alt 0 rows affected (no copies left)
        BookRepo-->>Store: ErrNoCopiesAvailable
        Store->>DB: ROLLBACK
        Store-->>LS: apperror.Conflict
    else copy claimed
        Store->>LoanRepo: Create(loan)
        LoanRepo->>DB: INSERT INTO loans (...)
        Store->>DB: COMMIT
        Store-->>LS: *models.Loan (joined w/ book title, borrower name)
    end
    deactivate Store
    LS-->>H: loan or apperror
```

The `available_copies > 0` condition living inside the `UPDATE`'s `WHERE` clause — not a separate `SELECT` followed by an `UPDATE` — is what makes this safe under concurrent borrow requests for the last copy of a title: two simultaneous transactions racing for the same row can't both succeed, because SQLite serializes writes to a row and the loser's `UPDATE` genuinely affects 0 rows rather than corrupting the count to `-1`. `TestLoanService_Borrow_NoCopiesAvailable` in `internal/service` asserts exactly this: `available_copies` reads `0`, never negative, after a failed second borrow.

Return is the mirror image: `MarkReturned` only affects a row `WHERE returned_at IS NULL` (idempotent — a second return attempt on the same loan gets `ErrAlreadyReturned` instead of silently double-crediting `available_copies`), then `IncrementAvailable` releases the copy, both inside the same `WithinTx`.
