remove puml

This commit is contained in:
Aaron Po
2026-08-31 20:14:01 -04:00
parent f4471c7fd7
commit 12af9cfee2
20 changed files with 92 additions and 3570 deletions

View File

@@ -1,56 +0,0 @@
' skinparam shadowing false
' skinparam backgroundColor #FCFCF7
' skinparam defaultFontName "DM Sans"
' skinparam defaultFontColor #14180C
' skinparam titleFontName "Volkhov"
' skinparam titleFontColor #14180C
' skinparam ArrowColor #656F33
' skinparam NoteBackgroundColor #DBEEDD
' skinparam NoteFontColor #14180C
' skinparam NoteBorderColor #4A5837
' skinparam SwimlaneBorderColor #4A5837
' skinparam SwimlaneBorderThickness 1
' skinparam activityStartColor #EBECE3
' skinparam activityEndColor #4A5837
' skinparam activityStopColor #4A5837
' skinparam ActivityBackgroundColor #EBECE3
' skinparam ActivityBorderColor #4A5837
' skinparam ActivityDiamondBackgroundColor #CBD2B5
' skinparam ActivityDiamondBorderColor #4A5837
' skinparam packageStyle rectangle
' skinparam packageBackgroundColor #F1F3EA
' skinparam packageBorderColor #4A5837
' skinparam packageFontColor #14180C
' skinparam classBackgroundColor #EBECE3
' skinparam classBorderColor #4A5837
' skinparam classFontColor #14180C
' skinparam classAttributeFontColor #3F4724
' skinparam classStereotypeFontColor #4A5837
' skinparam interfaceBackgroundColor #DBEEDD
' skinparam interfaceBorderColor #4A5837
' skinparam interfaceFontColor #14180C
' skinparam enumBackgroundColor #E4E6D8
' skinparam enumBorderColor #4A5837
' skinparam enumFontColor #14180C
' skinparam ParticipantBackgroundColor #EBECE3
' skinparam ParticipantBorderColor #4A5837
' skinparam ParticipantFontColor #14180C
' skinparam ActorBackgroundColor #DBEEDD
' skinparam ActorBorderColor #4A5837
' skinparam ActorFontColor #14180C
' skinparam LifeLineBorderColor #4A5837
' skinparam LifeLineBackgroundColor #F1F3EA
' skinparam DatabaseBackgroundColor #EBECE3
' skinparam DatabaseBorderColor #4A5837
' skinparam DatabaseFontColor #14180C
' skinparam SequenceGroupBackgroundColor #DBEEDD
' skinparam SequenceGroupBorderColor #4A5837
' skinparam SequenceGroupFontColor #14180C
' skinparam SequenceGroupBodyBackgroundColor transparent
' skinparam SequenceDividerBackgroundColor #DBEEDD
' skinparam SequenceDividerBorderColor #4A5837
' skinparam SequenceDividerFontColor #14180C
' skinparam SequenceBoxBackgroundColor #F1F3EA
' skinparam SequenceBoxBorderColor #4A5837
' skinparam SequenceBoxFontColor #14180C

View File

@@ -1,280 +0,0 @@
@startuml
!include ../biergarten-weizen-theme.puml
skinparam style strictuml
skinparam defaultFontSize 14
skinparam titleFontSize 20
skinparam ActivityBarColor #628A5B
title The Biergarten Data Pipeline (Current — Synchronous Data Path)
|#F2F6F0|main.cc|
start
:Create BoundedChannel<LogEntry> log_channel;
:Spawn log dispatcher thread\n(LogDispatcher::Run());
note right
The only concurrency in the current pipeline:
a dedicated thread drains log_channel and
forwards entries to spdlog for the entire run.
Joined during shutdown after log_channel closes.
end note
:ParseArguments(argc, argv);
if (Are arguments valid?) then (no)
:Log error / usage info;
stop
else (yes)
endif
:Init OpenSSL global state & LlamaBackendState;
:di::make_injector(...);
note right
Binds ILogger, IEnrichmentService, DataGenerator,
IExportService, IPromptFormatter, WebClient based
on --mocked vs. model-backed mode.
end note
:injector.create<std::unique_ptr<BiergartenPipelineOrchestrator>>();
:BiergartenPipelineOrchestrator::Run();
note right
Run() itself is synchronous — sampling, enrichment,
generation, and export all happen on this thread.
end note
|#EAF0E8|BiergartenPipelineOrchestrator|
:Initialize SQLite export;
|#E0EAE0|SqliteExportService|
:GetUtcTimestamp() from SystemDateTimeProvider;
:Initialize();
note right
Builds a fresh biergarten_seed_<UTC datetime>.sqlite filename
Appends a numeric suffix if the timestamp already exists
Opens DB Connection
Executes Schema DDL
Begins Transaction
end note
|#EAF0E8|BiergartenPipelineOrchestrator|
:QueryLocations();
|#E2EBDC|JsonLoader|
:JsonLoader::LoadLocations("locations.json");
:std::ranges::sample(all_locations, --location-count);
note right
--location-count defaults to 10.
end note
|#EAF0E8|BiergartenPipelineOrchestrator|
while (For each sampled Location?) is (Remaining cities)
|#DCE8D8|IEnrichmentService|
:GetLocationContext(loc);
note right
WikipediaEnrichmentService fetches a generic
"brewing" extract and a "beer in {country}" extract
(not a city/region-specific page). MockEnrichmentService
(--mocked) returns an empty string instead.
end note
|#EAF0E8|BiergartenPipelineOrchestrator|
if (Lookup failed?) then (yes)
:Log warning, skip city;
else (no)
:Store EnrichedCity{Location, region_context};
endif
endwhile (Done)
|#EAF0E8|BiergartenPipelineOrchestrator|
:GenerateUsers(enriched_cities);
|#E2EBDC|JsonLoader|
:LoadPersonas("personas.json");
:LoadForenamesByCountry("forenames-by-country.json");
:LoadSurnamesByCountry("surnames-by-country.json");
note right
Each call is cached after its first parse.
MockCuratedDataService (--mocked) returns a
fixed in-memory dataset instead and skips
these three JSON files entirely.
end note
|#EAF0E8|BiergartenPipelineOrchestrator|
while (For each EnrichedCity?) is (Remaining cities)
:SampleName(forenames_by_country,\n surnames_by_country, location.iso3166_1);
if (Names available for country?) then (no)
:Log warning, skip city;
else (yes)
:Sample UserPersona (uniform random);
|#E5EDE1|DataGenerator|
if (Generator Mode) then (MockGenerator)
:DeterministicHash & Format;
elseif (Generator Mode) then (LlamaGenerator)
:prompt_directory_->Load("USER_GENERATION");
note right
Resolves to USER_GENERATION.md inside --prompt-dir.
end note
repeat
:Infer(system_prompt, user_prompt, max_tokens, kUserJsonGrammar);
:ValidateUserJson(raw, user);
if (Is JSON Valid?) then (yes)
break
else (no)
:Attempt++;
:Append validation error to retry prompt;
endif
repeat while (Attempt < 3?) is (yes)
else (OpenAIGenerator)
:prompt_directory_->Load("USER_GENERATION");
note right
Same prompt file as LlamaGenerator - content
is backend-agnostic.
end note
repeat
:CallChatCompletionsApi(system_prompt, user_prompt,\nkUserJsonSchema, max_tokens);
:ValidateUserJson(raw, user);
if (Is JSON Valid?) then (yes)
break
else (no)
:Attempt++;
:Append validation error to retry prompt;
endif
repeat while (Attempt < 2?) is (yes)
note right
Structured Outputs (response_format:
json_schema, strict: true) already
guarantees schema shape, so only 2
attempts are needed - retries here cover
transient failures / placeholder text,
not malformed JSON.
end note
endif
|#EAF0E8|BiergartenPipelineOrchestrator|
if (Generation successful?) then (yes)
:GetRandomCoordsWithinRange(city_centre, 5km, rng);
:BuildEmail(name, used_email_local_parts);
:GenerateDateOfBirth(rng);
note right
email and date_of_birth are generated
programmatically, never LLM-authored.
Generation + coordinate jitter run inside
one try/catch per record. UserRecord.address
now carries {city, longitude, latitude}.
end note
|#E0EAE0|SqliteExportService|
:ProcessRecord(UserRecord);
if (Location in cache?) then (yes)
:Reuse location_id;
else (no)
:Insert Location & Cache ID;
endif
:Insert User (FK: location_id);
if (Exception caught during insert?) then (yes)
|#EAF0E8|BiergartenPipelineOrchestrator|
:Log warning "SQLite export failed";
else (no)
endif
else (no)
:Log warning "Generation failed, skipping...";
endif
endif
endwhile (Done)
|#EAF0E8|BiergartenPipelineOrchestrator|
:GenerateBreweries(enriched_cities);
|#E5EDE1|DataGenerator|
while (For each EnrichedCity?) is (Remaining cities)
if (Generator Mode) then (MockGenerator)
:DeterministicHash & Format;
elseif (Generator Mode) then (LlamaGenerator)
:PrepareRegionContext;
:prompt_directory_->Load("BREWERY_GENERATION");
note right
Resolves to BREWERY_GENERATION.md inside --prompt-dir.
end note
repeat
:Infer(system_prompt, user_prompt, max_tokens, kBreweryJsonGrammar);
:ValidateBreweryJson(raw, brewery);
if (Is JSON Valid?) then (yes)
break
else (no)
:Attempt++;
:Append validation error to retry prompt;
endif
repeat while (Attempt < 3?) is (yes)
note right
max_tokens is fixed across retries —
it is not raised on truncation.
end note
else (OpenAIGenerator)
:PrepareRegionContext;
:prompt_directory_->Load("BREWERY_GENERATION");
note right
Same prompt file as LlamaGenerator - content
is backend-agnostic.
end note
repeat
:CallChatCompletionsApi(system_prompt, user_prompt,\nkBreweryJsonSchema, max_tokens);
:ValidateBreweryJson(raw, brewery);
if (Is JSON Valid?) then (yes)
break
else (no)
:Attempt++;
:Append validation error to retry prompt;
endif
repeat while (Attempt < 2?) is (yes)
note right
Structured Outputs already guarantees schema
shape, so only 2 attempts are needed - retries
here cover transient failures / placeholder
text, not malformed JSON.
end note
endif
|#EAF0E8|BiergartenPipelineOrchestrator|
if (Generation successful?) then (yes)
:GetRandomCoordsWithinRange(city_centre, 5km, rng);
note right
Generation + coordinate jitter run inside one
try/catch per record. BreweryRecord.address
now carries {city, longitude, latitude}.
end note
|#E0EAE0|SqliteExportService|
:ProcessRecord(GeneratedBrewery);
if (Location in cache?) then (yes)
:Reuse location_id;
else (no)
:Insert Location & Cache ID;
endif
:Insert Brewery (FK: location_id);
if (Exception caught during insert?) then (yes)
|#EAF0E8|BiergartenPipelineOrchestrator|
:Log warning "SQLite export failed";
note right
Data loss is prevented per-record.
The pipeline continues running.
end note
else (no)
endif
else (no)
:Log warning "Generation failed, skipping...";
endif
|#E5EDE1|DataGenerator|
endwhile (Done)
|#E0EAE0|SqliteExportService|
:Finalize();
note right
Commits Transaction
Closes Database Connection
end note
|#F2F6F0|main.cc|
:Close log_channel;
:Join log dispatcher thread;
:Return 0;
stop
@enduml

View File

@@ -1,282 +0,0 @@
@startuml
!include ../biergarten-weizen-theme.puml
skinparam style strictuml
skinparam defaultFontSize 14
skinparam titleFontSize 20
title The Biergarten Data Pipeline - Class Diagram
class BiergartenPipelineOrchestrator {
- logger_ : std::shared_ptr<ILogger>
- context_service_ : std::unique_ptr<IEnrichmentService>
- generator_ : std::unique_ptr<DataGenerator>
- exporter_ : std::unique_ptr<IExportService>
- curated_data_service_ : std::unique_ptr<ICuratedDataService>
- application_options_ : ApplicationOptions
- generated_breweries_ : std::vector<BreweryRecord>
- generated_users_ : std::vector<UserRecord>
+ Run() : bool
- QueryLocations() : std::vector<City>
- GenerateBreweries(cities : std::span<const EnrichedCity>) : void
- GenerateUsers(cities : std::span<const EnrichedCity>) : void
- LogResults() const : void
}
note right of BiergartenPipelineOrchestrator
GenerateBreweries and GenerateUsers both call the
free function GetRandomCoordsWithinRange()
(biergarten_pipeline_orchestrator/coords.h) to jitter
a coordinate pair uniformly within 5km of the city
centre for each generated record's address.
end note
class LogLevel <<enumeration>> {
Debug
Info
Warn
Error
}
class PipelinePhase <<enumeration>> {
Startup
Enrichment
UserGeneration
BreweryAndBeerGeneration
CheckinGeneration
RatingGeneration
FollowGeneration
Teardown
}
struct LogDTO {
+ level : LogLevel
+ phase : PipelinePhase
+ message : std::string
}
struct LogEntry {
+ timestamp : std::chrono::system_clock::time_point
+ origin : std::source_location
+ thread_id : std::thread::id
+ level : LogLevel
+ phase : PipelinePhase
+ message : std::string
}
interface ILogger <<interface>> {
+ Log(payload : LogDTO) : void
- {abstract} DoLog(entry : LogEntry) : void
}
class LogProducer {
- channel_ : BoundedChannel<LogEntry>&
- DoLog(entry : LogEntry) : void
}
class LogDispatcher {
- channel_ : BoundedChannel<LogEntry>&
+ Run() : void
- ToSpdlogLevel(level) : spdlog::level::level_enum
}
interface IEnrichmentService <<interface>> {
+ GetLocationContext(loc : const Location&) : std::string
}
class MockEnrichmentService {
+ GetLocationContext(loc : const Location&) : std::string
}
class WikipediaEnrichmentService {
- client_ : std::unique_ptr<WebClient>
- extract_cache_ : std::unordered_map<std::string, std::string>
+ GetLocationContext(loc : const Location&) : std::string
- FetchExtract(query : std::string_view) : std::string
}
interface WebClient <<interface>> {
+ Get(url : const std::string&) : std::string
+ UrlEncode(value : const std::string&) : std::string
}
class HttpWebClient {
+ Get(url : const std::string&) : std::string
+ UrlEncode(value : const std::string&) : std::string
}
interface DataGenerator <<interface>> {
+ GenerateBrewery(city : const EnrichedCity&) : BreweryResult
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
}
class MockGenerator {
+ GenerateBrewery(city : const EnrichedCity&) : BreweryResult
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
- DeterministicHash(location : const Location&) : size_t
- DeterministicHash(location : const Location&, persona : const UserPersona&, name : const Name&) : size_t
}
class LlamaGenerator {
- model_ : ModelHandle
- context_ : ContextHandle
- prompt_formatter_ : std::unique_ptr<IPromptFormatter>
- prompt_directory_ : std::unique_ptr<IPromptDirectory>
- rng_ : std::mt19937
+ GenerateBrewery(...) : BreweryResult
+ GenerateUser(...) : UserResult
- Load(model_path : const std::string&) : void
- Infer(...) : std::string
- InferFormatted(...) : std::string
}
class OpenAIGenerator {
- api_key_ : std::string
- model_ : std::string
- logger_ : std::shared_ptr<ILogger>
- prompt_directory_ : std::unique_ptr<IPromptDirectory>
- web_client_ : std::unique_ptr<WebClient>
+ GenerateBrewery(city : const EnrichedCity&) : BreweryResult
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
- CallChatCompletionsApi(...) : std::string
}
note right of OpenAIGenerator
Chat Completions with Structured Outputs
(response_format: json_schema, strict: true)
guarantees schema-valid JSON, replacing
llama.cpp's GBNF grammar constraint -
no IPromptFormatter needed.
end note
interface IPromptFormatter <<interface>> {
+ Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string
}
class Gemma4JinjaPromptFormatter {
+ Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string
}
interface IPromptDirectory <<interface>> {
+ Load(key : std::string_view) : std::string
}
class PromptDirectory {
- prompt_dir_ : std::filesystem::path
- cache_ : std::unordered_map<std::string, std::string>
+ Load(key : std::string_view) : std::string
}
interface ICuratedDataService <<interface>> {
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
+ LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
+ LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
}
class JsonLoader {
- cache_ : cache
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
+ LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
+ LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
}
note right of JsonLoader
Each Load* method memoizes its result in
cache_ on first call; later calls on the
same instance return the cached value
without re-parsing.
end note
class MockCuratedDataService {
- locations_ : std::vector<Location>
- personas_ : std::vector<UserPersona>
- forenames_by_country_ : std::unordered_map<std::string, forename_list>
- surnames_by_country_ : std::unordered_map<std::string, surname_list>
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
+ LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
+ LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
}
note right of MockCuratedDataService
Fixed 4-location / 3-persona dataset
(US, DE, FR, BE) used in --mocked runs;
filepath arguments are ignored.
end note
interface IExportService <<interface>> {
+ Initialize() : void
+ ProcessRecord(brewery : const BreweryRecord&) : uint64_t
+ ProcessRecord(user : const UserRecord&) : uint64_t
+ Finalize() : void
}
class SqliteExportService {
- date_time_provider_ : std::unique_ptr<IDateTimeProvider>
- run_timestamp_utc_ : std::string
- database_path_ : std::filesystem::path
- db_handle_ : SqliteDatabaseHandle
- insert_location_stmt_ : SqliteStatementHandle
- insert_brewery_stmt_ : SqliteStatementHandle
- insert_user_stmt_ : SqliteStatementHandle
- transaction_open_ : bool
- location_cache_ : std::unordered_map<std::string, sqlite3_int64>
+ Initialize() : void
+ ProcessRecord(brewery : const BreweryRecord&) : uint64_t
+ ProcessRecord(user : const UserRecord&) : uint64_t
+ Finalize() : void
- InitializeSchema() : void
- ResolveLocationId(location : const Location&) : sqlite3_int64
}
interface IDateTimeProvider <<interface>> {
+ GetUtcTimestamp() : std::string
}
class SystemDateTimeProvider {
+ GetUtcTimestamp() : std::string
}
' Structural Relationships / Dependency Injection
BiergartenPipelineOrchestrator *-- ILogger : owns
BiergartenPipelineOrchestrator *-- IEnrichmentService : owns
BiergartenPipelineOrchestrator *-- DataGenerator : owns
BiergartenPipelineOrchestrator *-- IExportService : owns
BiergartenPipelineOrchestrator *-- ICuratedDataService : owns
LogEntry *-- LogLevel
LogEntry *-- PipelinePhase
LogDTO *-- LogLevel
LogDTO *-- PipelinePhase
ILogger <|.. LogProducer : implements
LogProducer ..> LogEntry : emits
LogDispatcher ..> LogEntry : consumes
IEnrichmentService <|.. WikipediaEnrichmentService : implements
IEnrichmentService <|.. MockEnrichmentService : implements
WikipediaEnrichmentService *-- WebClient : owns
WebClient <|.. HttpWebClient : implements
DataGenerator <|.. MockGenerator : implements
DataGenerator <|.. LlamaGenerator : implements
DataGenerator <|.. OpenAIGenerator : implements
LlamaGenerator *-- IPromptFormatter : uses
LlamaGenerator *-- IPromptDirectory : uses
OpenAIGenerator *-- IPromptDirectory : uses
OpenAIGenerator *-- WebClient : uses
IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements
IPromptDirectory <|.. PromptDirectory : implements
ICuratedDataService <|.. JsonLoader : implements
ICuratedDataService <|.. MockCuratedDataService : implements
IExportService <|.. SqliteExportService : implements
SqliteExportService *-- IDateTimeProvider : owns
IDateTimeProvider <|.. SystemDateTimeProvider : implements
@enduml

View File

@@ -1,372 +0,0 @@
@startuml biergarten_activity
!include ../biergarten-weizen-theme.puml
skinparam defaultFontSize 13
skinparam titleFontSize 20
title The Biergarten Data Pipeline — Activity Diagram
|Main|
start
:ParseArguments(argc, argv);
if (Invalid args?) then (yes)
:spdlog::error;
stop
else (no)
endif
:Init OpenSSL global state & LlamaBackendState;
:Build DI injector;
:Initialize SqliteExportService;
note right
Opens SQLite connection.
(Transactions are now managed
per-phase via batching).
end note
:Create BoundedChannel<LogEntry> log_ch;
:Spawn Log Worker thread;
note right
Log worker drains log_ch for the
entire pipeline lifetime.
All workers emit LogEntry structs
via PipelineLogger -- never spdlog directly.
end note
:BiergartenPipelineOrchestrator::Run();
|BiergartenPipelineOrchestrator::Run()|
fork
:JsonLoader::LoadBeerStyles("beer-styles.json");
:EnrichmentService::PreWarmBeerStyleCache(beer_styles);
fork again
:JsonLoader::LoadLocations("locations.json");
:EnrichmentService::PreWarmLocationCache(sampled_locations);
end fork
fork
:JsonLoader::LoadForenamesByCountry(\n "forenames-by-country.json");
fork again
:JsonLoader::LoadSurnamesByCountry(\n "surnames-by-country.json");
fork again
:JsonLoader::LoadPersonas("personas.json");
end fork
note right
Each call is memoized after its first parse
(implemented today). MockCuratedDataService
returns a fixed in-memory dataset instead
under --mocked, skipping these JSON files.
end note
' ═══════════════════════════════════════════
' PHASE 0 — USER GENERATION
' ═══════════════════════════════════════════
|Orchestrator|
:RunUserPhase(sampled_locations);
:Create BoundedChannels\n(loc_ch, exp_ch);
fork
|Orchestrator|
:Loop: Send Locations -> loc_ch;
:Close loc_ch;
note right
Producer closes loc_ch.
LLM Worker while loop
terminates on empty + closed.
end note
fork again
|LLM Worker|
while (loc_ch has items?) is (yes)
:Receive Location;
:GetLocationContextFromCache(location);
note right
Guaranteed cache hit from startup.
end note
:IPersonaSelectionStrategy::SelectPersona(\n personas_palette_);
note right
Guaranteed cache hit from startup.
Returns a Persona struct carrying
style_affinities, abv_range,
ibu_preference, checkin_weight.
end note
:SampleName(forenames_by_country,\n surnames_by_country, location.iso3166_1, rng);
note right
Deterministic lookup -- no LLM involved.
Free function (implemented today in
generate_users.cc), not a class method.
Name selected from pre-keyed maps
and passed into the generation prompt.
Skips the city if either map has no
entry for iso3166_1.
end note
:GenerateUser(enriched_city, persona, sampled_name)\nvia DataGenerator;
note right
LLM receives: EnrichedCity context + persona
description + sampled name. Generates
bio and preference signals grounded
in locale and persona.
end note
:PipelineLogger::Log(Info, UserGeneration,\n city, user_id, "llm");
:Send GeneratedUser -> exp_ch;
endwhile (no)
:Close exp_ch;
note right
Producer closes exp_ch.
SQLite Worker while loop
terminates on empty + closed.
end note
fork again
|SQLite Worker|
:BEGIN TRANSACTION;
while (exp_ch has items?) is (yes)
:Receive GeneratedUser;
:ProcessUser(user);
:PipelineLogger::Log(Info, UserGeneration,\n city, user_id, "sqlite");
:Append -> user_pool_;
if (Batch size reached?) then (yes)
:COMMIT & BEGIN;
else (no)
endif
endwhile (no)
:COMMIT (Final);
end fork
|Orchestrator|
:Join LLM Worker, SQLite Worker;
' ═══════════════════════════════════════════
' PHASE 1a — BREWERY GENERATION
' ═══════════════════════════════════════════
:RunBreweryPhase(sampled_locations);
:Create BoundedChannels\n(loc_ch, exp_ch);
fork
|Orchestrator|
:Loop: Sample User from user_pool_
and pair with Location;
:Send BreweryTask(Location, User) -> loc_ch;
:Close loc_ch;
fork again
|LLM Worker|
while (loc_ch has items?) is (yes)
:Receive BreweryTask(Location, User);
:GetLocationContextFromCache(task.location);
note right
Guaranteed cache hit from startup.
end note
:GenerateBrewery(enriched_city, context, task.user)\nvia DataGenerator;
note right
KV cache stays warm.
Brewery is linked to the sampled owner_user_id.
end note
:PipelineLogger::Log(Info,\n BreweryGeneration,\n city, brewery_id, "llm");
:Send GeneratedBrewery -> exp_ch;
endwhile (no)
:Close exp_ch;
fork again
|SQLite Worker|
:BEGIN TRANSACTION;
while (exp_ch has items?) is (yes)
:Receive GeneratedBrewery;
:ProcessBrewery(brewery);
:PipelineLogger::Log(Info,\n BreweryGeneration,\n city, brewery_id, "sqlite");
:Append -> brewery_pool_;
if (Batch size reached?) then (yes)
:COMMIT & BEGIN;
else (no)
endif
endwhile (no)
:COMMIT (Final);
end fork
|Orchestrator|
:Join LLM Worker, SQLite Worker;
note right
brewery_pool_ is now fully populated.
Phase 1b may begin.
end note
' ═══════════════════════════════════════════
' PHASE 1b — BEER GENERATION
' ═══════════════════════════════════════════
:RunBeerPhase();
:Create BoundedChannels\n(brew_ch, exp_ch);
fork
|Orchestrator|
:Loop: Send Breweries -> brew_ch;
:Close brew_ch;
fork again
|LLM Worker|
while (brew_ch has items?) is (yes)
:Receive GeneratedBrewery;
:IBeerSelectionStrategy::SelectStyles(\n brewery, beer_style_palette_);
while (For each selected BeerStyle?) is (remaining)
:GetStyleContextFromCache(style);
note right
Guaranteed cache hit from startup.
KV cache stays warm across all
beer generations -- system prompt
does not change within this phase.
end note
:GenerateBeer(brewery, style_context)\nvia DataGenerator;
:Attach GeneratedBeer to bundle;
endwhile (done)
:PipelineLogger::Log(Info,\n BeerGeneration,\n city, brewery_id, "llm");
:Send BeersBundle -> exp_ch;
endwhile (no)
:Close exp_ch;
fork again
|SQLite Worker|
:BEGIN TRANSACTION;
while (exp_ch has items?) is (yes)
:Receive BeersBundle;
while (For each beer in bundle?) is (remaining)
:Set beer.brewery_id from bundle;
:ProcessBeer(beer);
:Append -> beer_pool_;
endwhile (done)
:PipelineLogger::Log(Info,\n BeerGeneration,\n city, brewery_id, "sqlite");
if (Batch size reached?) then (yes)
:COMMIT & BEGIN;
else (no)
endif
endwhile (no)
:COMMIT (Final);
end fork
|Orchestrator|
:Join LLM Worker, SQLite Worker;
note right
Both brewery_pool_ and beer_pool_
are now completely populated.
Checkin and Follow phases may
now run in parallel.
end note
' ═══════════════════════════════════════════
' PHASE 2 — CHECKIN + FOLLOW GENERATION
' (parallel — both depend only on user_pool_
' and brewery_pool_ being fully populated)
' ═══════════════════════════════════════════
fork
|Orchestrator|
:RunCheckinPhase();
:ICheckinDistributionStrategy::\nAssignActivityWeights(user_pool_);
note right
Weights seeded from each user's
persona.checkin_weight. J-curve profile
emerges from persona distribution.
end note
:BEGIN TRANSACTION;
while (For each GeneratedUser in user_pool_?) is (remaining)
:CheckinsForUser(user, brewery_pool_.size());
while (For each checkin index?) is (remaining)
:TimestampFor(user, index);
:Select brewery from brewery_pool_;
:GenerateCheckin(user, brewery, timestamp)\nvia DataGenerator;
:ProcessCheckin(checkin);
:PipelineLogger::Log(Info, CheckinGeneration,\n nullopt, checkin_id, "sqlite");
:Append -> checkin_pool_;
if (Batch size reached?) then (yes)
:COMMIT & BEGIN;
else (no)
endif
endwhile (done)
endwhile (done)
:COMMIT (Final);
fork again
|Orchestrator|
:RunFollowPhase();
:IFollowGenerationStrategy::\nAssignFollowWeights(user_pool_);
note right
For RandomFollowStrategy, weights
are uniform. For ActivityWeightedFollowStrategy,
weights derived from user.activity_weight
so high-activity users attract more followers.
end note
:BEGIN TRANSACTION;
:IFollowGenerationStrategy::\nGenerateFollows(user_pool_);
note right
Self-follow constraint (follower_id != followed_id)
enforced here and at the DB schema level.
end note
while (For each GeneratedFollow?) is (remaining)
:ProcessFollow(follow);
:PipelineLogger::Log(Info, FollowGeneration,\n nullopt, follower_id, "sqlite");
:Append -> follow_pool_;
if (Batch size reached?) then (yes)
:COMMIT & BEGIN;
else (no)
endif
endwhile (done)
:COMMIT (Final);
end fork
|Orchestrator|
:Join CheckinPhase, FollowPhase;
note right
checkin_pool_ and follow_pool_
are now fully populated.
Rating phase may begin.
end note
' ═══════════════════════════════════════════
' PHASE 3 — RATING GENERATION
' ═══════════════════════════════════════════
:RunRatingPhase();
note right
Beer selection biased by
user.persona.style_affinities and abv_range.
Rating skew modulated per persona.
end note
:BEGIN TRANSACTION;
while (For each GeneratedCheckin in checkin_pool_?) is (remaining)
:Match brewery_id, select beer from beer_pool_\n(same brewery_id, biased by persona affinities);
if (Beer exists for brewery?) then (yes)
:GenerateRating(user, beer, checkin_id)\nvia DataGenerator;
:ProcessRating(rating);
:PipelineLogger::Log(Info, RatingGeneration,\n nullopt, rating_id, "sqlite");
if (Batch size reached?) then (yes)
:COMMIT & BEGIN;
else (no)
endif
else (no)
:PipelineLogger::Log(Warn, RatingGeneration,\n nullopt, brewery_id, "sqlite");
:Skip -- brewery has no beers;
endif
endwhile (done)
:COMMIT (Final);
' ═══════════════════════════════════════════
' TEARDOWN
' ═══════════════════════════════════════════
|Orchestrator|
:Finalize SqliteExportService;
note right
Safely closes the DB connection.
end note
:Close log_ch;
|Main|
:spdlog::info "Pipeline complete in X ms";
:Join Log Worker;
note right
Drain guarantees no LogEntry is
dropped at shutdown.
end note
stop
@enduml

View File

@@ -1,646 +0,0 @@
@startuml class_diagram
' ==========================================
' CONFIGURATION & STYLING
' ==========================================
!include ../biergarten-weizen-theme.puml
skinparam classAttributeFontSize 9
skinparam defaultFontSize 25
skinparam titleFontSize 30
title Biergarten Data Pipeline — Class Diagram
package "Domain: Models" {
class Location {
+ city : std::string
+ state_province : std::string
+ iso3166_2 : std::string
+ country : std::string
+ iso3166_1 : std::string
+ local_languages : std::vector<std::string>
+ latitude : double
+ longitude : double
}
class LocationContext {
+ text : std::string
+ completeness : Completeness
+ char_count : size_t
}
enum Completeness {
Full
Partial
Absent
}
class EnrichedCity {
+ location : Location
+ context : LocationContext
}
class BeerStyle {
+ name : std::string
+ description : std::string
+ min_abv : float
+ max_abv : float
+ min_ibu : int
+ max_ibu : int
}
class BreweryResult {
+ name_en : std::string
+ description_en : std::string
+ name_local : std::string
+ description_local : std::string
}
class BeerResult {
+ name_en : std::string
+ description_en : std::string
+ name_local : std::string
+ description_local : std::string
+ style : std::string
+ abv : float
+ ibu : int
}
class UserResult {
+ first_name : std::string
+ last_name : std::string
+ gender : std::string
+ username : std::string
+ bio : std::string
+ activity_weight : float
}
class Name {
+ first_name : std::string
+ last_name : std::string
+ gender : std::string
}
class ForenameEntry {
+ name : std::string
+ gender : std::string
}
note right of ForenameEntry
forename_list = std::unordered_set<ForenameEntry>
surname_list = std::unordered_set<std::string>
(aliases declared in curated_data_service.h).
ICuratedDataService::LoadForenamesByCountry() /
LoadSurnamesByCountry() each return a flat
std::unordered_map<std::string, forename_list>
or <std::string, surname_list> keyed by ISO
3166-1 country code -- no NamesByCountry
wrapper class. SampleName(forenames_by_country,
surnames_by_country, iso3166_1, rng) is a free
function, not a method, so it can be called by
any per-city worker without owning the maps.
Sourced from popular-names-by-country-dataset
(CC0) -- see ETHICS-AND-KNOWN-ISSUES.md. Gender
is preserved per forename so it can be threaded
through to persona/bio generation later; surnames
carry no gender in the source data. Pairing
happens at sample time, not at fixture-authoring
time, so no information from the source dataset
is discarded up front.
end note
ForenameEntry ..> Name : SampleName() produces
class CheckinResult {
+ checked_in_at : std::string
+ note : std::string
}
class RatingResult {
+ score : float
+ note : std::string
}
class GenerationMetadata {
+ generation_id : uint64_t
+ generated_time : std::string
+ context_provided : bool
+ generated_with : std::string
}
class GeneratedBrewery {
+ brewery_id : uint64_t
+ location : Location
+ brewery : BreweryResult
+ context_completeness : LocationContext::Completeness
+ metadata : GenerationMetadata
}
class GeneratedBeer {
+ beer_id : uint64_t
+ brewery_id : uint64_t
+ location : Location
+ style : BeerStyle
+ beer : BeerResult
+ metadata : GenerationMetadata
}
class GeneratedUser {
+ user_id : uint64_t
+ location : Location
+ user : UserResult
+ email : std::string
+ date_of_birth : std::string
+ password : std::string
+ metadata : GenerationMetadata
}
note right of GeneratedUser
email, date_of_birth, and password are
programmatically generated by the orchestrator
(not LLM-authored) so a downstream auth-account
seeding consumer can register real accounts from
this export. See ROADMAP.md §1.
end note
class GeneratedCheckin {
+ checkin_id : uint64_t
+ user_id : uint64_t
+ brewery_id : uint64_t
+ checkin : CheckinResult
+ metadata : GenerationMetadata
}
class GeneratedRating {
+ user_id : uint64_t
+ beer_id : uint64_t
+ checkin_id : uint64_t
+ rating : RatingResult
+ metadata : GenerationMetadata
}
class GeneratedFollow {
+ follower_id : uint64_t
+ followed_id : uint64_t
+ metadata : GenerationMetadata
}
class UserPersona {
+ name: std::string
+ description: std::string
+ style_affinities: std::vector<std::string>
}
LocationContext *-- Completeness
}
package "Domain: Application Configuration" {
class SamplingOptions {
+ temperature: float = 1.0F
+ top_p: float = 0.95F
+ top_k: uint32_t = 64
+ n_ctx: uint32_t = 8192
+ seed: int = -1
}
class GeneratorOptions {
+ model_path: std::filesystem::path
+ use_mocked: bool = false
+ sampling: std::optional<SamplingOptions>
}
class PipelineOptions {
+ output_path: std::filesystem::path
+ log_path: std::filesystem::path
}
class ApplicationOptions {
+ generator: GeneratorOptions
+ pipeline: PipelineOptions
}
ApplicationOptions *-- GeneratorOptions
ApplicationOptions *-- PipelineOptions
GeneratorOptions o-- SamplingOptions
}
package "Domain: Policy" {
interface ContextStrategy <<interface>> {
+ QueriesFor(loc : const Location&) : std::vector<std::string>
+ MaxContextChars() : size_t
}
class BreweryContextStrategy {
+ QueriesFor(loc : const Location&) : std::vector<std::string>
+ MaxContextChars() : size_t
}
class BeerContextStrategy {
+ QueriesFor(loc : const Location&) : std::vector<std::string>
+ MaxContextChars() : size_t
}
interface SamplingStrategy <<interface>> {
+ Sample(locations : const std::vector<Location>&) : std::vector<Location>
}
class UniformSamplingStrategy {
- sample_size_ : size_t
+ Sample(locations : const std::vector<Location>&) : std::vector<Location>
}
interface BeerSelectionStrategy <<interface>> {
+ SelectStyles(brewery : const GeneratedBrewery&,\n palette : std::span<const BeerStyle>) : std::vector<BeerStyle>
}
class RandomBeerSelectionStrategy {
- rng_ : std::mt19937
- min_beers_ : size_t
- max_beers_ : size_t
+ SelectStyles(brewery : const GeneratedBrewery&,\n palette : std::span<const BeerStyle>) : std::vector<BeerStyle>
}
interface CheckinDistributionStrategy <<interface>> {
+ AssignActivityWeights(users : std::vector<GeneratedUser>&) : void
+ CheckinsForUser(user : const GeneratedUser&,\n brewery_count : size_t) : size_t
+ TimestampFor(user : const GeneratedUser&,\n index : size_t) : std::string
}
class JCurveCheckinStrategy {
- rng_ : std::mt19937
+ AssignActivityWeights(users : std::vector<GeneratedUser>&) : void
+ CheckinsForUser(user : const GeneratedUser&,\n brewery_count : size_t) : size_t
+ TimestampFor(user : const GeneratedUser&,\n index : size_t) : std::string
}
class RandomCheckinStrategy {
- rng_ : std::mt19937
- min_checkins_ : size_t
- max_checkins_ : size_t
+ AssignActivityWeights(users : std::vector<GeneratedUser>&) : void
+ CheckinsForUser(user : const GeneratedUser&,\n brewery_count : size_t) : size_t
+ TimestampFor(user : const GeneratedUser&,\n index : size_t) : std::string
}
interface FollowGenerationStrategy <<interface>> {
+ GenerateFollows(users : const std::vector<GeneratedUser>&) : std::vector<GeneratedFollow>
}
class RandomFollowStrategy {
- rng_ : std::mt19937
- min_follows_ : size_t
- max_follows_ : size_t
+ GenerateFollows(users : const std::vector<GeneratedUser>&) : std::vector<GeneratedFollow>
}
class ActivityWeightedFollowStrategy {
- rng_ : std::mt19937
- min_follows_ : size_t
- max_follows_ : size_t
+ GenerateFollows(users : const std::vector<GeneratedUser>&) : std::vector<GeneratedFollow>
}
}
package "Infrastructure: Logging" {
enum LogLevel {
Debug
Info
Warn
Error
}
enum PipelinePhase {
Startup
UserGeneration
BreweryAndBeerGeneration
CheckinGeneration
RatingGeneration
FollowGeneration
Teardown
}
class LogEntry {
+ timestamp : std::chrono::system_clock::time_point
+ level : LogLevel
+ phase : PipelinePhase
+ message : std::string
+ worker : std::optional<std::string>
}
interface ILogger <<interface>> {
+ Log(entry : const LogEntry&) : void
}
class LogProducer {
- channel_ : BoundedChannel<LogEntry>&
+ Log(entry : const LogEntry&) : void
}
class LogDispatcher {
- channel_ : BoundedChannel<LogEntry>&
+ Run() : void
- ToSpdlogLevel(level) : spdlog::level::level_enum
}
LogEntry *-- LogLevel
LogEntry *-- PipelinePhase
ILogger <|.. LogProducer
LogProducer ..> LogEntry : emits
LogDispatcher ..> LogEntry : consumes
}
package "Infrastructure: Pipeline Channel" {
class "BoundedChannel<T>" as BoundedChannel {
- queue_ : std::queue<T>
- mutex_ : std::mutex
- not_full_ : std::condition_variable
- not_empty_ : std::condition_variable
- capacity_ : size_t
- closed_ : bool
+ Send(item : T) : void
+ Receive() : std::optional<T>
+ Close() : void
}
}
package "Infrastructure: Data Preloading" {
interface ICuratedDataService <<interface>> {
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
+ LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector<BeerStyle>&
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
+ LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
+ LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
}
class JsonLoader {
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
+ LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector<BeerStyle>&
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
+ LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
+ LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
}
note right of JsonLoader
Each Load* call is memoized after its first
parse -- implemented today, ahead of the rest
of this package (LoadBeerStyles is still planned).
end note
class MockCuratedDataService {
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
+ LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector<BeerStyle>&
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
+ LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
+ LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
}
note right of MockCuratedDataService
Fixed in-memory dataset used in --mocked mode,
implemented today for Locations/Personas/
Forenames/Surnames; would need a LoadBeerStyles
fixture too once that method is implemented.
end note
}
package "Infrastructure: Enrichment" {
interface EnrichmentService <<interface>> {
+ GetLocationContext(loc : const Location&,\n strategy : const ContextStrategy&) : LocationContext
}
class WikipediaService {
- client_ : std::unique_ptr<WebClient>
- extract_cache_ : std::unordered_map<std::string, std::string>
+ GetLocationContext(loc : const Location&,\n strategy : const ContextStrategy&) : LocationContext
- FetchExtract(query : std::string_view) : std::string
}
interface WebClient <<interface>> {
+ Get(url : const std::string&) : std::string
+ UrlEncode(value : const std::string&) : std::string
}
class HttpWebClient {
+ Get(url : const std::string&) : std::string
+ UrlEncode(value : const std::string&) : std::string
}
}
package "Infrastructure: Prompting" {
interface IPromptDirectory <<interface>> {
+ Load(key : std::string_view) : std::string
}
class PromptDirectory {
- prompt_dir_ : std::filesystem::path
- cache_ : std::unordered_map<std::string, std::string>
+ PromptDirectory(prompt_dir : const std::filesystem::path&)
+ Load(key : std::string_view) : std::string
}
IPromptDirectory <|.. PromptDirectory
}
package "Infrastructure: Data Generation" {
interface DataGenerator <<interface>> {
+ GenerateBrewery(location : const Location&,\n context : const LocationContext&) : BreweryResult
+ GenerateBeer(brewery_id : uint64_t,\n location : const Location&,\n context : const LocationContext&,\n style : const BeerStyle&) : BeerResult
+ GenerateUser(city : const EnrichedCity&,\n persona : const UserPersona&,\n name : const Name&) : UserResult
+ GenerateCheckin(user : const GeneratedUser&,\n brewery : const GeneratedBrewery&,\n timestamp : const std::string&) : CheckinResult
+ GenerateRating(user : const GeneratedUser&,\n beer : const GeneratedBeer&,\n checkin_id : uint64_t) : RatingResult
}
class MockGenerator {
+ GenerateBrewery(...) : BreweryResult
+ GenerateBeer(...) : BeerResult
+ GenerateUser(...) : UserResult
+ GenerateCheckin(...) : CheckinResult
+ GenerateRating(...) : RatingResult
- DeterministicHash(location : const Location&) : size_t
}
class LlamaGenerator {
- model_ : ModelHandle
- context_ : ContextHandle
- prompt_formatter_ : std::unique_ptr<PromptFormatter>
- prompt_directory_ : std::unique_ptr<IPromptDirectory>
- rng_ : std::mt19937
+ GenerateBrewery(...) : BreweryResult
+ GenerateBeer(...) : BeerResult
+ GenerateUser(...) : UserResult
+ GenerateCheckin(...) : CheckinResult
+ GenerateRating(...) : RatingResult
- Load(opts : const GeneratorOptions&) : void
- Infer(system_prompt, user_prompt,\n max_tokens, grammar) : std::string
- ValidateModelArchitecture() : void
}
interface PromptFormatter <<interface>> {
+ Format(system_prompt : std::string_view,\n user_prompt : std::string_view) : std::string
+ ExpectedArchitecture() : std::string_view
}
class Gemma4JinjaPromptFormatter {
+ Format(...) : std::string
+ ExpectedArchitecture() : std::string_view
}
}
package "Infrastructure: Data Export" {
interface ExportService <<interface>> {
+ Initialize() : void
+ ProcessBrewery(brewery : const GeneratedBrewery&) : uint64_t
+ ProcessBeer(beer : const GeneratedBeer&) : uint64_t
+ ProcessUser(user : const GeneratedUser&) : uint64_t
+ ProcessCheckin(checkin : const GeneratedCheckin&) : uint64_t
+ ProcessRating(rating : const GeneratedRating&) : void
+ ProcessFollow(follow : const GeneratedFollow&) : void
+ Finalize() : void
}
class SqliteExportService {
- date_time_provider_ : std::unique_ptr<DateTimeProvider>
- db_handle_ : SqliteDatabaseHandle
- insert_location_stmt_ : SqliteStatementHandle
- insert_brewery_stmt_ : SqliteStatementHandle
- insert_beer_stmt_ : SqliteStatementHandle
- insert_user_stmt_ : SqliteStatementHandle
- insert_checkin_stmt_ : SqliteStatementHandle
- insert_rating_stmt_ : SqliteStatementHandle
- insert_follow_stmt_ : SqliteStatementHandle
- transaction_open_ : bool
- location_cache_ : std::unordered_map<std::string, uint64_t>
- brewery_cache_ : std::unordered_map<std::string, uint64_t>
+ Initialize() : void
+ ProcessRecord(brewery : const GeneratedBrewery&) : uint64_t
+ ProcessRecord(beer : const GeneratedBeer&) : uint64_t
+ ProcessRecord(user : const GeneratedUser&) : uint64_t
+ ProcessRecord(checkin : const GeneratedCheckin&) : uint64_t
+ ProcessRecord(rating : const GeneratedRating&) : void
+ ProcessRecord(follow : const GeneratedFollow&) : void
+ Finalize() : void
- InitializeSchema() : void
- PrepareStatements() : void
- RollbackAndCloseNoThrow() : void
- FinalizeStatements() : void
}
interface DateTimeProvider <<interface>> {
+ GetUtcTimestamp() : std::string
}
class SystemDateTimeProvider {
+ GetUtcTimestamp() : std::string
}
}
class BiergartenPipelineOrchestrator {
- curated_data_service_ : std::unique_ptr<ICuratedDataService>
- enrichment_service_ : std::unique_ptr<EnrichmentService>
- generator_ : std::unique_ptr<DataGenerator>
- logger_ : std::unique_ptr<Logger>
- exporter_ : std::unique_ptr<ExportService>
- brewery_context_strategy_ : std::unique_ptr<ContextStrategy>
- sampling_strategy_ : std::unique_ptr<SamplingStrategy>
- beer_selection_strategy_ : std::unique_ptr<BeerSelectionStrategy>
- checkin_strategy_ : std::unique_ptr<CheckinDistributionStrategy>
- follow_strategy_ : std::unique_ptr<FollowGenerationStrategy>
- beer_style_palette_ : std::vector<BeerStyle>
- options_ : ApplicationOptions
--
- user_pool_ : std::vector<GeneratedUser>
- brewery_pool_ : std::vector<GeneratedBrewery>
- beer_pool_ : std::vector<GeneratedBeer>
- checkin_pool_ : std::vector<GeneratedCheckin>
- follow_pool_ : std::vector<GeneratedFollow>
--
+ Run() : bool
- RunUserPhase(locations : const std::vector<Location>&) : void
- RunBreweryAndBeerPhase(locations : const std::vector<Location>&) : void
- RunCheckinPhase() : void
- RunRatingPhase() : void
- RunFollowPhase() : void
}
' --- Orchestration Aggregations (Services & Strategies) ---
BiergartenPipelineOrchestrator *-- ICuratedDataService
BiergartenPipelineOrchestrator *-- EnrichmentService
BiergartenPipelineOrchestrator *-- DataGenerator
BiergartenPipelineOrchestrator *-- ExportService
BiergartenPipelineOrchestrator *-- CheckinDistributionStrategy
BiergartenPipelineOrchestrator *-- FollowGenerationStrategy
BiergartenPipelineOrchestrator *-- SamplingStrategy
BiergartenPipelineOrchestrator *-- BeerSelectionStrategy
BiergartenPipelineOrchestrator *-- ApplicationOptions
BiergartenPipelineOrchestrator *-- Logger
' --- Orchestration Aggregations (Data Pools) ---
BiergartenPipelineOrchestrator *-- "0..*" GeneratedUser : user_pool_
BiergartenPipelineOrchestrator *-- "0..*" GeneratedBrewery : brewery_pool_
BiergartenPipelineOrchestrator *-- "0..*" GeneratedBeer : beer_pool_
BiergartenPipelineOrchestrator *-- "0..*" GeneratedCheckin : checkin_pool_
BiergartenPipelineOrchestrator *-- "0..*" GeneratedFollow : follow_pool_
' --- Interfaces & Implementations ---
ICuratedDataService <|.. JsonLoader
ICuratedDataService <|.. MockCuratedDataService
Logger <|.. PipelineLogger
ContextStrategy <|.. BreweryContextStrategy
ContextStrategy <|.. BeerContextStrategy
SamplingStrategy <|.. UniformSamplingStrategy
BeerSelectionStrategy <|.. RandomBeerSelectionStrategy
CheckinDistributionStrategy <|.. JCurveCheckinStrategy
CheckinDistributionStrategy <|.. RandomCheckinStrategy
FollowGenerationStrategy <|.. RandomFollowStrategy
FollowGenerationStrategy <|.. ActivityWeightedFollowStrategy
EnrichmentService <|.. WikipediaService
WebClient <|.. HttpWebClient
DataGenerator <|.. MockGenerator
DataGenerator <|.. LlamaGenerator
PromptFormatter <|.. Gemma4JinjaPromptFormatter
ExportService <|.. SqliteExportService
DateTimeProvider <|.. SystemDateTimeProvider
' --- Service Compositions & Dependencies ---
WikipediaService *-- WebClient
WikipediaService ..> ContextStrategy
LlamaGenerator *-- PromptFormatter
LlamaGenerator *-- IPromptDirectory
LlamaGenerator ..> GeneratorOptions
SqliteExportService *-- DateTimeProvider
' --- Cross-Component Aggregations (Held References) ---
PipelineLogger o-- BoundedChannel : logs to
LogWorker o-- BoundedChannel : drains from
' --- Domain Containment ---
EnrichedCity *-- Location
EnrichedCity *-- LocationContext
GeneratedBrewery *-- Location
GeneratedBrewery *-- BreweryResult
GeneratedBrewery *-- GenerationMetadata
GeneratedBeer *-- Location
GeneratedBeer *-- BeerStyle
GeneratedBeer *-- BeerResult
GeneratedBeer *-- GenerationMetadata
GeneratedUser *-- Location
GeneratedUser *-- UserResult
GeneratedUser *-- GenerationMetadata
GeneratedCheckin *-- CheckinResult
GeneratedCheckin *-- GenerationMetadata
GeneratedRating *-- RatingResult
GeneratedRating *-- GenerationMetadata
GeneratedFollow *-- GenerationMetadata
@enduml

View File

@@ -1,84 +1,120 @@
# The Biergarten App — Vertical Slice Architecture
Two diagrams, split by concern: which host wires up which slice at startup, and
which projects each slice actually compiles against. Mixing DI wiring and
compile-time references in one graph is what made the old diagram unreadable.
## Host wiring
Each host (`API.Core`, `Database.Seed`) decides for itself which `Features.*`
assemblies to register — there's no shared bootstrap. `Database.Migrations`
doesn't wire up any slice; it applies schema directly.
```mermaid
---
config:
flowchart:
curve: stepAfter
---
flowchart TB
subgraph HOST["API.Core (thin host)"]
API["API.Core<br/>ASP.NET Core Web API host"]
end
API["API.Core<br/>(ASP.NET Core Web API)"]
Seed["Database.Seed<br/>(console app)"]
Migrations["Database.Migrations<br/>(console app)"]
subgraph SLICES["Feature Slices"]
UsersSlice["Features.Users"]
direction LR
UsersSlice["Features.Users<br/>(controller)"]
BrewerySlice["Features.Breweries<br/>(controller)"]
PhotoSlice["Features.PhotoUpload<br/>(MediatR only)"]
EmailsSlice["Features.Emails<br/>(MediatR only)"]
LocationsSlice["Features.Locations<br/>(plain DI service)"]
BeersSlice["Features.Beers<br/>(scaffolded, no code yet)"]
end
API -->|AddApplicationPart + MediatR scan| UsersSlice
API -->|AddApplicationPart + MediatR scan| BrewerySlice
API -->|MediatR scan only| PhotoSlice
API -->|MediatR scan only| EmailsSlice
Seed -->|AddFeaturesUsers| UsersSlice
Seed -->|AddFeaturesBreweries| BrewerySlice
Seed -->|AddFeaturesPhotoUpload| PhotoSlice
Seed -->|AddFeaturesLocations| LocationsSlice
```
## Project reference dependencies
Solid arrows are compile-time project references. Dashed arrows are the two
places slices talk to each other without a direct reference: the email
MediatR contract routed through `Shared.Application`, and the one place that
pattern is broken outright. Every DB-backed slice depends on the same three
foundation projects (`Shared.Contracts`, `Domain.Entities`,
`Database.Connection`) — drawn as one box instead of fifteen separate arrows;
see the notes for what else each of those foundation projects pulls in.
```mermaid
---
config:
flowchart:
curve: stepAfter
---
flowchart TB
subgraph SLICES["Feature Slices"]
direction LR
BrewerySlice["Features.Breweries"]
EmailsSlice["Features.Emails<br/>(no controller)"]
LocationsSlice["Features.Locations<br/>(no controller, no MediatR)"]
LocationsSlice["Features.Locations"]
BeersSlice["Features.Beers<br/>(scaffolded)"]
PhotoSlice["Features.PhotoUpload"]
UsersSlice["Features.Users"]
EmailsSlice["Features.Emails"]
end
subgraph SHARED["Shared"]
subgraph FOUNDATION["Foundation (every DB-backed slice depends on all three)"]
direction LR
SharedContracts["Shared.Contracts"]
SharedApp["Shared.Application"]
Domain["Domain.Entities"]
DBConn["Database.Connection"]
end
subgraph INFRA["Infrastructure Layer"]
Sql["Infrastructure.Sql"]
SharedApp["Shared.Application<br/>(email command contract)"]
subgraph INFRA["Slice-specific Infrastructure"]
direction LR
JWT["Infrastructure.Jwt"]
PwdHash["Infrastructure.PasswordHashing"]
FileUpload["Infrastructure.FileUpload"]
Email["Infrastructure.Email"]
EmailTemplates["Infrastructure.Email.Templates"]
end
subgraph DOMAIN["Domain Layer"]
Domain["Domain.Entities"]
DomainExceptions["Domain.Exceptions"]
end
Tables[("SQL Server<br/>Tables")]
Seed["Database.Seed<br/>(separate console app)"]
API -.->|AddApplicationPart| UsersSlice
API -.->|AddApplicationPart| BrewerySlice
API -.->|MediatR scan only| EmailsSlice
%% the one feature-to-feature reference, breaking slice isolation
UsersSlice ==>|"project ref, sends UploadPhotoCommand"| PhotoSlice
Seed -.->|AddFeaturesUsers| UsersSlice
Seed -.->|AddFeaturesBreweries| BrewerySlice
Seed -.->|AddFeaturesLocations| LocationsSlice
%% cross-slice MediatR contract, routed through Shared.Application instead
UsersSlice -.->|sends email commands| SharedApp
EmailsSlice -.->|handles email commands| SharedApp
UsersSlice --> Sql
UsersSlice --> JWT
UsersSlice --> PwdHash
UsersSlice --> SharedContracts
UsersSlice --> SharedApp
%% every DB-backed slice depends on the same foundation
BrewerySlice & LocationsSlice & BeersSlice & PhotoSlice & UsersSlice --> FOUNDATION
BrewerySlice --> Sql
BrewerySlice --> SharedContracts
BrewerySlice --> SharedApp
%% slice-specific infra, one edge per slice
UsersSlice --> JWT & PwdHash
PhotoSlice --> FileUpload
EmailsSlice --> Email & EmailTemplates
LocationsSlice --> Sql
EmailsSlice --> Email
EmailsSlice --> EmailTemplates
EmailsSlice --> SharedApp
UsersSlice -.->|sends SendRegistrationEmailCommand| SharedApp
EmailsSlice -.->|handles SendRegistrationEmailCommand| SharedApp
Sql --> Domain
UsersSlice --> Tables
BrewerySlice --> Tables
LocationsSlice --> Tables
UsersSlice --> Domain
BrewerySlice --> Domain
LocationsSlice --> Domain
UsersSlice --> DomainExceptions
DBConn -->|Dapper SQL, no ORM| Tables
```
## Notes
- **API.Core**: Program.cs wiring only — MediatR + `AddApplicationPart` per `Features.*` assembly, Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter.
- **Feature slices**: each owns its own controller (HTTP endpoints), commands/queries + handlers, validators, and Dapper repository. `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`). `Features.Emails` has no controller — invoked only via MediatR commands sent from other slices. `Features.Locations` has no controller and no MediatR handlers — it exposes `ILocationRepository` directly as a plain DI service, not registered by `API.Core` at all; only `Database.Seed` depends on it today.
- **Shared.Application**: `ValidationBehavior` (MediatR pipeline) plus the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) — the one cross-slice interaction is a MediatR command contract, not a project reference.
- **Domain.Entities**: `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar`, `BreweryPost`, `BreweryPostLocation`, `City`, `StateProvince`, `Country`, `BeerPost`, `BeerStyle` (schema tables exist for the last two; no repository yet).
- **Infrastructure.Sql**: SQL-first approach — each slice's own repository issues inline SQL via Dapper, no ORM, no stored procedures.
- **Database.Seed**: calls each slice's `AddFeatures*()` DI extension directly — no API host, no MediatR pipeline.
- No `Features.*` project ever references another `Features.*` project directly.
- **API.Core**: Program.cs wiring only — MediatR + `AddApplicationPart` per HTTP-exposed `Features.*` assembly, Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter. Only `Features.Users` and `Features.Breweries` get `AddApplicationPart` (controllers); `Features.PhotoUpload` and `Features.Emails` are scanned for MediatR handlers only. `Features.Locations` and `Features.Beers` aren't referenced by `API.Core` at all.
- **Feature slices**: each owns its own controller (where present), commands/queries + handlers, validators, and Dapper repository. `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`) — internally namespaced `Features.Auth`, despite the project being named `Features.Users`. `Features.Emails` and `Features.PhotoUpload` have no controller — invoked only via MediatR commands sent from other slices. `Features.Locations` has no controller either; it now has one MediatR handler (`GetOrCreateCityHandler`), but that's only registered by `Database.Seed`'s own MediatR pipeline — `API.Core` never scans it. `Features.Beers` is scaffolded (project + references only, no source files yet).
- **The one exception to slice isolation**: `Features.Users` takes a direct project reference to `Features.PhotoUpload` and sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler` — unlike the email flow, the command type lives in the target slice itself, not in `Shared.Application`.
- **Shared.Application**: `ValidationBehavior` (MediatR pipeline) plus the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) — that interaction is a MediatR command contract, not a project reference, and every slice (including `Features.Emails`) references it for the pipeline behavior alone.
- **Domain.Entities**: `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar`, `BreweryPost`, `BreweryPostLocation`, `City`, `StateProvince`, `Country`, `BeerPost`, `BeerStyle`, `Photo` (schema tables exist for `BeerPost`/`BeerStyle`; no repository yet since `Features.Beers` has no code).
- **Database.Connection**: the shared connection-factory project — each slice's own repository issues inline SQL via Dapper against it, no ORM, no stored procedures. Replaces the old `Infrastructure.Sql` project.
- **Database.Migrations**: separate console app (`schema.dbml` + SQL scripts) that applies DDL directly — distinct from `Database.Seed`, which loads data through the feature slices' own DI extensions.
- **Database.Seed**: calls each slice's `AddFeatures*()` DI extension directly and builds its own MediatR registration — no API host, no shared pipeline with `API.Core`.
- **Omitted from the diagram**: `Domain.Exceptions` (referenced by nearly every project directly, including all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice) and `Infrastructure.Configuration` (the shared options-binding project referenced by `Database.Connection`, `Infrastructure.Email`, `Infrastructure.FileUpload`, and `Features.Emails` directly) — both are plumbing-level dependencies of nearly everything above, and drawing them would just add a second copy of the same fan-in.

View File

@@ -1,151 +0,0 @@
@startuml architecture_v6
skinparam linetype ortho
skinparam nodesep 90
skinparam ranksep 110
title The Biergarten App - Vertical Slice Architecture
package "API.Core (thin host)" #F1F3EA {
[API.Core\nASP.NET Core Web API host] as API
note right of API
- Program.cs wiring only
- MediatR + AddApplicationPart
per Features.* assembly
- Swagger/OpenAPI, health checks
- JWT auth middleware
- Global exception filter
end note
}
package "Feature Slices" #DBEEDD {
[Features.Users] as UsersSlice
[Features.Breweries] as BrewerySlice
[Features.Emails] as EmailsSlice
[Features.Locations] as LocationsSlice
note right of UsersSlice
Each slice owns its own:
- Controller (HTTP endpoints)
- Commands/Queries + Handlers
- Validators
- Repository (Dapper)
Features.Users covers auth
(Commands/Authentication),
account management
(Commands/Account), and
profile/avatar (Commands/Profile)
end note
note right of EmailsSlice
No controller - invoked only
via MediatR commands sent
from other slices
end note
note right of LocationsSlice
No controller and no MediatR
handlers - exposes ILocationRepository
directly as a plain DI service.
Not registered by API.Core at all;
only Database.Seed depends on it today
end note
}
package "Shared" #E4E6D8 {
[Shared.Contracts] as SharedContracts
[Shared.Application] as SharedApp
note right of SharedApp
- ValidationBehavior
(MediatR pipeline)
- Cross-slice email commands
(SendRegistrationEmailCommand,
SendResendConfirmationEmailCommand)
end note
}
package "Infrastructure Layer" #EBECE3 {
[Infrastructure.Sql] as Sql
[Infrastructure.Jwt] as JWT
[Infrastructure.PasswordHashing] as PwdHash
[Infrastructure.Email] as Email
[Infrastructure.Email.Templates] as EmailTemplates
}
package "Domain Layer" #CBD2B5 {
[Domain.Entities] as Domain
[Domain.Exceptions] as DomainExceptions
note right of Domain
- UserAccount, UserCredential, UserVerification
- UserProfile, UserAvatar
- BreweryPost, BreweryPostLocation
- City, StateProvince, Country
- BeerPost, BeerStyle
(schema tables exist; no repository yet)
end note
}
database "SQL Server" {
[Tables] as Tables
}
node "Database.Seed\n(separate console app)" as Seed
note right of Seed
Seed calls each slice's
AddFeatures*() DI extension
directly - no API host,
no MediatR pipeline
end note
' Relationships
API ..> UsersSlice : AddApplicationPart
API ..> BrewerySlice : AddApplicationPart
API ..> EmailsSlice : MediatR scan only
Seed ..> UsersSlice : AddFeaturesUsers()
Seed ..> BrewerySlice : AddFeaturesBreweries()
Seed ..> LocationsSlice : AddFeaturesLocations()
UsersSlice --> Sql
UsersSlice --> JWT
UsersSlice --> PwdHash
UsersSlice --> SharedContracts
UsersSlice --> SharedApp
BrewerySlice --> Sql
BrewerySlice --> SharedContracts
BrewerySlice --> SharedApp
LocationsSlice --> Sql
EmailsSlice --> Email
EmailsSlice --> EmailTemplates
EmailsSlice --> SharedApp
' The one cross-slice interaction: a MediatR command contract, not a project reference
UsersSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
Sql --> Domain
UsersSlice --> Tables : Dapper SQL
BrewerySlice --> Tables
LocationsSlice --> Tables
UsersSlice --> Domain
BrewerySlice --> Domain
LocationsSlice --> Domain
UsersSlice --> DomainExceptions
' Notes
note left of Sql
SQL-first approach
Each slice's own repository issues
inline SQL via Dapper - no ORM,
no stored procedures
end note
note bottom of UsersSlice
No Features.* project
ever references another
Features.* project directly
end note
@enduml

View File

@@ -1,109 +0,0 @@
@startuml confirm-user
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Confirm User
actor User
participant "API\nController" as API
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
box "Handler Layer" #F1F3EA
participant "ConfirmUserHandler" as ConfirmHandler
participant "UserManager<ApplicationUser>" as UserMgr
participant "TokenService" as TokenSvc
end box
box "Infrastructure Layer" #EBECE3
participant "JWT\nInfrastructure" as JWT
end box
box "Repository / Cross-slice" #DBEEDD
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
end box
database "SQL Server" as DB
note over UserMgr
ASP.NET Core Identity's UserManager<TUser>
replaces the deleted AuthRepository: handlers
no longer issue SQL or call Argon2 directly,
they delegate to UserManager, which in turn
calls DapperUserStore (persistence) and
Argon2PasswordHasher (IPasswordHasher<TUser>).
end note
User -> API: POST /api/auth/confirm?token=<token>\n(requires Bearer access token)
activate API
API -> Mediator: Send(ConfirmUserCommand(token))
activate Mediator
Mediator -> ConfirmHandler: Handle(command)
activate ConfirmHandler
ConfirmHandler -> TokenSvc: ValidateConfirmationTokenAsync(token)
activate TokenSvc
TokenSvc -> JWT: Validate against confirmation\ntoken secret, extract userId
activate JWT
JWT --> TokenSvc: ValidatedToken or throws
deactivate JWT
TokenSvc --> ConfirmHandler: ValidatedToken
deactivate TokenSvc
alt Token invalid or expired
ConfirmHandler -> Mediator: throw UnauthorizedException
Mediator -> API: propagate
API -> User: 401 Unauthorized
else Token valid
ConfirmHandler -> UserMgr: FindByIdAsync(validatedToken.UserId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userId)
activate UserStore
UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
activate DB
DB --> UserStore: row or null
deactivate DB
UserStore --> UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr --> ConfirmHandler: ApplicationUser or null
deactivate UserMgr
alt User not found
ConfirmHandler -> Mediator: throw UnauthorizedException\n"User account not found"
Mediator -> API: propagate
API -> User: 401 Unauthorized
else User found
ConfirmHandler -> UserStore: SetEmailConfirmedAsync(user, true)
note right of UserStore
IUserEmailStore<ApplicationUser> is injected
directly into the handler (not via UserManager),
setting the confirmed flag in-memory here.
end note
ConfirmHandler -> UserMgr: UpdateAsync(user)
activate UserMgr
UserMgr -> UserStore: UpdateAsync(user)
activate UserStore
UserStore -> DB: UPDATE dbo.UserAccount SET ...
UserStore -> DB: INSERT INTO dbo.UserVerification\n(UserAccountId, VerificationDateTime)
note right of UserStore
Idempotent: skipped (duplicate-key
exception swallowed) if a verification
row already exists.
end note
UserStore --> UserMgr: IdentityResult.Success
deactivate UserStore
UserMgr --> ConfirmHandler: IdentityResult
deactivate UserMgr
ConfirmHandler --> Mediator: ConfirmationPayload(\n user.Id, DateTime.UtcNow)
deactivate ConfirmHandler
Mediator --> API: ConfirmationPayload
deactivate Mediator
API -> User: 200 OK\n{message: "User with ID {id} is confirmed.",\npayload: {...}}
end
end
deactivate API
@enduml

View File

@@ -1,91 +0,0 @@
@startuml delete-account
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Delete Account
actor User
participant "API\nController" as API
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
box "Handler Layer" #F1F3EA
participant "DeleteAccountHandler" as DeleteAccountHandler
participant "UserManager<ApplicationUser>" as UserMgr
end box
box "Repository / Cross-slice" #DBEEDD
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
end box
database "SQL Server" as DB
note over UserMgr
ASP.NET Core Identity's UserManager<TUser>
replaces the deleted AuthRepository: handlers
no longer issue SQL or call Argon2 directly,
they delegate to UserManager, which in turn
calls DapperUserStore (persistence) and
Argon2PasswordHasher (IPasswordHasher<TUser>).
end note
User -> API: DELETE /api/auth/account\n(requires Bearer access token)
activate API
API -> Mediator: Send(DeleteAccountCommand(\n GetAuthenticatedUserId()))
activate Mediator
Mediator -> DeleteAccountHandler: Handle(command)
activate DeleteAccountHandler
DeleteAccountHandler -> UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userAccountId)
UserStore --> UserMgr: ApplicationUser or null
UserMgr --> DeleteAccountHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
DeleteAccountHandler -> Mediator: throw NotFoundException
Mediator -> API: propagate
API -> User: 404 Not Found
else Account found
DeleteAccountHandler -> UserMgr: DeleteAsync(user)
activate UserMgr
UserMgr -> UserStore: DeleteAsync(user)
activate UserStore
UserStore -> DB: DELETE FROM dbo.UserAccount\nWHERE UserAccountID = @Id
note right of UserStore
UserCredential/UserVerification/UserAvatar
cascade on delete. Posts, comments, photos,
and follows use ON DELETE NO ACTION - if any
reference this account, the DELETE throws a
foreign key violation (SQL error 547).
end note
alt Foreign key violation (dependent records exist)
UserStore --> UserMgr: IdentityResult.Failed\n(AccountHasDependentRecords)
else Delete succeeds
UserStore --> UserMgr: IdentityResult.Success
end
deactivate UserStore
UserMgr --> DeleteAccountHandler: IdentityResult
deactivate UserMgr
alt Account has dependent records
DeleteAccountHandler -> Mediator: throw ConflictException\n"Account cannot be deleted while it still\nhas associated posts, comments, photos,\nor follows."
Mediator -> API: propagate
API -> User: 409 Conflict
else !result.Succeeded (other failure)
DeleteAccountHandler -> Mediator: throw ConflictException
Mediator -> API: propagate
API -> User: 409 Conflict
else result.Succeeded
DeleteAccountHandler --> Mediator: (void)
deactivate DeleteAccountHandler
Mediator --> API: (void)
deactivate Mediator
API -> User: 200 OK\n{message: "Account deleted successfully."}
end
end
deactivate API
@enduml

View File

@@ -1,30 +0,0 @@
@startuml error-handling
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Error Handling (Global Exception Filter)
participant "API\nController" as API
note over API
API.Core's GlobalExceptionFilter catches:
- FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property)
- ConflictException -> 409 Conflict
- NotFoundException -> 404 Not Found
- UnauthorizedException -> 401 Unauthorized
- ForbiddenException -> 403 Forbidden
- Domain.Exceptions.ValidationException -> 400 Bad Request
- Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable
- All others -> 500 Internal Server Error
end note
note over API
Applies uniformly across every authentication
endpoint (registration, login, refresh, confirm,
resend, update-username/email/password/profile,
delete-account) - see the per-flow diagrams for
where each exception type is thrown.
end note
@enduml

View File

@@ -1,129 +0,0 @@
@startuml login
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Login
actor User
participant "API\nController" as API
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
box "Handler Layer" #F1F3EA
participant "LoginHandler" as LoginHandler
participant "UserManager<ApplicationUser>" as UserMgr
participant "TokenService" as TokenSvc
end box
box "Infrastructure Layer" #EBECE3
participant "Argon2PasswordHasher" as Argon2
participant "JWT\nInfrastructure" as JWT
end box
box "Repository / Cross-slice" #DBEEDD
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
end box
database "SQL Server" as DB
note over UserMgr
ASP.NET Core Identity's UserManager<TUser>
replaces the deleted AuthRepository: handlers
no longer issue SQL or call Argon2 directly,
they delegate to UserManager, which in turn
calls DapperUserStore (persistence) and
Argon2PasswordHasher (IPasswordHasher<TUser>).
end note
User -> API: POST /api/auth/login\n{username, password}
activate API
API -> Mediator: Send(LoginCommand)
activate Mediator
note right of Mediator
ValidationBehavior runs LoginValidator:
username and password must both be non-empty
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
else Validation succeeds
Mediator -> LoginHandler: Handle(command)
activate LoginHandler
LoginHandler -> UserMgr: FindByNameAsync(username)
activate UserMgr
UserMgr -> UserStore: FindByNameAsync(username)
activate UserStore
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
activate DB
DB --> UserStore: UserAccount row or null
deactivate DB
UserStore -> UserStore: HydrateAsync\n(load active UserCredential.Hash,\nUserVerification -> EmailConfirmed)
UserStore --> UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr --> LoginHandler: ApplicationUser or null
deactivate UserMgr
alt User not found
LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password."
Mediator -> API: propagate
API -> User: 401 Unauthorized
else User found
LoginHandler -> UserMgr: CheckPasswordAsync(user, password)
activate UserMgr
note right of UserMgr
HasPasswordAsync short-circuits to false
(no active UserCredential was hydrated)
without calling the hasher; otherwise
delegates to Argon2PasswordHasher.
end note
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, password)
activate Argon2
note right of Argon2
Delegates to IPasswordInfrastructure.Verify:
1. Split stored hash: "salt:hash"
2. Hash provided password with the same salt
3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
end note
Argon2 --> UserMgr: Success/Failed
deactivate Argon2
UserMgr --> LoginHandler: true/false
deactivate UserMgr
alt Password invalid
LoginHandler -> Mediator: throw UnauthorizedException
Mediator -> API: propagate
API -> User: 401 Unauthorized
else Password valid
LoginHandler -> TokenSvc: GenerateAccessToken(user)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(...)
activate JWT
JWT --> TokenSvc: Access Token
deactivate JWT
TokenSvc --> LoginHandler: Access Token
deactivate TokenSvc
LoginHandler -> TokenSvc: GenerateRefreshToken(user)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(...)
activate JWT
JWT --> TokenSvc: Refresh Token
deactivate JWT
TokenSvc --> LoginHandler: Refresh Token
deactivate TokenSvc
LoginHandler --> Mediator: LoginPayload(\n userAccountId, username,\n refreshToken, accessToken)
deactivate LoginHandler
Mediator --> API: LoginPayload
deactivate Mediator
API -> User: 200 OK\n{\n message: "Logged in successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken\n }\n}
end
end
end
deactivate API
@enduml

View File

@@ -1,108 +0,0 @@
@startuml refresh-token
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Refresh Token
actor User
participant "API\nController" as API
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
box "Handler Layer" #F1F3EA
participant "RefreshTokenHandler" as RefreshHandler
participant "UserManager<ApplicationUser>" as UserMgr
participant "TokenService" as TokenSvc
end box
box #EBECE3
participant "JWT\nInfrastructure" as JWT
end box
box "Repository / Cross-slice" #DBEEDD
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
end box
database "SQL Server" as DB
note over UserMgr
ASP.NET Core Identity's UserManager<TUser>
replaces the deleted AuthRepository: handlers
no longer issue SQL or call Argon2 directly,
they delegate to UserManager, which in turn
calls DapperUserStore (persistence) and
Argon2PasswordHasher (IPasswordHasher<TUser>).
end note
User -> API: POST /api/auth/refresh\nHeader: X-Refresh-Token: <token>
activate API
API -> Mediator: Send(RefreshTokenCommand(refreshToken))
activate Mediator
note right of Mediator
ValidationBehavior runs RefreshTokenValidator:
refreshToken must be non-empty. Anonymous
endpoint - no Bearer token required.
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
else Validation succeeds
Mediator -> RefreshHandler: Handle(command)
activate RefreshHandler
RefreshHandler -> TokenSvc: RefreshTokenAsync(refreshToken)
activate TokenSvc
TokenSvc -> JWT: ValidateRefreshTokenAsync(refreshToken)
activate JWT
note right of JWT
Validates signature/expiry against the
refresh token secret, extracts userId/
username claims.
end note
JWT --> TokenSvc: ValidatedToken or throws
deactivate JWT
alt Token invalid or expired
TokenSvc -> RefreshHandler: throw UnauthorizedException
RefreshHandler -> Mediator: propagate
Mediator -> API: propagate
API -> User: 401 Unauthorized
else Token valid
TokenSvc -> UserMgr: FindByIdAsync(validated.UserId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userId)
activate UserStore
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
activate DB
DB --> UserStore: row or null
deactivate DB
UserStore --> UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr --> TokenSvc: ApplicationUser or null
deactivate UserMgr
alt User not found
TokenSvc -> RefreshHandler: throw UnauthorizedException\n"User account not found"
RefreshHandler -> Mediator: propagate
Mediator -> API: propagate
API -> User: 401 Unauthorized
else User found
TokenSvc -> JWT: GenerateAccessToken(user)\nGenerateRefreshToken(user)
JWT --> TokenSvc: new access + refresh tokens
TokenSvc --> RefreshHandler: RefreshTokenResult
deactivate TokenSvc
RefreshHandler --> Mediator: LoginPayload(\n userId, username,\n refreshToken, accessToken)
deactivate RefreshHandler
Mediator --> API: LoginPayload
deactivate Mediator
API -> User: 200 OK\n{message: "Token refreshed successfully.",\npayload: {...}}
end
end
end
deactivate API
@enduml

View File

@@ -1,241 +0,0 @@
@startuml registration
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Registration
actor User
participant "API\nController" as API
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
box "Handler Layer" #F1F3EA
participant "RegisterUserHandler" as RegHandler
participant "UserManager<ApplicationUser>" as UserMgr
participant "TokenService" as TokenSvc
end box
box "Infrastructure Layer" #EBECE3
participant "Argon2PasswordHasher" as Argon2
participant "JWT\nInfrastructure" as JWT
participant "Email\nProvider" as SMTP
participant "Template\nProvider" as Template
end box
box "Repository / Cross-slice" #DBEEDD
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
end box
database "SQL Server" as DB
note over UserMgr
ASP.NET Core Identity's UserManager<TUser>
replaces the deleted AuthRepository: handlers
no longer issue SQL or call Argon2 directly,
they delegate to UserManager, which in turn
calls DapperUserStore (persistence) and
Argon2PasswordHasher (IPasswordHasher<TUser>).
end note
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
activate API
API -> Mediator: Send(RegisterUserCommand)
activate Mediator
note right of Mediator
ValidationBehavior runs RegisterUserValidator
(FluentValidation) before the handler:
- Username: 3-64 chars, alphanumeric + [._-]
- Email: valid format, max 128 chars
- Password: min 8 chars, uppercase,\n lowercase, number, special char
- DateOfBirth: must be 19+ years old
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
else Validation succeeds
Mediator -> RegHandler: Handle(command)
activate RegHandler
RegHandler -> RegHandler: user = new UserAccount{...}\n.ToApplicationUser()
RegHandler -> UserMgr: CreateAsync(user, password)
activate UserMgr
UserMgr -> Argon2: HashPassword(user, password)
activate Argon2
note right of Argon2
Delegates to IPasswordInfrastructure.Hash:
Argon2id parameters:
- Salt: 16 bytes (128-bit)
- Memory: 64MB
- Iterations: 4
- Parallelism: CPU count
- Hash output: 32 bytes
end note
Argon2 --> UserMgr: "base64(salt):base64(hash)"
deactivate Argon2
UserMgr -> UserStore: SetPasswordHashAsync(user, hash)
note right of UserStore
user.Id is still empty at this point -
only sets the in-memory hash. CreateAsync
writes it to dbo.UserCredential itself.
end note
UserMgr -> UserStore: FindByNameAsync(username)
activate UserStore
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
activate DB
DB --> UserStore: null (user doesn't exist)
deactivate DB
deactivate UserStore
UserMgr -> UserStore: FindByEmailAsync(email)
activate UserStore
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
activate DB
DB --> UserStore: null (email doesn't exist)
deactivate DB
deactivate UserStore
note right of UserMgr
Default UserManager validators check
uniqueness before CreateAsync is called;
DapperUserStore.CreateAsync also guards
against a genuine race via a caught
duplicate-key SqlException (2601/2627).
end note
UserMgr -> UserStore: CreateAsync(user)
activate UserStore
UserStore -> DB: BEGIN TRANSACTION
activate DB
UserStore -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
DB --> UserStore: UserAccountId (GUID)
UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
UserStore -> DB: COMMIT TRANSACTION
deactivate DB
note right of UserStore
Both statements run via Dapper's
ExecuteScalarAsync/ExecuteAsync against
a single DbTransaction, not a stored
procedure - no manual parameter binding
end note
UserStore --> UserMgr: IdentityResult.Success\n(user.Id set)
deactivate UserStore
UserMgr --> RegHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
RegHandler -> Mediator: throw ConflictException\n(DuplicateUserName/DuplicateEmail codes ->\n"Username or email already exists";\notherwise joined error descriptions)
Mediator -> API: propagate
API -> User: 409 Conflict\n"Username or email already exists"
else result.Succeeded
RegHandler -> TokenSvc: GenerateAccessToken(user)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
activate JWT
note right of JWT
JWT Configuration:
- Algorithm: HS256
- Expires: 1 hour
- Claims:
* sub: userId
* unique_name: username
* iat / jti
end note
JWT -> JWT: Create JWT with claims
JWT -> JWT: Sign with secret key
JWT --> TokenSvc: Access Token
deactivate JWT
TokenSvc --> RegHandler: Access Token
deactivate TokenSvc
RegHandler -> TokenSvc: GenerateRefreshToken(user)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
activate JWT
note right of JWT
Refresh Token:
- Expires: 21 days (504 hours)
- Same structure as access token,\n signed with a separate secret
end note
JWT --> TokenSvc: Refresh Token
deactivate JWT
TokenSvc --> RegHandler: Refresh Token
deactivate TokenSvc
RegHandler -> TokenSvc: GenerateConfirmationToken(user)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
activate JWT
note right of JWT
Confirmation Token:
- Expires: 30 minutes (0.5 hours)
- Signed with a third, separate secret
end note
JWT --> TokenSvc: Confirmation Token
deactivate JWT
TokenSvc --> RegHandler: Confirmation Token
deactivate TokenSvc
RegHandler -> Mediator: Send(SendRegistrationEmailCommand)\n(Shared.Application cross-slice contract)
activate Mediator
Mediator -> EmailDispatcher: SendRegistrationEmailAsync(\n firstName, email, confirmationToken)
note right of Mediator
Routed to Features.Emails'
SendRegistrationEmailHandler,
the only cross-slice call in
the backend
end note
activate EmailDispatcher
EmailDispatcher -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
activate Template
note right of Template
Razor Component:
- Header with branding
- Welcome message
- Confirmation button
- Footer
end note
Template -> Template: Render Razor component\nto HTML
Template --> EmailDispatcher: HTML email content
deactivate Template
EmailDispatcher -> SMTP: SendAsync(email, subject, body)
activate SMTP
note right of SMTP
SMTP Configuration (MailKit):
- Host/port: from env (SMTP_HOST/SMTP_PORT)
- TLS: StartTLS when SMTP_USE_SSL=true
- Auth: username/password, if configured
end note
SMTP -> SMTP: Build MIME message
SMTP -> SMTP: Connect, authenticate,\nsend, disconnect
SMTP --> EmailDispatcher: Success / throws
deactivate SMTP
deactivate EmailDispatcher
deactivate Mediator
alt Email sent successfully
RegHandler -> RegHandler: emailSent = true
else mediator.Send throws
RegHandler -> RegHandler: catch(Exception),\nlog to stderr,\nemailSent = false
end
RegHandler --> Mediator: RegistrationPayload(\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent)
deactivate RegHandler
Mediator --> API: RegistrationPayload
deactivate Mediator
API -> User: 201 Created\n{\n message: "User registered successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent\n }\n}
end
end
deactivate API
@enduml

View File

@@ -1,97 +0,0 @@
@startuml resend-confirmation
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Resend Confirmation Email
actor User
participant "API\nController" as API
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
box "Handler Layer" #F1F3EA
participant "ResendConfirmationEmailHandler" as ResendHandler
participant "UserManager<ApplicationUser>" as UserMgr
participant "TokenService" as TokenSvc
end box
box "Infrastructure Layer" #EBECE3
participant "Email\nProvider" as SMTP
participant "Template\nProvider" as Template
end box
box "Repository / Cross-slice" #DBEEDD
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
end box
database "SQL Server" as DB
note over UserMgr
ASP.NET Core Identity's UserManager<TUser>
replaces the deleted AuthRepository: handlers
no longer issue SQL or call Argon2 directly,
they delegate to UserManager, which in turn
calls DapperUserStore (persistence) and
Argon2PasswordHasher (IPasswordHasher<TUser>).
end note
User -> API: POST /api/auth/confirm/resend?userId=<id>\n(requires Bearer access token)
activate API
API -> Mediator: Send(ResendConfirmationEmailCommand(userId))
activate Mediator
Mediator -> ResendHandler: Handle(command)
activate ResendHandler
ResendHandler -> UserMgr: FindByIdAsync(userId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userId)
activate UserStore
UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
activate DB
DB --> UserStore: row or null
deactivate DB
UserStore --> UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr --> ResendHandler: ApplicationUser or null
deactivate UserMgr
alt User not found
ResendHandler -> ResendHandler: return silently\n(prevents user enumeration)
else User found
ResendHandler -> UserMgr: IsEmailConfirmedAsync(user)
UserMgr --> ResendHandler: bool
alt Already confirmed
ResendHandler -> ResendHandler: return silently (no-op)
else Not yet confirmed
ResendHandler -> TokenSvc: GenerateConfirmationToken(user)
activate TokenSvc
TokenSvc --> ResendHandler: confirmation token
deactivate TokenSvc
ResendHandler -> Mediator: Send(SendResendConfirmationEmailCommand)
activate Mediator
Mediator -> EmailDispatcher: SendResendConfirmationEmailAsync(\n firstName, email, confirmationToken)
activate EmailDispatcher
EmailDispatcher -> Template: Render email\n(same Razor pipeline as registration)
EmailDispatcher -> SMTP: SendAsync(email, subject, body)
deactivate EmailDispatcher
deactivate Mediator
end
end
ResendHandler --> Mediator: (void)
deactivate ResendHandler
Mediator --> API: (void)
deactivate Mediator
API -> User: 200 OK\n{message: "confirmation email has been resent"}
note right of API
Always 200 OK regardless of outcome above -
same user-enumeration precaution as a not-found
user or an already-confirmed account.
end note
deactivate API
@enduml

View File

@@ -1,93 +0,0 @@
@startuml update-email
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Update Email
actor User
participant "API\nController" as API
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
box "Handler Layer" #F1F3EA
participant "UpdateEmailHandler" as UpdateEmailHandler
participant "UserManager<ApplicationUser>" as UserMgr
end box
box "Repository / Cross-slice" #DBEEDD
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
end box
database "SQL Server" as DB
note over UserMgr
ASP.NET Core Identity's UserManager<TUser>
replaces the deleted AuthRepository: handlers
no longer issue SQL or call Argon2 directly,
they delegate to UserManager, which in turn
calls DapperUserStore (persistence) and
Argon2PasswordHasher (IPasswordHasher<TUser>).
end note
User -> API: PATCH /api/auth/email\n{newEmail}\n(requires Bearer access token)
activate API
API -> Mediator: Send(UpdateEmailCommand(\n GetAuthenticatedUserId(), newEmail))
activate Mediator
note right of Mediator
ValidationBehavior runs UpdateEmailValidator
(same format rules as registration)
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request
else Validation succeeds
Mediator -> UpdateEmailHandler: Handle(command)
activate UpdateEmailHandler
UpdateEmailHandler -> UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userAccountId)
UserStore -> DB: SELECT ...
UserStore --> UserMgr: ApplicationUser or null
UserMgr --> UpdateEmailHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdateEmailHandler -> Mediator: throw NotFoundException
Mediator -> API: propagate
API -> User: 404 Not Found
else Account found
UpdateEmailHandler -> UserMgr: SetEmailAsync(user, newEmail)
activate UserMgr
UserMgr -> UserStore: FindByEmailAsync(newEmail)
note right of UserMgr
Uniqueness check. Also resets
user.EmailConfirmed = false -
the caller must re-confirm via
POST /api/auth/confirm/resend.
end note
UserMgr -> UserStore: UpdateAsync(user)
UserStore -> DB: UPDATE dbo.UserAccount\nSET Email = @Email, ...
UserStore -> DB: DELETE FROM dbo.UserVerification\nWHERE UserAccountID = @Id
UserStore --> UserMgr: IdentityResult
UserMgr --> UpdateEmailHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
UpdateEmailHandler -> Mediator: throw ConflictException\n(DuplicateEmail code ->\n"Email address already exists")
Mediator -> API: propagate
API -> User: 409 Conflict
else result.Succeeded
UpdateEmailHandler --> Mediator: UpdateEmailPayload(\n user.Id, user.Email,\n user.EmailConfirmed = false)
deactivate UpdateEmailHandler
Mediator --> API: UpdateEmailPayload
deactivate Mediator
API -> User: 200 OK\n{message: "Email updated successfully.",\npayload: {...}}
end
end
end
deactivate API
@enduml

View File

@@ -1,111 +0,0 @@
@startuml update-password
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Update Password
actor User
participant "API\nController" as API
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
box "Handler Layer" #F1F3EA
participant "UpdatePasswordHandler" as UpdatePasswordHandler
participant "UserManager<ApplicationUser>" as UserMgr
end box
box "Infrastructure Layer" #EBECE3
participant "Argon2PasswordHasher" as Argon2
end box
box "Repository / Cross-slice" #DBEEDD
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
end box
database "SQL Server" as DB
note over UserMgr
ASP.NET Core Identity's UserManager<TUser>
replaces the deleted AuthRepository: handlers
no longer issue SQL or call Argon2 directly,
they delegate to UserManager, which in turn
calls DapperUserStore (persistence) and
Argon2PasswordHasher (IPasswordHasher<TUser>).
end note
User -> API: PATCH /api/auth/password\n{currentPassword, newPassword}\n(requires Bearer access token)
activate API
API -> Mediator: Send(UpdatePasswordCommand(\n GetAuthenticatedUserId(),\n currentPassword, newPassword))
activate Mediator
note right of Mediator
ValidationBehavior runs UpdatePasswordValidator
(same password strength rules as registration)
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request
else Validation succeeds
Mediator -> UpdatePasswordHandler: Handle(command)
activate UpdatePasswordHandler
UpdatePasswordHandler -> UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userAccountId)
UserStore --> UserMgr: ApplicationUser or null
UserMgr --> UpdatePasswordHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdatePasswordHandler -> Mediator: throw NotFoundException
Mediator -> API: propagate
API -> User: 404 Not Found
else Account found
UpdatePasswordHandler -> UserMgr: ChangePasswordAsync(\n user, currentPassword, newPassword)
activate UserMgr
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, currentPassword)
activate Argon2
Argon2 --> UserMgr: Success/Failed
deactivate Argon2
alt Current password mismatch
UserMgr --> UpdatePasswordHandler: IdentityResult.Failed\n(PasswordMismatch)
else Current password matches
UserMgr -> Argon2: HashPassword(user, newPassword)
activate Argon2
Argon2 --> UserMgr: new hash
deactivate Argon2
UserMgr -> UserStore: SetPasswordHashAsync(user, newHash)
activate UserStore
UserStore -> DB: UPDATE dbo.UserCredential\nSET IsRevoked = 1, RevokedAt = GETDATE()\nWHERE UserAccountId = @Id AND IsRevoked = 0
UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
note right of UserStore
Revoke-then-insert runs inside one
DbTransaction (credential rotation).
end note
UserStore --> UserMgr: (void)
deactivate UserStore
UserMgr --> UpdatePasswordHandler: IdentityResult.Success
end
deactivate UserMgr
alt Current password is incorrect
UpdatePasswordHandler -> Mediator: throw UnauthorizedException\n"Current password is incorrect"
Mediator -> API: propagate
API -> User: 401 Unauthorized
else !result.Succeeded (other failure)
UpdatePasswordHandler -> Mediator: throw ConflictException
Mediator -> API: propagate
API -> User: 409 Conflict
else result.Succeeded
UpdatePasswordHandler --> Mediator: UpdatePasswordPayload(\n user.Id, DateTime.UtcNow)
deactivate UpdatePasswordHandler
Mediator --> API: UpdatePasswordPayload
deactivate Mediator
API -> User: 200 OK\n{message: "Password updated successfully.",\npayload: {...}}
end
end
end
deactivate API
@enduml

View File

@@ -1,92 +0,0 @@
@startuml update-profile
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Update Profile
actor User
participant "API\nController" as API
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
box "Handler Layer" #F1F3EA
participant "UpdateProfileHandler" as UpdateProfileHandler
participant "UserManager<ApplicationUser>" as UserMgr
end box
box "Repository / Cross-slice" #DBEEDD
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
end box
database "SQL Server" as DB
note over UserMgr
ASP.NET Core Identity's UserManager<TUser>
replaces the deleted AuthRepository: handlers
no longer issue SQL or call Argon2 directly,
they delegate to UserManager, which in turn
calls DapperUserStore (persistence) and
Argon2PasswordHasher (IPasswordHasher<TUser>).
end note
User -> API: PATCH /api/auth/profile\n{firstName, lastName, dateOfBirth}\n(requires Bearer access token)
activate API
API -> Mediator: Send(UpdateProfileCommand(\n GetAuthenticatedUserId(),\n firstName, lastName, dateOfBirth))
activate Mediator
note right of Mediator
ValidationBehavior runs UpdateProfileValidator
(same name/DateOfBirth rules as registration)
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request
else Validation succeeds
Mediator -> UpdateProfileHandler: Handle(command)
activate UpdateProfileHandler
UpdateProfileHandler -> UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userAccountId)
UserStore --> UserMgr: ApplicationUser or null
UserMgr --> UpdateProfileHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdateProfileHandler -> Mediator: throw NotFoundException
Mediator -> API: propagate
API -> User: 404 Not Found
else Account found
UpdateProfileHandler -> UpdateProfileHandler: user.FirstName = ...\nuser.LastName = ...\nuser.DateOfBirth = ...
note right of UpdateProfileHandler
First/last name and date of birth aren't
tracked by any Identity store interface -
mutated directly on the ApplicationUser,
then saved via UserManager.UpdateAsync.
end note
UpdateProfileHandler -> UserMgr: UpdateAsync(user)
activate UserMgr
UserMgr -> UserStore: UpdateAsync(user)
UserStore -> DB: UPDATE dbo.UserAccount\nSET FirstName = @FirstName,\n LastName = @LastName,\n DateOfBirth = @DateOfBirth, ...
UserStore --> UserMgr: IdentityResult
UserMgr --> UpdateProfileHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
UpdateProfileHandler -> Mediator: throw ConflictException
Mediator -> API: propagate
API -> User: 409 Conflict
else result.Succeeded
UpdateProfileHandler --> Mediator: UpdateProfilePayload(\n user.Id, firstName,\n lastName, dateOfBirth)
deactivate UpdateProfileHandler
Mediator --> API: UpdateProfilePayload
deactivate Mediator
API -> User: 200 OK\n{message: "Profile updated successfully.",\npayload: {...}}
end
end
end
deactivate API
@enduml

View File

@@ -1,94 +0,0 @@
@startuml update-username
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Update Username
actor User
participant "API\nController" as API
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
box "Handler Layer" #F1F3EA
participant "UpdateUsernameHandler" as UpdateUsernameHandler
participant "UserManager<ApplicationUser>" as UserMgr
end box
box "Repository / Cross-slice" #DBEEDD
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
end box
database "SQL Server" as DB
note over UserMgr
ASP.NET Core Identity's UserManager<TUser>
replaces the deleted AuthRepository: handlers
no longer issue SQL or call Argon2 directly,
they delegate to UserManager, which in turn
calls DapperUserStore (persistence) and
Argon2PasswordHasher (IPasswordHasher<TUser>).
end note
User -> API: PATCH /api/auth/username\n{newUsername}\n(requires Bearer access token)
activate API
API -> Mediator: Send(UpdateUsernameCommand(\n GetAuthenticatedUserId(), newUsername))
activate Mediator
note right of Mediator
ValidationBehavior runs UpdateUsernameValidator
(same format rules as registration)
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request
else Validation succeeds
Mediator -> UpdateUsernameHandler: Handle(command)
activate UpdateUsernameHandler
UpdateUsernameHandler -> UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userAccountId)
activate UserStore
UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
activate DB
DB --> UserStore: row or null
deactivate DB
UserStore --> UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr --> UpdateUsernameHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdateUsernameHandler -> Mediator: throw NotFoundException
Mediator -> API: propagate
API -> User: 404 Not Found
else Account found
UpdateUsernameHandler -> UserMgr: SetUserNameAsync(user, newUsername)
activate UserMgr
UserMgr -> UserStore: FindByNameAsync(newUsername)
note right of UserMgr
Uniqueness check before the UPDATE.
end note
UserMgr -> UserStore: UpdateAsync(user)
UserStore -> DB: UPDATE dbo.UserAccount\nSET Username = @UserName, ...
UserStore --> UserMgr: IdentityResult
UserMgr --> UpdateUsernameHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
UpdateUsernameHandler -> Mediator: throw ConflictException\n(DuplicateUserName code ->\n"Username already exists")
Mediator -> API: propagate
API -> User: 409 Conflict
else result.Succeeded
UpdateUsernameHandler --> Mediator: UpdateUsernamePayload(\n user.Id, user.UserName)
deactivate UpdateUsernameHandler
Mediator --> API: UpdateUsernamePayload
deactivate Mediator
API -> User: 200 OK\n{message: "Username updated successfully.",\npayload: {...}}
end
end
end
deactivate API
@enduml

View File

@@ -1,202 +0,0 @@
@startuml database-schema
!include ../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam linetype ortho
title Database Schema
caption Every table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no repository/feature slice yet.
package "User & Auth" {
entity "UserAccount" as User {
* UserAccountID: UNIQUEIDENTIFIER <<PK>>
--
* Username: VARCHAR(64) <<UNIQUE>>
* FirstName: NVARCHAR(128)
* LastName: NVARCHAR(128)
* Email: VARCHAR(128) <<UNIQUE>>
* CreatedAt: DATETIME
UpdatedAt: DATETIME
* DateOfBirth: DATE
}
entity "UserCredential" as Cred {
* UserCredentialID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
* CreatedAt: DATETIME
* Expiry: DATETIME
* Hash: NVARCHAR(256)
* IsRevoked: BIT
RevokedAt: DATETIME
}
entity "UserVerification" as Verify {
* UserVerificationID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
* VerificationDateTime: DATETIME
}
entity "UserProfile" as Profile {
* UserProfileID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
* Biography: NVARCHAR(MAX)
}
entity "UserAvatar" as Avatar {
* UserAvatarID: UNIQUEIDENTIFIER <<PK>>
--
* UserProfileID: UNIQUEIDENTIFIER <<FK>>
* PhotoID: UNIQUEIDENTIFIER <<FK>>
* ValidFrom: DATETIME2(3)
ValidTo: DATETIME2(3)
..
At most one row per UserProfileID
has ValidTo IS NULL (active avatar)
}
entity "UserFollow (*)" as Follow {
* UserFollowID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
* FollowingID: UNIQUEIDENTIFIER <<FK>>
* CreatedAt: DATETIME
}
entity "Photo (*)" as Photo {
* PhotoID: UNIQUEIDENTIFIER <<PK>>
--
Hyperlink: NVARCHAR(256)
* UploadedByID: UNIQUEIDENTIFIER <<FK>>
* UploadedAt: DATETIME
}
}
package "Location" {
entity "Country" as Country {
* CountryID: UNIQUEIDENTIFIER <<PK>>
--
* CountryName: NVARCHAR(100)
* ISO3166_1: CHAR(2) <<UNIQUE>>
}
entity "StateProvince" as StateProvince {
* StateProvinceID: UNIQUEIDENTIFIER <<PK>>
--
* StateProvinceName: NVARCHAR(100)
* ISO3166_2: CHAR(6) <<UNIQUE>>
* CountryID: UNIQUEIDENTIFIER <<FK>>
}
entity "City" as City {
* CityID: UNIQUEIDENTIFIER <<PK>>
--
* CityName: NVARCHAR(100)
* StateProvinceID: UNIQUEIDENTIFIER <<FK>>
}
}
package "Brewery" {
entity "BreweryPost" as Brewery {
* BreweryPostID: UNIQUEIDENTIFIER <<PK>>
--
* BreweryName: NVARCHAR(256)
* PostedByID: UNIQUEIDENTIFIER <<FK>>
* Description: NVARCHAR(512)
* CreatedAt: DATETIME
UpdatedAt: DATETIME
}
entity "BreweryPostLocation" as BreweryLoc {
* BreweryPostLocationID: UNIQUEIDENTIFIER <<PK>>
--
* BreweryPostID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
* AddressLine1: NVARCHAR(256)
AddressLine2: NVARCHAR(256)
* PostalCode: NVARCHAR(20)
* CityID: UNIQUEIDENTIFIER <<FK>>
Coordinates: GEOGRAPHY
}
entity "BreweryPostPhoto (*)" as BreweryPhoto {
* BreweryPostPhotoID: UNIQUEIDENTIFIER <<PK>>
--
* BreweryPostID: UNIQUEIDENTIFIER <<FK>>
* PhotoID: UNIQUEIDENTIFIER <<FK>>
* LinkedAt: DATETIME
}
}
package "Beer (*)" {
entity "BeerStyle (*)" as BeerStyle {
* BeerStyleID: UNIQUEIDENTIFIER <<PK>>
--
* StyleName: NVARCHAR(100) <<UNIQUE>>
Description: NVARCHAR(MAX)
}
entity "BeerPost (*)" as Beer {
* BeerPostID: UNIQUEIDENTIFIER <<PK>>
--
* Name: NVARCHAR(100)
* Description: NVARCHAR(MAX)
* ABV: DECIMAL(4,2)
* IBU: INT
* PostedByID: UNIQUEIDENTIFIER <<FK>>
* BeerStyleID: UNIQUEIDENTIFIER <<FK>>
* BrewedByID: UNIQUEIDENTIFIER <<FK>>
* CreatedAt: DATETIME
UpdatedAt: DATETIME
}
entity "BeerPostPhoto (*)" as BeerPhoto {
* BeerPostPhotoID: UNIQUEIDENTIFIER <<PK>>
--
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
* PhotoID: UNIQUEIDENTIFIER <<FK>>
* LinkedAt: DATETIME
}
entity "BeerPostComment (*)" as BeerComment {
* BeerPostCommentID: UNIQUEIDENTIFIER <<PK>>
--
* Comment: NVARCHAR(250)
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
* CommentedByID: UNIQUEIDENTIFIER <<FK>>
* Rating: INT
* CreatedAt: DATETIME
UpdatedAt: DATETIME
}
}
' Relationships: User & Auth
User ||--o{ Cred
User ||--o| Verify
User ||--o| Profile
Profile ||--o{ Avatar
Avatar }o--|| Photo
User ||--o{ Follow
User ||--o{ Follow
User ||--o{ Photo
' Relationships: Location
Country ||--o{ StateProvince
StateProvince ||--o{ City
' Relationships: Brewery
User ||--o{ Brewery
Brewery ||--o| BreweryLoc
City ||--o{ BreweryLoc
Brewery ||--o{ BreweryPhoto
Photo ||--o{ BreweryPhoto
' Relationships: Beer
User ||--o{ Beer
BeerStyle ||--o{ Beer
Brewery ||--o{ Beer
Beer ||--o{ BeerPhoto
Photo ||--o{ BeerPhoto
Beer ||--o{ BeerComment
User ||--o{ BeerComment
@enduml

View File

@@ -1,330 +0,0 @@
@startuml deployment
!include ../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam linetype ortho
title Docker Deployment Architecture
' External systems
actor Developer
cloud "Docker Host" as Host
package "Development Environment\n(docker-compose.dev.yaml)" #F1F3EA {
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as DevDB {
database "Biergarten\nDatabase" as DevDBInner {
portin "1433"
}
note right
Environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=***
- MSSQL_PID=Developer
Volumes:
- biergarten-dev-data
end note
}
node "API Container\n(API.Core)" as DevAPI {
component "ASP.NET Core 10" as API1
portin "8080:8080 (HTTP)" as DevPort1
portin "8081:8081 (HTTPS)" as DevPort2
note right
Environment:
- ASPNETCORE_ENVIRONMENT=Development
- DB_SERVER=sql-server
- DB_NAME=Biergarten
- DB_USER/PASSWORD
- JWT_SECRET
- SMTP_* (10+ variables)
Health Check:
/health endpoint
end note
}
node "Migrations\n(run-once)" as DevMig {
component "Database.Migrations" as Mig1
note bottom
Runs: DbUp migrations
Environment:
- CLEAR_DATABASE=false
Depends on: sql-server
end note
}
node "Seed\n(run-once)" as DevSeed {
component "Database.Seed" as Seed1
note bottom
Creates:
- 100 test users
- Location data (US/CA/MX)
- test.user account
Depends on: migrations
end note
}
node "Mailpit\n(axllent/mailpit)" as DevMailpit {
portin "8025 (Web UI)" as DevMailpitWeb
portin "1025 (SMTP)" as DevMailpitSmtp
note right
Local SMTP catcher for dev email testing.
Environment:
- MP_SMTP_AUTH_ACCEPT_ANY=1
- MP_SMTP_AUTH_ALLOW_INSECURE=1
end note
}
}
package "Test Environment\n(docker-compose.test.yaml)" #EBECE3 {
node "SQL Server\n(isolated instance)" as TestDB {
database "Biergarten\nTest Database" as TestDBInner {
portin "1434"
}
note right
Fresh instance each run
CLEAR_DATABASE=true
Volumes:
- biergarten-test-data
(ephemeral)
end note
}
node "Migrations\n(test)" as TestMig {
component "Database.Migrations"
}
node "Seed\n(test)" as TestSeed {
component "Database.Seed"
note bottom
Minimal seed:
- test.user only
- Essential data
end note
}
node "API.Specs\n(Integration Tests)" as Specs {
component "Reqnroll + xUnit" as SpecsComp
note right
Tests:
- Registration flow
- Login flow
- Validation rules
- 404 handling
Uses: TestApiFactory
Mocks: Email services
end note
}
node "unit.tests\n(Features.*.Tests, one container)" as UnitTests {
component "xUnit + Moq" as UnitComp
note right
Builds Dockerfile.tests, which
restores/builds against Core.slnx
then runs `dotnet test` for every
Features/*.Tests project in turn:
- Features.Users.Tests
- Features.Breweries.Tests
- Features.Emails.Tests
Uses: Moq to mock each handler's
repository/service dependencies
No real database needed
end note
}
}
package "Production Environment\n(docker-compose.prod.yaml)" #CBD2B5 {
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as ProdDB {
database "Biergarten\nDatabase" as ProdDBInner
note right
Environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=***
- MSSQL_PID=Express
Volumes:
- sqlserverdata-prod
No host port published.
end note
}
node "Migrations\n(run-once)" as ProdMig {
component "Database.Migrations" as ProdMig1
note bottom
Depends on: sql-server (healthy)
No seed step in production.
end note
}
node "API Container\n(API.Core)" as ProdAPI {
component "ASP.NET Core 10" as ProdAPI1
portin "8080:8080 (HTTP)" as ProdPort1
portin "8081:8081 (HTTPS)" as ProdPort2
note right
Environment:
- ASPNETCORE_ENVIRONMENT=Production
- DB_SERVER/NAME/USER/PASSWORD
- ACCESS/REFRESH/CONFIRMATION_TOKEN_SECRET
- WEBSITE_BASE_URL
Depends on: sql-server (healthy) -
does not wait on migrations/seed.
end note
}
ProdMig --> ProdDB : 1. Run migrations
ProdAPI --> ProdDB : 2. Connect & serve
ProdMig .up.> ProdDB : depends_on
ProdAPI .up.> ProdDB : depends_on
note bottom of ProdDB
<b>Prod Network (bridge: prodnet)</b>
Isolated from dev/test networks.
Volumes: sqlserverdata-prod,
nuget-cache-prod
end note
}
package "Database-only Environment\n(docker-compose.db.yaml)" #E4E6D8 {
node "SQL Server" as DbOnlyDB {
database "Biergarten\nDatabase" as DbOnlyDBInner {
portin "1434"
}
}
node "Migrations\n(run-once)" as DbOnlyMig {
component "Database.Migrations"
}
node "Seed\n(run-once)" as DbOnlySeed {
component "Database.Seed"
}
note bottom of DbOnlyDB
Same devnet volumes/images as
docker-compose.dev.yaml, minus
api.core and mailpit - for working
against the database without
running the API.
end note
DbOnlyMig --> DbOnlyDB : 1. Run migrations
DbOnlySeed --> DbOnlyDB : 2. Seed data
DbOnlyMig .up.> DbOnlyDB : depends_on
DbOnlySeed .up.> DbOnlyMig : depends_on
}
package "Minimal Environment\n(docker-compose.min.yaml)" #DBEEDD {
node "SQL Server" as MinDB {
database "Biergarten\nDatabase" as MinDBInner {
portin "1433"
}
}
node "Migrations\n(run-once)" as MinMig {
component "Database.Migrations"
}
node "Mailpit" as MinMailpit {
portin "8025 / 1025"
}
node "Seed\n(run-once)" as MinSeed {
component "Database.Seed"
}
note bottom of MinDB
Database + mailpit only, no API
container - for frontend development
against an API.Core process run
outside Docker.
end note
MinMig --> MinDB : 1. Run migrations
MinSeed --> MinDB : 2. Seed data
MinMig .up.> MinDB : depends_on
MinSeed .up.> MinMig : depends_on
}
folder "test-results/\n(mounted volume)" as Results {
file "api-specs/\n results.trx" as Result1
file "Features.Users.Tests.trx" as Result2
file "Features.Breweries.Tests.trx" as Result3
file "Features.Emails.Tests.trx" as Result5
note bottom
TRX format
Readable by:
- Visual Studio
- Azure DevOps
- GitHub Actions
end note
}
' External access
Developer --> Host : docker compose up
Host --> DevAPI : http://localhost:8080
' Development dependencies
DevMig --> DevDB : 1. Run migrations
DevSeed --> DevDB : 2. Seed data
DevAPI --> DevDB : 3. Connect & serve
DevAPI ..> DevMailpit : 4. SMTP_HOST/PORT
DevMig .up.> DevDB : depends_on
DevSeed .up.> DevMig : depends_on
DevAPI .up.> DevSeed : depends_on
' Test dependencies
TestMig --> TestDB : 1. Migrate
TestSeed --> TestDB : 2. Seed
Specs --> TestDB : 3. Integration test
UnitTests ..> TestDB : depends_on for startup\nordering only (Moq mocks,\nno real connection)
TestMig .up.> TestDB : depends_on
TestSeed .up.> TestMig : depends_on
Specs .up.> TestSeed : depends_on
UnitTests .up.> TestSeed : depends_on
' Test results export
Specs --> Results : Export TRX
UnitTests --> Results : Export TRX (one .trx per slice)
' Network notes
note bottom of DevDB
<b>Dev Network (bridge: biergarten-dev)</b>
Internal DNS:
- sql-server (resolves to SQL container)
- api (resolves to API container)
end note
note bottom of TestDB
<b>Test Network (bridge: biergarten-test)</b>
All test components isolated
end note
' Startup sequence notes
note top of DevMig
Startup Order:
1. SQL Server (health check)
2. Migrations (run-once)
3. Seed (run-once)
4. API (long-running)
end note
note top of Specs
Test Execution:
All tests run in parallel
Results aggregated
end note
@enduml