mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
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:
@@ -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)
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user