Migrate Auth/Brewery/Location repositories to pure Dapper; rename Timer to RowVersion (#237)

* Integrate Spectre.Console into seed application, use async data loading

* Replace stored-procedure repositories with Dapper-based repositories

Removes the USP_* stored procedures for Auth, Location, and Brewery
data access in favour of services built with Dapper.

Adds a new Features.Locations slice and optimistic concurrency support
for the Brewery slice.

* Add new documentation for Dapper migration/database schema

* Update FluentAssertions to most recent open source ver

* Update XML doc comment in backend projects

* Modify brewery location dto to include denormalized data

* - Updated column names from 'Timer' to 'RowVersion' in schema.sql for
- Introduced new BeerPost entity and updated BreweryPost and BreweryPostLocation entities to use 'RowVersion' for concurrency.
- Modified UserAccount, UserCredential, UserVerification, City, StateProvince, and Country entities to use 'RowVersion' instead of 'Timer'.
- Updated tests and command handlers to reflect changes in concurrency token from 'Timer' to 'RowVersion'.
- Adjusted BreweryDto and BreweryDtoMapper to include 'RowVersion' and updated mapping logic accordingly.
- Ensured all repository methods and interfaces are aligned with the new concurrency handling approach.

* updates

* Add BeerStyle entity and related properties; refactor BreweryDto to remove BeerPosts

* Fix Api.Specs test failures: stale Timer column refs and missing test.user fixture

* Remove repository tests, usages of registration dto used in repo

* archive old stored procs/functions

* update stale docs

* Add instructions for generating cryptographically secure base64 secrets on macOS/Linux and Windows
This commit is contained in:
2026-08-21 13:09:13 -04:00
committed by Aaron Po
parent b27b5bdb40
commit de1e9ac02b
151 changed files with 2790 additions and 1988 deletions

View File

@@ -13,8 +13,8 @@ active website:
- **Architecture Style**: Vertical-slice backend plus server-rendered React
frontend
The legacy Next.js frontend has been retained in `archive/next-js-web-app/`
for reference only.
The legacy Next.js frontend has been retained in `archive/next-js-web-app/` for
reference only.
## Diagrams
@@ -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 @@ repository, 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 / │
@@ -69,23 +71,22 @@ repository, end to end:
↓
┌─────────────────────────────────────┐
│ Database (SQL Server) │
│ - Stored procedures │
│ - Tables & constraints │
└─────────────────────────────────────┘
```
Slices never reference each other's project. The one cross-slice
interaction (`Features.Auth` triggering a confirmation email handled by
`Features.Emails`) goes through a MediatR command (`SendRegistrationEmailCommand`)
whose contract lives in `Shared.Application`, so neither slice takes a
project reference on the other.
Slices never reference each other's project. The one cross-slice interaction
(`Features.Auth` triggering a confirmation email handled by `Features.Emails`)
goes through a MediatR command (`SendRegistrationEmailCommand`) whose contract
lives in `Shared.Application`, so neither slice takes a project reference on the
other.
### Layer responsibilities
#### API layer (`API.Core`)
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of
its own
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of its
own
**Components**:
@@ -108,32 +109,36 @@ its 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
**Components** (per slice):
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types
as the request contract
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per
operation, each containing the Command/Query record, its
`IRequestHandler`, and (for commands) a FluentValidation validator
- `Repository/`: the slice's own stored-procedure repository
implementation
- `Dtos/`: response shapes returned by query handlers (never the raw
domain entity)
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering
the slice's repository/services
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types as the
request contract
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per operation,
each containing the Command/Query record, its `IRequestHandler`, and (for
commands) a FluentValidation validator
- `Repository/`: the slice's own Dapper repository implementation
- `Dtos/`: response shapes returned by query handlers (never the raw domain
entity)
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering the
slice's repository/services
`Features.Emails` has no `Controllers/` folder. It's invoked only via
MediatR commands sent from other slices, never over HTTP.
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
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**:
- `Domain.Entities`, `Domain.Exceptions`
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever
infrastructure project the slice needs (`Infrastructure.Jwt`/
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure
project the slice needs (`Infrastructure.Jwt`/
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/
`Infrastructure.Email.Templates` for Emails)
- `Shared.Contracts`, `Shared.Application`
@@ -142,29 +147,27 @@ MediatR commands sent from other slices, never over HTTP.
**Rules**:
- All business logic for that feature lives in its command/query handlers
- No direct controller-to-repository calls; everything flows through
MediatR
- No direct controller-to-repository calls; everything flows through MediatR
- Read endpoints return a dedicated `Dto`, never the domain entity directly
#### Shared projects (`Shared.Contracts`, `Shared.Application`)
**Purpose**: The minimum cross-slice surface area required because every
slice needs it, or because duplicating it four times would be worse than
sharing it
**Purpose**: The minimum cross-slice surface area required because every slice
needs it, or because duplicating it four times would be worse than sharing it
**Components**:
- `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response
envelope every controller returns
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the
MediatR pipeline behavior that runs FluentValidation before a handler
executes) and the cross-slice email commands
(`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`)
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the MediatR
pipeline behavior that runs FluentValidation before a handler executes) and
the cross-slice email commands (`SendRegistrationEmailCommand`,
`SendResendConfirmationEmailCommand`)
**Rules**:
- Kept deliberately small: this is the exception to "no slice depends on
another slice," not a general-purpose dumping ground
- Kept deliberately small: this is the exception to "no slice depends on another
slice," not a general-purpose dumping ground
#### Infrastructure layer
@@ -201,6 +204,14 @@ 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
- `StateProvince` - A state/province referenced by a city
- `Country` - A country referenced by a state/province
- `BeerStyle` - A beer style/category (schema table exists; no repository yet)
- `BeerPost` - A beer listing tied to a brewery and style (schema table
exists; no repository yet)
**Dependencies**:
@@ -218,16 +229,15 @@ whichever slices need them
#### Vertical slice + MediatR
**Purpose**: Organize code by feature instead of by technical layer, so
everything needed to understand or change one capability lives in one
project
everything needed to understand or change one capability lives in one project
**Implementation**:
- Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
`Query` (for example, `GetBreweryByIdQuery`)
- Controllers bind directly to the Command/Query as the request body;
there is no separate request DTO + mapping step for writes
- Controllers bind directly to the Command/Query as the request body; there is
no separate request DTO + mapping step for writes
- A single shared `ValidationBehavior<TRequest,TResponse>`
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
MediatR pipeline before any handler executes
@@ -250,19 +260,18 @@ public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandl
**Purpose**: Abstract database access behind interfaces
**Implementation**: each slice owns its own repository, scoped to that
feature only:
**Implementation**: each slice owns its own repository, scoped to that feature
only:
- `Features.Auth/Repository/IAuthRepository.cs`
- `Features.Breweries/Repository/IBreweryRepository.cs`
- `Features.UserManagement/Repository/IUserAccountRepository.cs`
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic
connection factory every slice's repository builds on
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic connection
factory every slice's repository builds on
**Benefits**:
- Testable (easy to mock)
- SQL-first approach (stored procedures)
- Each slice's data access logic is self-contained
**Example**:
@@ -279,9 +288,9 @@ public interface IAuthRepository
**Purpose**: Loose coupling and testability
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across
every `Features.*` assembly; each slice exposes its own `AddFeaturesX()`
extension method that registers its repository and slice-internal services
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across every
`Features.*` assembly; each slice exposes its own `AddFeaturesX()` extension
method that registers its repository and slice-internal services
**Lifetimes**:
@@ -289,22 +298,32 @@ extension 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
through Dapper, with no manual `IDbCommand` parameter binding. The one
wrinkle is the `GEOGRAPHY` column on `BreweryPostLocation`, which Dapper
can't deserialize as a UDT: reads select `CONVERT(varbinary(max),
Coordinates)` so the value comes back as a plain `byte[]` Dapper can bind
- 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 `RowVersion` (`ROWVERSION`) column,
checked in the `UPDATE ... WHERE ... AND RowVersion = @RowVersion` 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
@@ -351,8 +370,8 @@ All component styling should prefer semantic tokens such as `primary`,
### Legacy frontend
The previous Next.js frontend has been archived at `archive/next-js-web-app/`
for reference only. Active product and engineering documentation should point
to `web/frontend`.
for reference only. Active product and engineering documentation should point to
`web/frontend`.
## Security architecture
@@ -361,18 +380,29 @@ to `web/frontend`.
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
@@ -411,19 +441,22 @@ to `web/frontend`.
## Database architecture
For the table-by-table schema and the custom SQL error code scheme, see
[Database](website/database.md).
### SQL-first philosophy
**Principles**:
1. Database is source of truth
2. Complex queries in stored procedures
2. Queries live in the owning repository, not scattered across the app
3. Database handles referential integrity
4. Application orchestrates, database executes
**Benefits**:
- Performance optimization via execution plans
- Centralized query logic
- Query logic scoped to the repository that owns it, not spread across layers
- Version-controlled schema (migrations)
- Easier query profiling and tuning
@@ -433,32 +466,41 @@ to `web/frontend`.
**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)
- Countries, state/provinces, and cities (via `Features.Locations`'
`ILocationRepository`, which creates `Country`/`StateProvince` rows on
demand while resolving a city)
- User accounts (via `Features.Auth`'s repository)
- Brewery posts with locations (via `Features.Breweries`' repository)
Tables that exist in the schema but have no repository yet (`Photo`,
`UserAvatar`, `UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`,
`BeerPostComment`, `BreweryPostPhoto`) are not seeded.
## Deployment architecture
@@ -470,6 +512,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**:
@@ -505,8 +548,8 @@ healthcheck:
├──────────────┤
│ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests,
│ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests
│ handlers + │ (commands/queries/handlers + that slice's own
│ repository) │ repository, mocked with Moq/DbMocker)
│ handler │ (commands/queries/handlers, with repository/service
│ tests) │ dependencies mocked via Moq)
└──────────────┘
```