Platform
Learn
Developer docs User guide Quickstart Blog
Company
Services About Contact Links Get started

Architecture

Updated

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.

ProjectHoldsMust not reference
src/Raytha.DomainEntities, value objects, domain events, domain exceptions.Any other project; EF Core; ASP.NET Core.
src/Raytha.ApplicationUse 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.InfrastructureEF Core context, configurations and migrations, file storage providers, SMTP, background tasks, the Functions engine, health checks, the JSON query engine.Web.
src/Raytha.WebThe host: Startup and middleware, the public site, the admin API, REST API v1, SPA hosting, observability.
src/adminThe 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 Command or Query types, and each has exactly one nested Handler. Commands are records with init-only properties.
  • Commands derive from LoggableRequest<T> or LoggableEntityRequest<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. Every DbSet must 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:

  1. Unhandled exception logs and rethrows.
  2. Validation runs FluentValidation validators and throws a validation exception.
  3. 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.
  4. 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.
  5. 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.cs is the EF Core context, using Npgsql on PostgreSQL. Entity mappings are in Persistence/Configurations/, one class per entity.
  • An interceptor in Persistence/Interceptors sets creation and modification timestamps and user ids on SaveChanges.
  • Content item data is stored as JSONB: published content in _PublishedContent, the draft beside it. The JsonQueryEngine translates 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/Public and the Liquid view engine in Areas/Public/DbViewEngine.
  • Admin. The React app, served from wwwroot/raytha at /raytha, talks to a cookie-authenticated JSON API at /raytha/api/admin and /raytha/api/auth (endpoints in Areas/Admin/Api). Writes need Content-Type: application/json, which doubles as CSRF protection. Every endpoint under /raytha must have an authorization policy unless it is on a test-enforced allow-list.
  • REST API v1. Controllers in Areas/Api/Controllers/V1 at /raytha/api/v1, authenticated with the X-API-KEY header. 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.

  1. 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.
  2. MainController sends the GetRouteByPath query. The Routes table 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.
  3. The controller loads the target and picks a web template.
    • Content item: unpublished items are a 404 unless the request has ?previewDraft=true and the user has permission. The template is the one assigned to the item, or the built-in raytha_html_content_item_detail.
    • View: Raytha runs the view's filter and sort with PublishedOnly set, clamps the page size to the view's limits, and uses the assigned list template or raytha_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.
  4. The result object (ContentItemActionViewResult, SitePageActionViewResult and siblings) assembles the template source: WebTemplateExtensions.ContentAssembledFromParents walks up the template's parent chain and replaces each parent's {% renderbody %} tag with the child's content, so layouts inherit.
  5. RenderEngine (Fluid, a .NET Liquid implementation) renders that source with a wrapper model: CurrentOrganization, CurrentUser, ContentType, Target (the item, list or page), QueryParams, RequestVerificationToken and PathBase.
  6. 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 addingPut it in
A field on an entityDomain entity, then its configuration and a migration in Infrastructure, then the DTOs and use cases that expose it.
A new operationA new file in Raytha.Application/<Feature>/Commands or /Queries, then an endpoint in Web that sends it.
A new webhook eventA [WebhookEvent] attribute on the command.
An admin screensrc/admin/apps/shell/src/pages, plus the endpoint and the typed client in src/admin/packages/api.
A new storage or mail providerAn implementation of the interface in Application, registered in Infrastructure's ConfigureServices.cs.

Gotchas

  • The compiled admin bundle in src/Raytha.Web/wwwroot/raytha is committed. Changing anything under src/admin requires 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 Startup on 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