backbone-core 3.0.0

Backbone Framework Core - Foundation for generic CRUD system
Documentation
# ๐Ÿ›๏ธ Backbone Core โ€” Architecture

**Status:** โœ… Current ยท **Last Updated:** 2026-06-06

This document explains how `backbone-core` is structured and why. It is written for
contributors and for anyone integrating the crate who wants the mental model rather
than a recipe (for recipes, see [usage.md](usage.md)).

## ๐ŸŽฏ The one idea

Every entity in a Backbone application needs the same things: list, read, create,
update, soft-delete, restore, bulk operations, pagination, filtering. Hand-writing
that per entity is ~250 lines of near-identical code each time. `backbone-core` makes
it **generic**: you supply an entity, its Create/Update DTOs, and a repository โ€” the
crate supplies the service behaviour and the HTTP/gRPC surface.

## ๐Ÿงฑ Layers

```
            โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  HTTP      โ”‚  BackboneCrudHandler<S,E,C,U,R>  (http.rs)   โ”‚  axum Router, ~21 routes
  / gRPC    โ”‚  GenericGraphQLResolver / gRPC   (grpc.rs)   โ”‚
            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
  Service   โ”‚  CrudService trait               (http.rs)  โ”‚  business-facing contract
            โ”‚  GenericCrudService<E,C,U,R>     (service.rs)โ”‚  hooks + events + batch
            โ”‚  UseCase / CQRS / Policy / Validation        โ”‚
            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
  Persist.  โ”‚  CrudRepository<E> trait    (persistence/)  โ”‚  storage-agnostic
            โ”‚  InMemoryRepository | PostgresRepository     โ”‚
            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
  Domain    โ”‚  PersistentEntity, AggregateRoot,           โ”‚  DDD building blocks
            โ”‚  ValueObject, Specification                  โ”‚
            โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
```

Each layer depends only on the one below it through a **trait**, never a concrete type.
That is what keeps the HTTP layer reusable across entities and the service layer
reusable across storage backends.

### Module map (`src/`)

| Area | Modules |
|------|---------|
| HTTP / transport | `http.rs`, `extractors.rs`, `grpc.rs`, `graphql.rs` |
| Service / use cases | `service.rs`, `usecase.rs`, `cqrs.rs`, `command.rs`, `query.rs`, `bulk.rs` |
| Persistence | `persistence/` (`traits.rs`, `memory.rs`, `postgres.rs`, `adapter.rs`), `repository.rs`, `macros.rs` |
| Domain (DDD) | `entity.rs`, `aggregate.rs`, `value_object.rs`, `specification.rs`, `domain_service.rs`, `policy.rs`, `validation.rs` |
| Lifecycle / orchestration | `trigger.rs`, `flow.rs`, `state_machine.rs`, `projection.rs`, `integration.rs` |
| Platform | `config/`, `module.rs`, `module_registry.rs`, `registry.rs`, `builder.rs`, `error.rs`, `utils.rs` |
| OpenAPI (feature) | `openapi.rs` |

## ๐Ÿ”Œ The generic CRUD handler

`BackboneCrudHandler<S, E, C, U, R>` is the heart of the HTTP layer. Its five type
parameters are the contract you fill in per entity:

| Param | Meaning | Bound |
|-------|---------|-------|
| `S` | Your service | `CrudService<E, C, U>` |
| `E` | The entity | `Serialize` |
| `C` | Create DTO | `DeserializeOwned` |
| `U` | Update DTO | `DeserializeOwned` |
| `R` | Response DTO | `From<E> + Serialize` |

It builds an `axum::Router` from a `base_path` (e.g. `/api/v1/products`). Helpers:

- `routes(service, base_path)` โ€” all read + write routes.
- `read_routes(service, base_path)` โ€” GET-only (list, get, trash, counts).
- `write_routes(service, base_path)` โ€” mutating routes (create, update, delete, bulk).

**Why handlers are generic (and why OpenAPI is hand-assisted).** Because the handlers
are generic over `E/C/U/R` and `base_path` is a runtime string, the crate cannot stamp
out a concrete per-entity OpenAPI spec โ€” utoipa's path macro needs concrete types and
literal paths. The crate therefore derives `ToSchema` on its shared concrete types and
ships a reusable component template; downstream crates assemble the per-entity spec.
See [openapi.md](openapi.md).

### Route precedence

Routes are registered so that static segments win over `:id` captures โ€” e.g.
`/{base}/trash/bulk` and `/{base}/restore/all` are matched before `/{base}/:id`. This
is why the bulk and trash endpoints can coexist with id-based ones.

## ๐Ÿ” Request lifecycle

```
  HTTP request
      โ”‚
      โ–ผ
  JsonOrForm extractor โ”€โ”€ decodes JSON *or* x-www-form-urlencoded (lenient: JSON fallback)
      โ”‚
      โ–ผ
  Handler โ”€โ”€ parses ListQueryParams, strips reserved keys (fields/include/with),
      โ”‚       validates pagination depth + batch size (โ†’ 400 on violation)
      โ–ผ
  CrudService โ”€โ”€ before_* hook โ†’ repository call โ†’ after_* hook โ†’ publish CrudEvent
      โ”‚
      โ–ผ
  Repository โ”€โ”€ storage (single transaction for atomic batch ops)
      โ”‚
      โ–ผ
  Response โ”€โ”€ ApiResponse / PaginatedApiResponse
      โ”‚       โ”€โ”€ field security (strip @private unless AccessScope allows) โ”€โ”€โ”
      โ”‚       โ”€โ”€ then relation expansion (?include=, batched) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
      โ”‚       โ”€โ”€ then sparse projection (?fields=) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
```

Field security runs **before** sparse projection, so the `AccessScope` ceiling
always wins over a `?fields=` request โ€” a stripped `@private` field cannot be
recovered by naming it. The `AccessScope` (`Platform` | `Tenant(id)`) is read
from an axum `Extension` injected by the app's auth middleware; an absent scope
fails closed. Entities opt in via `EntityRepoMeta::private_fields()` /
`owner_field()` (both default to no-op). See
[api-reference.md]api-reference.md#field-level-security-private--owner.

Relation expansion (`?include=`) runs between the two: declared to-one relations
are hydrated in one batched `WHERE id = ANY(...)` per relation (no N+1) and
injected as sibling objects, then sparse projection can keep or drop those keys.
The target table comes from `EntityRepoMeta::relations()` (generator-emitted),
never client input. *v1:* expanded rows bypass the target's response DTO and its
`@private` security โ€” see
[api-reference.md](api-reference.md#relation-expansion-include).

### Lifecycle hooks & events

`GenericCrudService` takes a `ServiceLifecycle<E>` (default `NoOpLifecycle`) with
`before_create/after_create/before_update/after_update/before_delete/after_delete`, and
a `CrudEventPublisher<E>` that receives `CrudEvent::{Created, Updated, SoftDeleted,
Restored, HardDeleted}` per affected entity โ€” batch operations fire one event per row,
exactly like the single-row methods.

## ๐Ÿ—‘๏ธ Soft-delete & trash model

Entities carry `deleted_at`. Soft-delete sets it; the entity stays in storage but is
excluded from normal `list`/`get`. The `trash` endpoints query the deleted set
explicitly, `restore` clears `deleted_at`, and `permanent_delete` / `empty_trash`
remove rows for good. Batch variants (`restore/bulk`, `delete/bulk`, `trash/bulk`,
`restore/all`) run in a single transaction with all-or-nothing semantics.

## ๐Ÿ“ Guard rails (constants)

| Constant | Value | Purpose |
|----------|-------|---------|
| `MAX_PER_PAGE` | 100 | Page size is clamped here before computing the offset |
| `MAX_PAGINATION_OFFSET` | 10_000 | Requests paging deeper are rejected (`400`) instead of running a slow deep `OFFSET` scan |
| `MAX_BATCH_SIZE` | 1_000 | Batch requests larger than this are rejected (`400`) at both the HTTP and service layer |

### 400 vs 500 classification

A bad filter/sort key (a typo, or a stray `sortOrder`) reaches Postgres and produces
`column "..." does not exist` (SQLSTATE `42703`) or `invalid input syntax`. The handler
classifies these as **client errors โ†’ `400`**, not server errors โ†’ `500`, so clients get
an actionable signal. Genuine database/server failures still surface as `500`.

## ๐Ÿงช Testing seam

`InMemoryRepository` implements the same `CrudRepository<E>` trait as
`PostgresRepository`, so services can be unit-tested with zero database. The
`impl_crud_repository!` macro generates a `CrudRepository` impl for a generated repo
struct, keeping hand-written and generated code in sync.

โ†’ Next: [usage.md](usage.md) to put this together in code.