Skip to content

Architecture ​

SchulyBackend is a clean-architecture solution with CQRS. Requests flow into thin controllers, which dispatch commands/queries through Mediator to handlers in the application layer; persistence and external integrations live in infrastructure.

Projects ​

The solution (Schuly.API.slnx) is split into the following projects:

ProjectRole
Schuly.APIEntry point. Controllers, OIDC wiring, OpenAPI/Scalar, startup migrations, plugin host registration. Owns the Dockerfile.
Schuly.ApplicationCQRS commands/queries + Mediator handlers, DTOs, mappers, authorization and pipeline behaviors. Must not reference Infrastructure.
Schuly.DomainPure entities (School, Class, Exam, Grade, Absence, AgendaEntry, ApplicationUser, SchoolUser, Teacher, SchoolSystem, SemesterReport, StudentDocument, …). Each inherits Base (Id, CreatedAt, UpdatedAt).
Schuly.InfrastructureSchulyDbContext, OIDC/user services, storage and the plugin secrets vault, repositories.
Schuly.Tests / Schuly.Tests.PluginTest projects (TUnit).

Schuly.Plugin.Abstractions is consumed as a NuGet PackageReference, not a project reference. The abstractions and the plugin implementations live in separate repositories.

Layering rules ​

  • Dependencies point inward: API → Application → Domain, and Infrastructure → Application/Domain.
  • Schuly.Application must not reference Schuly.Infrastructure. Handlers depend on abstractions; the API project composes the concrete infrastructure services into the DI container at startup (Program.cs).
  • Schuly.Domain has no project dependencies - entities stay pure.

Request pipeline ​

Controllers are thin and delegate to Mediator. Two pipeline behaviors are registered explicitly in Program.cs and run in registration order:

  1. AuthorizationBehavior - enforces role gates before the handler runs.
  2. PluginEventBehavior - dispatches backend commands to plugin event handlers.

Mediator handlers are registered automatically via source generation, so a new command/query and its handler are wired up just by adding the classes.

Document storage ​

Student documents and avatars are kept in an S3-compatible bucket - SeaweedFS in the bundled dev and self-hosting stacks, though any S3 implementation works without a code change. See Configuration for the settings.

The backend proxies every byte itself: clients never receive S3 URLs and never connect to the storage backend directly. Uploads go to POST /api/students/{id}/documents (multipart) and downloads come back from GET /api/documents/{id} as a file response. Avatars are the one exception - the database holds only a bare blob key, and a short-lived HMAC-signed capability URL is minted per access (see Avatar URL signing).

Adding an entity + endpoint ​

  1. Entity in Schuly.Domain (inherits Base).
  2. DbSet + configuration in Schuly.Infrastructure/SchulyDbContext.cs.
  3. Migration - see Migrations.
  4. Command/Query in Schuly.Application/Commands/<Entity>/ or Queries/<Entity>/.
  5. Handler alongside the command/query (auto-registered via Mediator source-gen).
  6. Controller in Schuly.API/Controllers/ - thin, delegates to Mediator.

Plugin host ​

The backend hosts plugins implementing ISchulyPlugin from Schuly.Plugin.Abstractions. The plugin runtime lives in Schuly.API/Plugins: plugins are downloaded at runtime from a registry into /app/plugins, each loaded into its own collectible AssemblyLoadContext with a child DI container, and can register controllers, minimal-API endpoints, and recurring background tasks - scheduled on TickerQ and dispatched back into the owning plugin's DI scope by name. Plugin requests execute inside the owning plugin's DI scope via PluginScopeMiddleware. See Plugin management for the registry, hot-swap, background tasks, and admin endpoints.