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

28 KiB

Architecture

This document describes the architecture of The Biergarten App.

High-level overview

The Biergarten App is a monorepo split between the backend and the website:

  • Backend: .NET 10 Web API with SQL Server, organized as vertical feature slices with MediatR
  • Frontend: React 19 + React Router 8 website in web/frontend
  • Architecture Style: Vertical-slice backend plus server-rendered React frontend

archive/next-js-web-app/ contains an archived Next.js frontend, kept for reference.

Diagrams

For visual representations, see:

Authentication workflow diagrams, one per flow, under diagrams/auth/:

Backend architecture

Vertical-slice architecture pattern

The backend organizes business capabilities as feature slices instead of technical layers. Each feature (Features.Users, Features.Breweries, Features.Emails, Features.Locations, Features.PhotoUpload) is a single project that owns its own MediatR commands/queries/handlers, validators, and repository end to end. Features.Emails, Features.Locations, and Features.PhotoUpload have no Controllers/ folder; all three are invoked internally by other slices via MediatR/DI, never over HTTP:

flowchart TB
    subgraph HOST["API.Core (thin host)"]
        direction LR
        H1["Program.cs wiring:<br/>MediatR + AddApplicationPart per slice"]
        H2["Swagger / OpenAPI"]
        H3["JWT auth middleware"]
        H4["GlobalExceptionFilter"]
    end

    subgraph SLICES["Feature Slices"]
        direction LR
        S1["Features.Users<br/>Controllers, Commands/Queries,<br/>Handlers, Repository"]
        S2["Features.Breweries<br/>Controller, Commands/Queries,<br/>Handlers, Repository"]
        S3["Features.Emails<br/>(internal only)<br/>Commands, Handlers,<br/>EmailDispatcher"]
        S4["Features.Locations<br/>(internal only)<br/>Repository"]
        S5["Features.PhotoUpload<br/>(internal only)<br/>Commands, Handlers,<br/>Repository"]
    end

    subgraph SHARED["Shared Projects"]
        direction LR
        SH1["Shared.Contracts<br/>ResponseBody envelope"]
        SH2["Shared.Application<br/>ValidationBehavior,<br/>cross-slice email commands"]
    end

    subgraph DOMAIN["Domain Layer"]
        direction LR
        D1["Domain.Entities"]
        D2["Domain.Exceptions"]
    end

    subgraph INFRA["Infrastructure Layer"]
        direction LR
        I1["Database.Connection"]
        I2["Infrastructure.Jwt"]
        I3["Infrastructure.PasswordHashing"]
        I4["Infrastructure.Email /<br/>Email.Templates"]
        I5["Infrastructure.FileUpload"]
    end

    DB[("Database (SQL Server)")]

    HOST -- "discovers controllers via<br/>AddApplicationPart" --> SLICES
    S1 -- "SendRegistrationEmailCommand<br/>(via Shared.Application contract)" --> S3
    S1 -- "ProjectReference<br/>(avatar upload)" --> S5

    SLICES --> SHARED
    SLICES --> DOMAIN
    SLICES --> INFRA

    INFRA --> DB

    style S3 stroke-dasharray: 4 3
    style S4 stroke-dasharray: 4 3
    style S5 stroke-dasharray: 4 3

Slices generally don't reference each other's project. Most cross-slice interactions go through a MediatR command instead — for example, Features.Users triggering a confirmation email handled by Features.Emails via a SendRegistrationEmailCommand whose contract lives in Shared.Application, so neither slice takes a project reference on the other. The one exception is Features.Users, which takes a direct ProjectReference on Features.PhotoUpload to send UploadPhotoCommand for avatar uploads.

Layer responsibilities

API layer (API.Core)

Purpose: Thin ASP.NET Core host: no business logic, no controllers of its own

Components:

Component Description
Program.cs Registers MediatR (scanning every Features.* assembly), FluentValidation, and uses AddApplicationPart so each slice's controllers are discovered by MVC.
GlobalException.cs Global exception filter (host-level cross-cutting concern).
Authentication/JwtAuthenticationHandler.cs JWT auth scheme middleware.
Swagger/OpenAPI documentation, health check endpoints Host-level documentation and health endpoints.

Dependencies:

  • Every Features.* project (for controller/MediatR discovery)
  • Shared.Contracts, Shared.Application
  • Infrastructure.Jwt (for the auth middleware)

Rules:

  • No controllers, no business logic, no feature-specific contracts
  • Exists purely to host and wire up the feature slices

Feature slices (Features.Users, Features.Breweries, Features.Emails, Features.Locations, Features.PhotoUpload)

Purpose: Each slice is the complete vertical for one business capability

Components (per slice):

Component Description
Controllers/ HTTP endpoints, binding directly to Command/Query types as the request contract
Commands/<Operation>/ and Queries/<Operation>/ One folder per operation, each containing the Command/Query record, its IRequestHandler, and (for commands) a FluentValidation validator
Repository/ The slice's own Dapper repository implementation
Dtos/ Response shapes returned by query handlers (never the raw domain entity)
DependencyInjection/ An AddFeaturesX() extension method registering the slice's repository/services
Note

Features.Emails has no Controllers/ folder. It's invoked only via MediatR commands sent from other slices, never over HTTP.

Features.Locations also has no Controllers/ folder and no MediatR handlers; it exposes ILocationRepository directly as a plain service, currently consumed only by Database.Seed to look up cities while seeding brewery locations. Other slices (for example, Features.Breweries) do not yet depend on it; they run their own CityID existence checks inline.

Features.PhotoUpload also has no Controllers/ folder; its UploadPhotoCommand is sent by other features' handlers (for example, a future brewery photo upload command) rather than bound to an HTTP route directly.

Dependencies:

  • Domain.Entities, Domain.Exceptions
  • Database.Connection (generic ADO.NET connection/command plumbing) plus whichever infrastructure project the slice needs (Infrastructure.Jwt/ Infrastructure.PasswordHashing for Users, Infrastructure.Email/ Infrastructure.Email.Templates for Emails, Infrastructure.FileUpload for PhotoUpload)
  • Shared.Contracts, Shared.Application
  • No other Features.* project, with one exception: Features.Users references Features.PhotoUpload directly for avatar uploads

Rules:

  • All business logic for that feature lives in its command/query handlers
  • No direct controller-to-repository calls; everything flows through MediatR
  • Read endpoints return a dedicated Dto, never the domain entity directly

Shared projects (Shared.Contracts, Shared.Application)

Purpose: The minimum cross-slice surface area required because every slice needs it, or because duplicating it four times would be worse than sharing it

Components:

Project Purpose
Shared.Contracts ResponseBody<T>/ResponseBody, the API response envelope every controller returns
Shared.Application ValidationBehavior<TRequest,TResponse> (the MediatR pipeline behavior that runs FluentValidation before a handler executes) and the cross-slice email commands (SendRegistrationEmailCommand, SendResendConfirmationEmailCommand)

Rules:

  • Kept deliberately small: this is the exception to "no slice depends on another slice," not a general-purpose dumping ground

Infrastructure layer

Purpose: Technical capabilities and external integrations, shared by whichever slices need them

Components:

Project Description
Database.Connection Generic ADO.NET connection/command plumbing (DefaultSqlConnectionFactory, the abstract DapperRepository base class), not domain-specific; each slice's own Repository/ folder builds on this.
Infrastructure.Jwt JWT token generation and validation.
Infrastructure.PasswordHashing Argon2id password hashing.
Infrastructure.Email Email sending capabilities (SMTP/MailKit).
Infrastructure.Email.Templates Email template rendering (Razor components).
Infrastructure.FileUpload IFileStorageProvider abstraction over S3-compatible object storage, implemented by S3FileStorageProvider (targets the SeaweedFS container in dev; any S3-compatible endpoint in other environments) using the AWS SDK's AmazonS3Client with ForcePathStyle = true.

Dependencies:

  • Domain entities
  • External libraries (ADO.NET, JWT, Argon2, MailKit, AWSSDK.S3, and so on)

Rules:

  • Implements technical concerns only, no business logic
  • Reusable across slices

Domain layer (Domain.Entities)

Purpose: Core business entities and models

Components:

Entity Description
UserAccount User profile data.
UserCredential Authentication credentials.
UserVerification Account verification state.
BreweryPost A user-submitted brewery listing.
BreweryPostLocation A brewery's address and coordinates.
City A city referenced by a brewery location.
StateProvince A state/province referenced by a city.
Country A country referenced by a state/province.
BeerStyle A beer style/category (schema table exists; no repository yet).
BeerPost A beer listing tied to a brewery and style (schema table exists; no repository yet).
Photo A photo uploaded by a user account, written via Features.PhotoUpload's IPhotoUploadRepository.

Dependencies:

  • None (pure domain)

Rules:

  • Plain Old CLR Objects (POCOs)
  • No framework dependencies
  • No infrastructure references
  • Represents business concepts

Design patterns

Vertical slice + MediatR

Purpose: Organize code by feature instead of by technical layer, so everything needed to understand or change one capability lives in one project

Implementation:

  • Each HTTP write operation is a Command (for example, CreateBreweryCommand in Features.Breweries/Commands/CreateBrewery/); each read operation is a Query (for example, GetBreweryByIdQuery)
  • Controllers bind directly to the Command/Query as the request body; there is no separate request DTO + mapping step for writes
  • A single shared ValidationBehavior<TRequest,TResponse> (Shared.Application/Behaviors/) runs FluentValidation validators in the MediatR pipeline before any handler executes
  • Query handlers map to a dedicated response Dto, so domain entities never leak over the wire

Example:

public record CreateBreweryCommand(Guid PostedById, string BreweryName, string Description, ...)
    : IRequest<BreweryDto>;

public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandler<CreateBreweryCommand, BreweryDto>
{
    public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken) { ... }
}

Repository pattern

Purpose: Abstract database access behind interfaces

Implementation: each slice owns its own repository, scoped to that feature only:

  • Features.Users/Repository/IUserListRepository.cs, Features.Users/Repository/IUserProfileRepository.cs
  • Features.Breweries/Repository/IBreweryRepository.cs
  • Features.PhotoUpload/Repository/IPhotoUploadRepository.cs
  • Database.Connection/DefaultSqlConnectionFactory.cs: the generic connection factory every slice's repository builds on
  • Database.Connection/DapperRepository.cs: shared abstract base class every repository extends, providing CreateConnection() and a RollbackQuietlyAsync() helper that rolls back a transaction while swallowing any exception the rollback itself raises, so the exception that triggered the rollback is what propagates

Benefits:

  • Testable (easy to mock)
  • Each slice's data access logic is self-contained

Example:

public interface IUserListRepository
{
    Task<UserAccount?> GetByIdAsync(Guid id);
    Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
}

Dependency injection

Purpose: Loose coupling and testability

Configuration: Program.cs wires up MediatR/FluentValidation across every Features.* assembly; each slice exposes its own AddFeaturesX() extension method that registers its repository and slice-internal services

Lifetimes:

  • Scoped: Repositories, slice-internal services (per request)
  • Singleton: DefaultSqlConnectionFactory
  • Transient: Utilities, helpers

Direct SQL via Dapper/ADO.NET

Purpose: Keep data access explicit and colocated with the slice that owns it

Strategy:

  • No ORM; each repository issues inline SQL through Dapper, which handles parameter binding. The one wrinkle is the GEOGRAPHY column on BreweryPostLocation, which Dapper can't deserialize as a UDT: reads select CONVERT(varbinary(max), Coordinates) so the value comes back as a plain byte[] Dapper can bind. Proximity search (GET /api/brewery/locations/nearby) filters and orders rows server-side with Coordinates.STDistance(@Origin) <= @RangeInMetres, avoiding a client-side distance calculation over every row. No spatial index backs the query yet; schema.sql sketches one, commented out, for when the table grows large enough to need it
  • Referential checks (for example, "does this CityId exist?") are explicit SELECT 1 ... existence checks in the repository method, run inside the same transaction as the write
  • Optimistic concurrency uses each table's RowVersion (ROWVERSION) column, checked in the UPDATE ... WHERE ... AND RowVersion = @RowVersion clause
  • Repositories throw Domain.Exceptions types (NotFoundException, ConflictException, ...) directly when a check fails; API.Core's GlobalExceptionFilter maps those exception types to HTTP status codes
  • Application focuses on orchestration; the database enforces integrity via keys, CHECK constraints, and cascades

See Database for the schema and the app's SQL error-handling approach in more detail.

Frontend architecture

Website (web/frontend)

The website is a React Router 8 application with server-side rendering enabled.

web/frontend/
├── app/
│   ├── components/      Shared UI: Navbar, FormField, SubmitButton, ToastProvider,
│   │                    RouteErrorState, ClientOnly (defers client-only children,
│   │                    for example Leaflet maps, past hydration)
│   ├── features/        One folder per feature slice, each owning its own
│   │                    routes/, components/, hooks/, schemas, and server-side
│   │                    loader helpers:
│   │                      - account    profile/dashboard management
│   │                      - auth       login, register, confirm, logout
│   │                      - breweries  index/directory/detail routes, Leaflet
│   │                                   map components, nearby-search resource
│   │                                   route, distance/address utils
│   │                      - catalog    beers, beer styles
│   │                      - home       landing page
│   │                      - theme      theme switcher/guide
│   ├── hooks/            Shared hooks not tied to one feature, for example useMediaQuery
│   ├── routes.ts         Route table (React Router 8 config-based routing)
│   ├── root.tsx          App shell and global providers
│   └── app.css           Theme tokens and global styling
├── .storybook/          Storybook config and preview setup
├── stories/             Storybook stories for shared UI and themes
├── tests/playwright/    Storybook Playwright coverage
└── package.json         Frontend scripts and dependencies

Frontend responsibilities

  • Render brewery discovery routes (/breweries index, /breweries/directory, /breweries/:id) with Leaflet-powered maps and geolocation-based "nearby" search
  • Render the auth demo, account dashboard, and theme guide routes
  • Manage cookie-backed website session state
  • Call the .NET API for login, registration, token refresh, confirmation, and brewery/location data
  • Provide shared UI building blocks for forms, navigation, themes, and toasts
  • Supply Storybook documentation and browser-based component verification

Theme system

The website uses semantic DaisyUI theme tokens backed by four Biergarten themes:

  • Biergarten Lager
  • Biergarten Stout
  • Biergarten Cassis
  • Biergarten Weizen

All component styling should prefer semantic tokens such as primary, success, surface, and highlight instead of hard-coded color values.

Archived frontend

archive/next-js-web-app/ contains an archived Next.js frontend, kept for reference. Product and engineering documentation points to web/frontend.

Security architecture

Authentication flow

  1. Registration:

    • User submits credentials
    • Password hashed with Argon2id
    • User account created (unverified) and a confirmation email is dispatched via Features.Emails
  2. Email confirmation:

    • User follows the confirmation link/token from the email
    • Features.Users' ConfirmUser command marks the account verified
  3. Login:

    • User submits credentials
    • Password verified against hash
    • Access and refresh JWTs are issued and returned to the client; neither is stored server-side
  4. Token refresh:

    • Client exchanges a valid, unexpired refresh token for a new access/refresh pair via Features.Users' RefreshToken command
    • Refresh tokens are stateless JWTs, not tracked server-side: the previous refresh token is not invalidated and remains usable until its own expiration
  5. API Requests:

    • Client sends the access JWT in the Authorization header
    • JwtAuthenticationHandler validates the token
    • Request proceeds if valid

Password security

Algorithm: Argon2id

  • Memory: 64MB
  • Iterations: 4
  • Parallelism: 2 (hardcoded, to avoid thread-pool exhaustion)
  • Salt: 128-bit (16 bytes)
  • Hash: 256-bit (32 bytes)

JWT tokens

Algorithm: HS256 (HMAC-SHA256)

Claims:

  • sub - User ID
  • unique_name - Username
  • jti - Unique token ID
  • iat - Issued at timestamp
  • exp - Expiration timestamp

Configuration (appsettings.json):

{
  "Jwt": {
    "ExpirationMinutes": 60,
    "Issuer": "biergarten-api",
    "Audience": "biergarten-users"
  }
}

This block exists in appsettings.json, but nothing in the code currently reads it: actual token lifetimes come from the hardcoded TokenServiceExpirationHours constants, and JwtInfrastructure hardcodes ValidateIssuer/ValidateAudience to false, so Issuer/Audience are not enforced.

Database architecture

For the table-by-table schema and the custom SQL error code scheme, see Database.

SQL-first philosophy

Principles:

  1. Database is source of truth
  2. Queries live in the owning repository, not scattered across the app
  3. Database handles referential integrity
  4. Application orchestrates, database executes

Benefits:

  • Performance optimization via execution plans
  • Query logic scoped to the repository that owns it, not spread across layers
  • Version-controlled schema (migrations)
  • Easier query profiling and tuning

Migration strategy

Tool: DbUp

Process:

  1. Write a SQL script under Database.Migrations/scripts/
  2. Embed it in the Database.Migrations project
  3. Database.Migrations runs each script against the target database on startup, tracked so a script never re-runs once applied

Migration Files:

scripts/
└── 01-schema/
    ├── 01-UserAccount.sql
    ├── 02-Photo.sql
    ├── ...
    ├── 16-UserProfile.sql
    └── 17-UserAvatar.sql        # one script per CREATE TABLE

Each table has its own script, numbered so a table's foreign keys always point at an earlier-numbered script. As the schema evolves, new numbered scripts get added rather than editing an existing one in place, so DbUp's already-applied tracking stays valid.

Data seeding

Purpose: Populate development/test databases with realistic data

Implementation: Database.Seed project, using data produced by the C++ pipeline under tooling/pipeline/ (see Pipeline README)

Seed Data:

  • Countries, state/provinces, and cities (via Features.Locations' ILocationRepository, which creates Country/StateProvince rows on demand while resolving a city)
  • User accounts (via Features.Users' repository)
  • Brewery posts with locations (via Features.Breweries' repository)

Photo rows are written via Features.PhotoUpload's IPhotoUploadRepository, but Database.Seed doesn't call it, so photos aren't part of the seed data. Tables that exist in the schema but have no repository yet (UserAvatar, UserFollow, BeerStyle, BeerPost, BeerPostPhoto, BeerPostComment, BreweryPostPhoto) are not seeded either.

Deployment architecture

Docker containerization

Container Structure:

  • sqlserver - SQL Server 2022
  • database.migrations - Schema migration runner
  • database.seed - Data seeder
  • api.core - ASP.NET Core Web API
  • mailpit - Local dev/test SMTP server + web UI (not used in production)
  • seaweedfs - S3-compatible object storage for uploaded photos (dev only)

Environments:

  • Development (docker-compose.dev.yaml)
  • Testing (docker-compose.test.yaml)
  • Production (docker-compose.prod.yaml)

For details, see Docker Guide.

Health checks

  • SQL Server: Validates database connectivity
  • API: Checks service health and dependencies

Configuration:

healthcheck:
  test: ["CMD-SHELL", "sqlcmd health check"]
  interval: 10s
  retries: 12
  start_period: 30s

Testing architecture

Test pyramid

flowchart TB
    IT["Integration Tests<br/>API.Specs (Reqnroll)"]
    UT["Unit Tests (per slice, handler tests)<br/>Features.Users.Tests, Features.Breweries.Tests,<br/>Features.Emails.Tests, Features.PhotoUpload.Tests<br/>(commands/queries/handlers, with repository/service<br/>dependencies mocked via Moq)"]

    IT --- UT

Strategy:

  • Many unit tests (fast, isolated)
  • Fewer integration tests (slower, e2e)
  • Mock external dependencies
  • Test database for integration tests

For details, see Testing Guide.