Architecture
Raytha is one ASP.NET Core application organised as Clean Architecture, with a React admin app bundled into it. Read this page before your first pull request: it names the layers, shows where each kind of code lives, and traces a public request from URL to HTML.
The layers
Dependencies point inward: Web depends on Infrastructure, which depends on Application, which depends on Domain.
| Project | Holds | Must not reference |
|---|---|---|
src/Raytha.Domain | Entities, value objects, domain events, domain exceptions. | Any other project; EF Core; ASP.NET Core. |
src/Raytha.Application | Use cases (commands and queries with handlers and validators), interfaces for everything outside the process, pipeline behaviors, DTOs and render models. | Infrastructure, Web, a concrete database provider, ASP.NET Core. |
src/Raytha.Infrastructure | EF Core context, configurations and migrations, file storage providers, SMTP, background tasks, the Functions engine, health checks, the JSON query engine. | Web. |
src/Raytha.Web | The host: Startup and middleware, the public site, the admin API, REST API v1, SPA hosting, observability. | |
src/admin | The React admin workspace (pnpm): apps/shell, packages/api, packages/ui. |
These rules are tests, not conventions. tests/Raytha.Architecture.Tests fails the build when a layer reaches outward, when a use case breaks the structure below, when an admin endpoint lacks an authorization policy, and for a dozen other invariants such as proxy trust and site-URL handling. Observability packages (Sentry, OpenTelemetry, Serilog sinks) live only in the Web host.
Use cases: commands and queries
Application code uses CQRS with Mediator (the source-generated library, version 3). One file holds one use case: a static-style outer class with a nested request, its handler and, optionally, a validator.
public class DeleteWebhook
{
public record Command : LoggableEntityRequest<CommandResponseDto<ShortGuid>> { }
public class Handler : IRequestHandler<Command, CommandResponseDto<ShortGuid>>
{
private readonly IRaythaDbContext _db;
public Handler(IRaythaDbContext db)
{
_db = db;
}
public async ValueTask<CommandResponseDto<ShortGuid>> Handle(
Command request,
CancellationToken cancellationToken
)
{
var entity = _db.Webhooks.FirstOrDefault(p => p.Id == request.Id.Guid);
if (entity == null)
throw new NotFoundException("Webhook", request.Id);
_db.Webhooks.Remove(entity);
await _db.SaveChangesAsync(cancellationToken);
return new CommandResponseDto<ShortGuid>(request.Id);
}
}
}
The architecture tests enforce the shape:
- Requests are nested
CommandorQuerytypes, and each has exactly one nestedHandler. Commands are records withinit-only properties. - Commands derive from
LoggableRequest<T>orLoggableEntityRequest<T>, which is what makes them auditable. A command that changes state and is not auditable fails a test unless it is on a short, reasoned allow-list. - Query handlers and validators never call
SaveChanges. Handlers do not send through the mediator or resolve services from a container. - Handlers use the interface
IRaythaDbContext, not the concrete context. EveryDbSetmust exist on both.
A command that should fire a webhook carries an attribute, for example [WebhookEvent("content_item.created", DisplayName = "Content item created", Group = "Content")]. The event catalog discovers these by reflection, so the admin list and the API stay in sync. See Webhooks.
The pipeline
Every request passes through behaviors registered in this order:
- Unhandled exception logs and rethrows.
- Validation runs FluentValidation validators and throws a validation exception.
- Transaction opens one database transaction around a command, its after-save event handlers, its audit row and its webhook publish. Queries pass through. A command sent from inside another joins the open transaction.
- Audit writes an audit log row for each successful command: a sanitised copy of the request, the user's email, the impersonator's email if any, and the IP address.
- Webhook publish creates delivery rows for subscribed webhooks, inside the same transaction. A failure here is rolled back to a savepoint and never fails the command.
Because of the transaction behavior, a command's data change, its audit entry and its queued webhook deliveries commit together or not at all.
Domain events (types in Raytha.Domain/Events) are dispatched through Mediator around SaveChanges; their handlers live next to the use cases in EventHandlers folders. Welcome and password emails are sent this way.
Persistence
Persistence/RaythaDbContext.csis the EF Core context, using Npgsql on PostgreSQL. Entity mappings are inPersistence/Configurations/, one class per entity.- An interceptor in
Persistence/Interceptorssets creation and modification timestamps and user ids onSaveChanges. - Content item data is stored as JSONB: published content in
_PublishedContent, the draft beside it. TheJsonQueryEnginetranslates filter and sort expressions for the REST API and Liquid into queries over those columns. - Dapper is used only in Infrastructure, for raw SQL such as the JSON query engine, the maintenance statistics and the background task scheduler.
- Data-protection keys are stored in the database (
DataProtectionKeys), so instances and restores share sign-in sessions.
Migrations
Migrations are named after the release that ships them. Add one with:
dotnet ef migrations add v2_1_0 \
--project src/Raytha.Infrastructure \
--startup-project src/Raytha.Web
Then refresh the SQL scripts in db/Postgres/: FreshCreateOnLatestVersion.sql is generated, and an upgrade script such as v1_5_0_to_v2_0_0.sql carries hand-written data rewrites that a plain migration cannot express. tools/check-sql-scripts.py regenerates the fresh script and fails on a diff, and a new migration raises the minor version. See Local development for the versioning rules.
The web host
Startup.cs wires the middleware pipeline. In order: path base, forwarded headers (proxy trust), exception handling, status-code pages, HTTPS redirection and HSTS, legacy admin redirects, the admin dev proxy (development only), static files, security headers, the admin JSON guard, routing, rate limiting, then authentication and authorization.
Three surfaces share the host:
- Public site. MVC controllers in
Areas/Publicand the Liquid view engine inAreas/Public/DbViewEngine. - Admin. The React app, served from
wwwroot/raythaat/raytha, talks to a cookie-authenticated JSON API at/raytha/api/adminand/raytha/api/auth(endpoints inAreas/Admin/Api). Writes needContent-Type: application/json, which doubles as CSRF protection. Every endpoint under/raythamust have an authorization policy unless it is on a test-enforced allow-list. - REST API v1. Controllers in
Areas/Api/Controllers/V1at/raytha/api/v1, authenticated with theX-API-KEYheader. Its reference is served by Scalar at/raytha/api.
Background work (CSV imports, theme duplication, webhook deliveries, Functions started from events) runs as rows in the BackgroundTasks table, consumed by NUM_BACKGROUND_WORKERS hosted workers, with a scheduler that re-enqueues due retries.
From a URL to a rendered page
Take a request for /products/desk-lamp.
- The request passes the middleware above. None of the admin or API routes match, so the catch-all route reaches
MainController. An empty path takes the home-page route instead. MainControllersends theGetRouteByPathquery. TheRoutestable maps each path (matched case-insensitively) to one of four targets: a content item, a view (a list), a site page, or a Raytha Function. No match gives the 404 page.- The controller loads the target and picks a web template.
- Content item: unpublished items are a 404 unless the request has
?previewDraft=trueand the user has permission. The template is the one assigned to the item, or the built-inraytha_html_content_item_detail. - View: Raytha runs the view's filter and sort with
PublishedOnlyset, clamps the page size to the view's limits, and uses the assigned list template orraytha_html_content_item_list. - Site page: the page's widgets are rendered with their widget templates inside the page's layout.
- Function: an HTTP-triggered function runs, and its result becomes the response.
- Content item: unpublished items are a 404 unless the request has
- The result object (
ContentItemActionViewResult,SitePageActionViewResultand siblings) assembles the template source:WebTemplateExtensions.ContentAssembledFromParentswalks up the template's parent chain and replaces each parent's{% renderbody %}tag with the child's content, so layouts inherit. RenderEngine(Fluid, a .NET Liquid implementation) renders that source with a wrapper model:CurrentOrganization,CurrentUser,ContentType,Target(the item, list or page),QueryParams,RequestVerificationTokenandPathBase.- The HTML is written to the response. Liquid errors surface here, at render time, not when a template is saved.
Templates, models and filters are documented in Building templates.
Where to put your change
| You are adding | Put it in |
|---|---|
| A field on an entity | Domain entity, then its configuration and a migration in Infrastructure, then the DTOs and use cases that expose it. |
| A new operation | A new file in Raytha.Application/<Feature>/Commands or /Queries, then an endpoint in Web that sends it. |
| A new webhook event | A [WebhookEvent] attribute on the command. |
| An admin screen | src/admin/apps/shell/src/pages, plus the endpoint and the typed client in src/admin/packages/api. |
| A new storage or mail provider | An implementation of the interface in Application, registered in Infrastructure's ConfigureServices.cs. |
Gotchas
- The compiled admin bundle in
src/Raytha.Web/wwwroot/raythais committed. Changing anything undersrc/adminrequires rebuilding it, and CI fails on a stale bundle. - Tests must not touch a real PostgreSQL, network or SMTP server. Architecture tests start the real
Startupon a host that is never run. - Older code has known departures from the structure rules. They sit in short, explicit allow-lists inside the tests; do not add to them without a reason.
Next steps
- Local development: run it, test it, and the versioning rules.
- Contributing: how to propose a change.
- Building templates: the Liquid side of rendering.