Project Architecture
Version 1 · Initial architecture doc: layering, multi-database connection-factory pattern, Story/Media/Lookup/Auth feature flows, cross-cutting concerns
Project Architecture
QDG CMS API follows a clean/onion layering split across four projects, all targeting .NET 9.
Layers
QDG.CMS.API (controllers, auth handler, Swagger, versioning, DI wiring)
-> QDG.CMS.Application (DTOs, service interfaces + implementations, mappings, settings)
-> QDG.CMS.Domain (entities, enums — no dependencies)
-> QDG.CMS.Infrastructure (repositories, DB connection factories, health checks, external clients)
Controllers depend only on Application-layer service interfaces (IStoryService, IMediaService, ILookupService, IAuthService) — never directly on Infrastructure repositories or IStorageService. This keeps the API layer free of data-access and storage concerns; see the doc comments on StoryController, MediaController, and LookupController for the explicit "depends only on I*Service" statement.
All DI wiring lives in Startup.cs, constructed from Program.cs (which also bootstraps Serilog and registers DapperTypeMapConfig.Register() before the host builds).
Request pipeline
Program.cs builds the host, then Startup.Configure wires, in order: Swagger (dev/prod only), Serilog request logging, CORS (AllowedSites policy), authentication, authorization, controller mapping, and the /health endpoint.
Multi-database data access
A single DbConnectionFactory class implements three marker interfaces so DI can resolve a different connection string per consumer, even though the underlying MySqlConnection construction is identical:
IDbConnectionFactory→DefaultConnection(cms database) — used byStoryRepositoryand the CMS half ofLookupRepository.IQdbConnectionFactory→QdbConnection(qdb database) — used by the racing-reference half ofLookupRepository.IPhotoGalleryConnectionFactory→PhotoGalleryConnection(photogallery database, separate server/credentials) — used byMediaRepository.
LookupRepository deliberately holds both a CMS and a qdb connection factory in one class/interface, since callers just want "give me a lookup list" regardless of source database.
Dapper is configured once via DapperTypeMapConfig.Register(): DefaultTypeMap.MatchNamesWithUnderscores = true plus one explicit CustomPropertyTypeMap entry so the tblcms.Story column binds to Story.StoryContent (the property couldn't be named Story since that's already the class name).
Story feature
StoryController → IStoryService/StoryService → IStoryRepository/StoryRepository (cms database). Queries are ported 1:1 from the legacy Appsmith CMS but fully parameterized (the originals built WHERE clauses via string interpolation). Create/Update run inside a transaction that also syncs tblcms_site and tblcms_newstype mapping rows via delete-then-reinsert. Restricted-content tag validation (RestrictedStoryTagValidator) throws RestrictedStoryTagsMissingException, caught in the controller and returned as 400 with the missing-tag list.
Media feature
MediaController → IMediaService/MediaService → IMediaRepository/MediaRepository (photogallery database) + IStorageService/WasabiStorageService (S3-compatible Wasabi). One controller/table (tblphotos) serves both Image Gallery and Video Gallery, discriminated by the Type query parameter/enum (MediaType).
Upload is two-phase (Helanka's decision, 31 Aug 2026):
GET /media/upload-urlreturns a presigned PUT URL + bare object key; the frontend uploads bytes directly to Wasabi.POST/PUT /mediapersists thetblphotosrow referencing that object key — the API itself never receives file bytes.
Resized-variant resolution (MediaMappings.BuildSignedUrlMap/ResolveVariants/MapToDto) lists the whole Wasabi folder for the type once per request and matches DB rows to listed files by base filename — reinstated from an earlier "derive keys from origFile" approach (kept commented out per the project's "never remove, comment out" convention) because the DB's own small/medium/hd/hires/large columns aren't reliable across rows.
Lookup feature
LookupController → ILookupService/LookupService → either ILookupRepository/LookupRepository (sites, authors, contributors, news types) or a set of ILookupProvider implementations (SitesLookupProvider, AuthorsLookupProvider, ContributorsLookupProvider) registered for the generic /lookup/bootstrap multi-key endpoint. Racing-reference lookups (horses/trainers/jockeys/courses/races) are parameterized (search-as-you-type or course-scoped) and don't fit the no-args ILookupProvider shape, so they call ILookupService methods directly.
Auth feature
See the doc comments on SessionAuthenticationHandler/AuthController and User Guide for the session flow. In brief: AuthController proxies login/TOTP verification to the external QDBAuth service via IAuthServiceClient/AuthServiceClient (shared-secret header), then issues its own opaque qdg_session cookie. SessionAuthenticationHandler (a custom AuthenticationScheme) validates that cookie against an in-memory ISessionCache/SessionCache on every request, transparently refreshing against QDBAuth when the access token has expired but the refresh token hasn't.
Cross-cutting concerns
- Versioning —
Asp.VersioningwithDefaultApiVersion = 1.0,AssumeDefaultVersionWhenUnspecified = true; routes areapi/v{version:apiVersion}/[controller]. - Swagger — documents the cookie-based session scheme as an API-key-in-cookie security definition so "Try it out" carries the session automatically after login + totp-verify.
- JSON enum binding — a global
JsonStringEnumConverter(camelCase) is added toAddJsonOptionsso[FromBody]enum properties (e.g.MediaSaveDto.Type) accept the same lowercase string the frontend sends for[FromQuery]enums. - CORS —
AllowedOriginsconfig array,AllowCredentials()enabled (required for the session cookie to be sent cross-origin). - Health checks —
MySqlHealthCheck(tagdb/ready) andWasabiHealthCheck(tagstorage/ready), aggregated at/healthwith a custom JSON response writer. - Logging — Serilog console sink, bootstrap logger during host startup,
UseSerilogRequestLogging()for request/response logging.
Deployment
See Overview — manual GitHub Actions workflow_dispatch pipeline publishing a self-contained win-x64 build to an on-prem IIS site, with backup/health-check/rollback built into the workflow itself (no external deployment tooling).