Files
the-biergarten-app/docs/web/database.md

10 KiB

title, last-updated, tags
title last-updated tags
SQL Server database schema, migrations, and error handling 2026-08-31
sql
repository
database
sql-server
schema

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.

Schema

The schema is split into six SQL Server schemas, one per feature area, each living in its own numbered folder under web/backend/Database/Database.Migrations/scripts/. The folder numbers (01-06) fix the load order so foreign-key dependencies resolve — DbUp runs embedded scripts in alphabetical-by-resource-name order, so e.g. 04-Brewery (which references Auth, Geolocation, and Media) runs after all three. Each folder's 00-Schema.sql creates its SQL schema before its tables run:

scripts
  ├── 01-Auth          (UserAccount, UserVerification, UserCredential)
  │     ├── 00-Schema.sql
  │     ├── 01-UserAccount.sql
  │     ├── 02-UserVerification.sql
  │     └── 03-UserCredential.sql
  ├── 02-Geolocation   (Country, StateProvince, City)
  │     ├── 00-Schema.sql
  │     ├── 01-Country.sql
  │     ├── 02-StateProvince.sql
  │     └── 03-City.sql
  ├── 03-Media         (Photo)
  │     ├── 00-Schema.sql
  │     └── 01-Photo.sql
  ├── 04-Brewery       (BreweryPost, BreweryPostLocation, BreweryPostPhoto)
  │     ├── 00-Schema.sql
  │     ├── 01-BreweryPost.sql
  │     ├── 02-BreweryPostLocation.sql
  │     └── 03-BreweryPostPhoto.sql
  ├── 05-Beer          (BeerStyle, BeerPost, BeerPostPhoto, BeerPostComment)
  │     ├── 00-Schema.sql
  │     ├── 01-BeerStyle.sql
  │     ├── 02-BeerPost.sql
  │     ├── 03-BeerPostPhoto.sql
  │     └── 04-BeerPostComment.sql
  └── 06-Social        (UserFollow, UserProfile, UserAvatar)
        ├── 00-Schema.sql
        ├── 01-UserFollow.sql
        ├── 02-UserProfile.sql
        └── 03-UserAvatar.sql

Each script is run in order by Database.Migrations (DbUp) on startup. Tables are referenced schema-qualified in code (e.g. Auth.UserAccount), not under the default dbo schema. Each table is queried directly by the owning feature slice's repository (see Direct SQL via Dapper/ADO.NET).

A generated DBML copy of the schema (for import into a diagramming tool such as dbdiagram.io) lives at web/backend/Database/Database.Migrations/schema.dbml.

All tables use a UNIQUEIDENTIFIER primary key defaulting to NEWID(), and most carry a RowVersion ROWVERSION column used for optimistic concurrency on updates (UPDATE ... WHERE ... AND RowVersion = @RowVersion).

Tables backed by the API today

Table Backed by Notes
UserAccount Domain.Entities.UserAccount, Features.Users Username and Email are unique
UserCredential Domain.Entities.UserCredential, Features.Users Argon2id password hash; IsRevoked/RevokedAt track credential rotation, not JWTs
UserVerification Domain.Entities.UserVerification, Features.Users Written when a user confirms their email
UserProfile Domain.Entities.UserProfile, Features.Users 1:1 with UserAccount; holds the user's biography; written via IUserProfileRepository, read via IUserListRepository's left joins in both GetByIdAsync and GetAllAsync
UserAvatar Domain.Entities.UserAvatar, Features.Users Links a UserProfile to a Photo as its avatar; written via IUserProfileRepository.SaveAvatarAsync, read via IUserListRepository's ValidTo IS NULL join for the active avatar
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 via a plain Dapper query that selects CONVERT(varbinary(max), Coordinates), since Dapper can't deserialize SQL Server UDTs directly
City Domain.Entities.City, Features.Locations Referenced by BreweryPostLocation.CityID; CityName and StateProvinceID together are unique; written via ILocationRepository, read via ILocationRepository and IBreweryRepository's location joins, currently written only by Database.Seed
StateProvince Domain.Entities.StateProvince, Features.Locations Referenced by City.StateProvinceID; created on demand by ILocationRepository.GetOrCreateCityIdAsync, read via IBreweryRepository's location joins
Country Domain.Entities.Country, Features.Locations Referenced by StateProvince.CountryID; created on demand by ILocationRepository.GetOrCreateCityIdAsync, read via IBreweryRepository's location joins
Photo Domain.Entities.Photo, Features.PhotoUpload Written via IPhotoUploadRepository.CreateAsync, sent as an UploadPhotoCommand from other features' handlers; Hyperlink holds the object key within the bucket, which readers sign into a time-limited URL via IFileStorageProvider.GetPresignedUrl; the active avatar's row is also read via IUserListRepository's Media.Photo join; not yet seeded 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 repository or seed data yet:

Table Purpose (from schema)
UserFollow One user following another (CannotFollowOwnAccount check constraint)
BeerStyle A beer style/category; has a Domain.Entities.BeerStyle type, but no repository yet
BeerPost A beer listing, linked to a BreweryPost and BeerStyle; ABV/IBU are constrained (0-67, 0-120); has a Domain.Entities.BeerPost type, but no repository yet
BeerPostPhoto Links a BeerPost to a Photo
BeerPostComment A comment + 1-5 Rating on a BeerPost
BreweryPostPhoto Links a BreweryPost to a Photo

Managing Migrations (DbUp)

Migrations are handled by the Database.Migrations container using DbUp. Scripts are placed in the scripts/ directory, applied in order, and tracked so that each one runs only once.

Note: The schema is currently considered volatile. At this stage of development, fresh database instances are provisioned for both development and testing, so no production data exists yet.

How to Make Schema Changes

  • Now (no production data): Update the schema by editing the original DDL statements directly.
  • Later (with production data): Add a new, numbered script for any schema changes. Never edit an existing script in place — DbUp tracks completion by script name, so modifying an already-applied script means the changes will be ignored on existing databases.

Data seeding

Database.Seed populates a target database from data produced by the C++ pipeline (see Pipeline README): countries, state/provinces, and cities (created on demand while resolving each brewery's location), user accounts, and brewery posts with locations. It doesn't call every feature slice that has a write path: Photo, for example, has a repository (Features.PhotoUpload) but no seed data.

Database error handling

  • Optimistic-concurrency conflicts are detected by an UPDATE affecting zero rows against the RowVersion 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 (for example, a genuine connectivity or constraint-violation failure) maps to 503.

    • One exception: Features.Users' DapperUserStore 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 DapperUserStore.IsDuplicateKeyViolation.