mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add new documentation for Dapper migration/database schema
This commit is contained in:
@@ -35,9 +35,11 @@ For visual representations, see:
|
||||
|
||||
The backend organizes business capabilities as feature slices instead of
|
||||
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
|
||||
`Features.UserManagement`, `Features.Emails`) is a single project that owns its
|
||||
own controller, MediatR commands/queries/handlers, validators, and repository,
|
||||
end to end:
|
||||
`Features.UserManagement`, `Features.Emails`, `Features.Locations`) is a single
|
||||
project that owns its own MediatR commands/queries/handlers, validators, and
|
||||
repository end to end. `Features.Emails` and `Features.Locations` have no
|
||||
`Controllers/` folder — both are invoked internally by other slices via
|
||||
MediatR/DI, never over HTTP:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
@@ -46,15 +48,15 @@ end to end:
|
||||
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
↓ discovers controllers via AddApplicationPart
|
||||
┌───────────────┬───────────────┬───────────────────┬───────────────────┐
|
||||
│ Features.Auth │Features. │ Features. │ Features.Emails │
|
||||
│ │Breweries │ UserManagement │ (no controller, │
|
||||
│ Controller │ Controller │ Controller │ internal only) │
|
||||
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │
|
||||
│ Queries + │ Queries + │ Queries + │ Handlers │
|
||||
│ Handlers │ Handlers │ Handlers │ │
|
||||
│ Repository │ Repository │ Repository │ EmailDispatcher │
|
||||
└───────────────┴───────────────┴───────────────────┴───────────────────┘
|
||||
┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┐
|
||||
│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │
|
||||
│ │Breweries │ UserManagement │ (no controller, │ Locations │
|
||||
│ Controller │ Controller │ Controller │ internal only) │ (no controller, │
|
||||
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │
|
||||
│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │
|
||||
│ Handlers │ Handlers │ Handlers │ │ │
|
||||
│ Repository │ Repository │ Repository │ EmailDispatcher │ │
|
||||
└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┘
|
||||
↓ each slice depends only on shared/domain/infra, never on another slice
|
||||
┌─────────────────────────┬─────────────────────────┬────────────────────┐
|
||||
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
|
||||
@@ -108,7 +110,7 @@ own
|
||||
- No controllers, no business logic, no feature-specific contracts
|
||||
- Exists purely to host and wire up the feature slices
|
||||
|
||||
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`)
|
||||
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`)
|
||||
|
||||
**Purpose**: Each slice is the complete vertical for one business capability
|
||||
|
||||
@@ -126,7 +128,12 @@ own
|
||||
slice's repository/services
|
||||
|
||||
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
|
||||
commands sent from other slices, never over HTTP.
|
||||
commands sent from other slices, never over HTTP. `Features.Locations` also has
|
||||
no `Controllers/` folder and no MediatR handlers — it exposes
|
||||
`ILocationRepository` directly as a plain service, currently consumed only by
|
||||
`Database.Seed` to look up cities while seeding brewery locations. Other slices
|
||||
(for example, `Features.Breweries`) do not yet depend on it; they run their own
|
||||
`CityID` existence checks inline.
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
@@ -198,6 +205,9 @@ whichever slices need them
|
||||
- `UserAccount` - User profile data
|
||||
- `UserCredential` - Authentication credentials
|
||||
- `UserVerification` - Account verification state
|
||||
- `BreweryPost` - A user-submitted brewery listing
|
||||
- `BreweryPostLocation` - A brewery's address and coordinates
|
||||
- `City` - A city referenced by a brewery location
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
@@ -258,7 +268,6 @@ only:
|
||||
**Benefits**:
|
||||
|
||||
- Testable (easy to mock)
|
||||
- SQL-first approach (stored procedures)
|
||||
- Each slice's data access logic is self-contained
|
||||
|
||||
**Example**:
|
||||
@@ -285,22 +294,31 @@ method that registers its repository and slice-internal services
|
||||
- Singleton: `ISqlConnectionFactory`
|
||||
- Transient: Utilities, helpers
|
||||
|
||||
#### SQL-first approach
|
||||
#### Direct SQL via Dapper/ADO.NET
|
||||
|
||||
**Purpose**: Push complex logic into the database
|
||||
**Purpose**: Keep data access explicit and colocated with the slice that owns
|
||||
it, without an ORM's change-tracking or mapping magic
|
||||
|
||||
**Strategy**:
|
||||
|
||||
- All queries via stored procedures
|
||||
- No ORM (Entity Framework not used)
|
||||
- Database handles complex logic
|
||||
- Application focuses on orchestration
|
||||
- No ORM (Entity Framework not used); each
|
||||
repository issues inline SQL (a mix of Dapper and raw
|
||||
`DbCommand`/`DbDataReader` for the few reads that need manual column mapping,
|
||||
such as the `GEOGRAPHY` column on `BreweryPostLocation`)
|
||||
- Referential checks that a stored procedure used to perform (e.g. "does this
|
||||
`CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the
|
||||
repository method, run inside the same transaction as the write
|
||||
- Optimistic concurrency uses each table's `Timer` (`ROWVERSION`) column,
|
||||
checked in the `UPDATE ... WHERE ... AND Timer = @Timer` clause
|
||||
- Repositories throw `Domain.Exceptions` types (`NotFoundException`,
|
||||
`ConflictException`, ...) directly when a check fails, rather than relying on
|
||||
a database-side `THROW`; `API.Core`'s `GlobalExceptionFilter` maps those
|
||||
exception types to HTTP status codes
|
||||
- Application focuses on orchestration; the database enforces integrity via
|
||||
keys, `CHECK` constraints, and cascades
|
||||
|
||||
**Stored Procedure Examples**:
|
||||
|
||||
- `USP_RegisterUser` - User registration
|
||||
- `USP_GetUserAccountByUsername` - User lookup
|
||||
- `USP_RotateUserCredential` - Password update
|
||||
See [Database](website/database.md) for the schema and the app's current SQL
|
||||
error-handling approach in more detail.
|
||||
|
||||
## Frontend architecture
|
||||
|
||||
@@ -357,18 +375,29 @@ for reference only. Active product and engineering documentation should point to
|
||||
1. **Registration**:
|
||||
- User submits credentials
|
||||
- Password hashed with Argon2id
|
||||
- User account created
|
||||
- JWT token issued
|
||||
- User account created (unverified) and a confirmation email is dispatched
|
||||
via `Features.Emails`
|
||||
|
||||
2. **Login**:
|
||||
2. **Email confirmation**:
|
||||
- User follows the confirmation link/token from the email
|
||||
- `Features.Auth`'s `ConfirmUser` command marks the account verified
|
||||
|
||||
3. **Login**:
|
||||
- User submits credentials
|
||||
- Password verified against hash
|
||||
- JWT token issued
|
||||
- Token stored client-side
|
||||
- Access and refresh JWTs are issued; the refresh token is stored server-side
|
||||
against the user's credential row
|
||||
|
||||
3. **API Requests**:
|
||||
- Client sends JWT in Authorization header
|
||||
- Middleware validates token
|
||||
4. **Token refresh**:
|
||||
- Client exchanges a valid, unexpired refresh token for a new access/refresh
|
||||
pair via `Features.Auth`'s `RefreshToken` command
|
||||
- Refresh tokens are stateless JWTs, not tracked server-side: the previous
|
||||
refresh token is not invalidated and remains usable until its own
|
||||
expiration
|
||||
|
||||
5. **API Requests**:
|
||||
- Client sends the access JWT in the `Authorization` header
|
||||
- `JwtAuthenticationHandler` validates the token
|
||||
- Request proceeds if valid
|
||||
|
||||
### Password security
|
||||
@@ -433,32 +462,40 @@ scheme, see [Database](website/database.md).
|
||||
|
||||
**Process**:
|
||||
|
||||
1. Write SQL migration script
|
||||
2. Embed in `Database.Migrations` project
|
||||
3. Run migrations on startup
|
||||
4. Idempotent and versioned
|
||||
1. Write a SQL script under `Database.Migrations/scripts/`
|
||||
2. Embed it in the `Database.Migrations` project
|
||||
3. `Database.Migrations` runs each script against the target database on
|
||||
startup, tracked so a script never re-runs once applied
|
||||
|
||||
**Migration Files**:
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── 001-CreateUserTables.sql
|
||||
├── 002-CreateLocationTables.sql
|
||||
├── 003-CreateBreweryTables.sql
|
||||
└── ...
|
||||
└── 01-schema/
|
||||
└── schema.sql # full table/index/constraint definitions
|
||||
```
|
||||
|
||||
Currently the whole schema is a single versioned script. As the schema evolves,
|
||||
new numbered scripts get added alongside it rather than editing `schema.sql` in
|
||||
place, so DbUp's already-applied tracking stays valid.
|
||||
|
||||
### Data seeding
|
||||
|
||||
**Purpose**: Populate development/test databases
|
||||
**Purpose**: Populate development/test databases with realistic data
|
||||
|
||||
**Implementation**: `Database.Seed` project
|
||||
**Implementation**: `Database.Seed` project, using data produced by the C++
|
||||
pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
|
||||
|
||||
**Seed Data**:
|
||||
|
||||
- Countries, states/provinces, cities
|
||||
- Test user accounts
|
||||
- Sample breweries (future)
|
||||
- Cities (via `Features.Locations`' `ILocationRepository`)
|
||||
- User accounts (via `Features.Auth`'s repository)
|
||||
- Brewery posts with locations (via `Features.Breweries`' repository)
|
||||
|
||||
Tables that exist in the schema but have no corresponding `Domain.Entities` type
|
||||
or feature slice yet (`Photo`, `UserAvatar`, `UserFollow`, `Country`,
|
||||
`StateProvince`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`,
|
||||
`BreweryPostPhoto`) are not seeded.
|
||||
|
||||
## Deployment architecture
|
||||
|
||||
@@ -470,6 +507,7 @@ scripts/
|
||||
- `database.migrations` - Schema migration runner
|
||||
- `database.seed` - Data seeder
|
||||
- `api.core` - ASP.NET Core Web API
|
||||
- `mailpit` - Local dev/test SMTP server + web UI (not used in production)
|
||||
|
||||
**Environments**:
|
||||
|
||||
|
||||
78
docs/website/database.md
Normal file
78
docs/website/database.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Database
|
||||
|
||||
SQL Server 2022 schema, migration approach, and the app's SQL error-handling
|
||||
convention. For the vertical-slice code that talks to this schema, see
|
||||
[Architecture](../architecture.md).
|
||||
|
||||
## Schema
|
||||
|
||||
The full schema lives in one script:
|
||||
`web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql`, run by
|
||||
`Database.Migrations` (DbUp) on startup. Each table is queried directly by the
|
||||
owning feature slice's repository (see
|
||||
[Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)).
|
||||
|
||||
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
|
||||
most carry a `Timer ROWVERSION` column used for optimistic concurrency on
|
||||
updates (`UPDATE ... WHERE ... AND Timer = @Timer`).
|
||||
|
||||
### Tables backed by the API today
|
||||
|
||||
| Table | Backed by | Notes |
|
||||
| --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Auth`/`Features.UserManagement` | `Username` and `Email` are unique |
|
||||
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
||||
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email |
|
||||
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
|
||||
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read manually via `DbDataReader.GetFieldValue<byte[]>` because Dapper can't deserialize SQL Server UDTs |
|
||||
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; looked up via `ILocationRepository`, currently consumed only by `Database.Seed` |
|
||||
|
||||
### Tables defined in the schema but not yet wired to a feature slice
|
||||
|
||||
These exist so the schema can support planned functionality, but have no
|
||||
`Domain.Entities` type, repository, or seed data yet:
|
||||
|
||||
| Table | Purpose (from schema) |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||
| `Photo` | A photo uploaded by a user account |
|
||||
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
|
||||
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
|
||||
| `StateProvince` | Referenced by `City.StateProvinceID` |
|
||||
| `Country` | Referenced by `StateProvince.CountryID` |
|
||||
| `BeerStyle` | A beer style/category |
|
||||
| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`) |
|
||||
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
|
||||
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
|
||||
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
|
||||
|
||||
## Migrations
|
||||
|
||||
**Tool**: DbUp, run by the `Database.Migrations` project/container.
|
||||
|
||||
Scripts live under `scripts/`, applied in order and tracked so a script never
|
||||
re-runs once it has succeeded. Today that's a single `01-schema/schema.sql`.
|
||||
When the schema needs to change, add a new numbered script rather than editing
|
||||
`schema.sql` in place — DbUp's tracking is per-script, so editing an
|
||||
already-applied script means it won't re-run against existing databases.
|
||||
|
||||
## Data seeding
|
||||
|
||||
`Database.Seed` populates a target database from data produced by the C++
|
||||
pipeline (see [Pipeline README](../pipeline/README.md)): cities, user accounts,
|
||||
and brewery posts with locations. It only seeds tables that have a feature slice
|
||||
to write through (see the table above).
|
||||
|
||||
## Database error handling
|
||||
|
||||
- Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero
|
||||
rows against the `Timer` column.
|
||||
- On failure, the repository throws a `Domain.Exceptions` type directly
|
||||
(`NotFoundException`, `ConflictException`, `ArgumentException`, ...).
|
||||
`API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404,
|
||||
409, 400, ...); an unmapped `SqlException` (e.g. a genuine connectivity or
|
||||
constraint-violation failure) maps to 503.
|
||||
|
||||
- One repository-level exception: `Features.Auth`'s `AuthRepository` does
|
||||
catch `SqlException` directly, to detect a duplicate-key race (SQL Server
|
||||
error 2601 or 2627) when two requests try to verify the same account
|
||||
concurrently — see `Auth Repository.IsDuplicateVerificationViolation`.
|
||||
Reference in New Issue
Block a user