# ๐๏ธ 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/`)
| 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:
| `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
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)
| `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.