Table of Contents

Architecture

The system is one engine, not fourteen features: every request type is a dynamic form + a conditional route of steps, each with its deadline + a closure rule, and the types themselves are data edited from the admin UI. Adding a service means publishing a definition, not writing a page.

Three layers, one direction

flowchart TB
    subgraph host [Host — src/ErpDarAlBer.Web]
        P[Program.cs] --- L[MainLayout] --- H[Home = module widgets]
        M[Migrations]
    end
    subgraph core [Core — src/ErpDarAlBer.Core]
        D[(AppDbContext\ncomposed from modules)]
        U[Users · Roles · Permissions · Org]
        S[Shared components]
        I[IModule contract + ModuleHost]
    end
    subgraph mods [Modules — modules/*]
        R[Requests]
        T[_template]
        F[fleet…]
    end
    host --> core
    mods --> core
    host -. glob reference, discovered by reflection .-> mods
  • Core knows nothing about any module. It owns identity and organisation data, the single AppDbContext, shared UI components, the realtime bus, and the IModule contract.
  • Host wires everything: Program.cs, the layout (menus come from IModule.Nav), login, core admin pages, and the EF migrations (the host is the only project that sees every module's model).
  • Modules are folders. The host references modules/*/*.csproj with a glob and discovers the IModule class at startup. Delete the folder and nothing else changes. See Modules.

Inside the Requests module

flowchart LR
    Cat[(Catalog\nRequestTypeVersions)] -->|definition| DF[DynamicForm]
    DF -->|FormModel, text values| Sub[RequestService.SubmitAsync]
    Sub --> Fill[Booleans.Fill\nan unticked box is no]
    Fill --> Ctx[ContextValuesListener\nrequester's card, list details]
    Ctx --> Prep[IRequestListener.PrepareAsync\nfeature-computed fields]
    Prep --> Val[FormValidation\nRules.Shows / Requires / Invalid]
    Val --> Chain[ApprovalChain.Submitted\nRules.OnRoute → steps]
    Chain --> Dir[ApproverDirectory\nrole → person]
    Dir --> Steps[(RequestApprovals\nthe route's rows)]
    Steps --> Due[StartDeadlineAsync\nthe step due now's deadline]
    Steps --> Inbox
    Steps --> Notif[NotificationService + RealtimeBus]
    Sweep[StalledStepSweep\nhand-overs, late notices] --> Steps
    Steps -->|a task done| Feat[IRequestListener.StepDoneAsync\nstock posted, items held]
    Notif --> Out[EmailOutbox]
Concern Where Why it is shaped this way
Request type definitions Services/Catalog.cs, Data/ServiceCatalog.*.cs (seed only) Versioned in the DB; a request always renders with the version it was submitted with
Form values Services/FormModel.cs Everything is text (what HTML sends); a formula reads it typed
Every rule Domain/Formula.cs, Domain/Rules.cs One small typed language and one judge for showing, requiring, validating, routing and deadlines; the builder's rows (Condition) are a view of a formula. See Rules and formulas
What a rule reads beside the form Domain/RuleContext.cs, Services/ContextValuesListener.cs The requester's card (requester.branch, requester.positions…) and a chosen list value's details (car.seats), written into the form before it is judged, kept as submitted
Field groups Domain/FieldChecks.cs Fields drawn and checked together, with an optional named result the route reads
Feature-computed fields Services/IRequestListener.cs (PrepareAsync), Domain/Booleans.cs A listener fills the values its feature owns (the room's type from the room) before validation, so conditions see them. "Within working hours" is the requester's own yes/no; Booleans.Fill makes an unticked box "no", so the chain always resolves
Who approves Services/ApproverDirectory.cs, src/ErpDarAlBer.Core/Domain/Relations.cs Positions are rows in the roles table; line manager / branch officer / the requester follow the requester
The route of a request Domain/RequestRoute.cs, RequestService.CanAct The step due now is the first undecided row; the status, the inbox and the buttons start from it; one who-may-act rule with its SQL twins in Inbox
Deadlines and late Domain/Deadlines.cs, Services/StalledStepSweep.cs A deadline per step, counted from when it falls due; late is one rule in memory and in SQL; the sweep tells each late step once, again only after a party's hand-over or a correction
Step photos Domain/Photos.cs, Services/AttachmentService.cs, wwwroot/camera.js Taken live on a phone and posted over HTTP from the app's own page; every rule (the holder, the most, the row, final once added) lives in the service
State machine Services/RequestService.cs Every transition checks the expected status and bumps Version (optimistic concurrency)
Who may see a request Services/RequestAccess.cs One rule for the page and for attachment downloads
Inbox counts and lists Services/Inbox.cs One query for both, so "3 waiting" never sits above an empty table
A feature drawing a field its own way Services/IFormWidget.cs The booking's room, day and window are chosen on /rooms; the form shows the choice
Lent items Services/HeldValuesListener.cs, LookupService.HoldsAsync A value of a held list is taken while an open request holds it; nothing is stored, the forms are the record
Stock Stock/ One ledger and one writer (StockService.PostAsync); a task's «حركة مخزون» posts through StockListener. See Lists and inventory
Reports Reports/ One scope (ReportScope), paged and grouped in SQL, any value a column (ReportValues). See Reports
E-mail Email/EmailOutbox.cs, EmailTemplates Every notification queued in its change's transaction; the outbox sends each once
Room booking Rooms/ A booking is a request; RoomBookingListener is the engine's one seam, and BookingRules refuses with the same Arabic the page greys with
The floor plan Rooms/RoomPlan.cs, Components/Shared/RoomMap.razor, wwwroot/plan/*.svg Two static drawings; a room's place is Room.ShapeJson in per-mille of the drawing's box, saved only through RoomService.SavePlanAsync

The floor plan. The building's two floors are static SVGs in the module's wwwroot/plan (rebuilt from the as-built PDF by tools/plan/build.py; both floors share one crop, so never re-export one alone). RoomMap shows a floor as an image with every placed room drawn over it as a button positioned in percent, coloured by the same cells the ruler draws; /rooms uses it to pick a room and the administrator's builder (PlanEditor, the "المخطط" tab of /admin/rooms) uses it to draw. A placement is a PlanRect stored as Room.ShapeJson; Room.Floor is a key from RoomPlan.Floors. Everything that decides is in C#: RoomPlan.Clamp and RoomPlan.Problems judge a rectangle for the editor and for SavePlanAsync alike, and the ES modules (ruler.js for the week ruler, plan.js for the drag, spot.js for a booked room's place) only preview and report back. The other three ES modules are camera.js (a step's camera), canvas.js (the catalog editor's drag) and sort.js (a report's columns dragged into order). Six in all, each a drag or a device: what decides stays in C#.

Blazor Server specifics you must know

  • The DI scope is the circuit. A scoped DbContext would live for the user's whole session and return stale entities. Always IDbContextFactory<AppDbContext> and a short-lived context per operation.
  • Interactivity is set once on <Routes> in App.razor, and HeadOutlet is interactive too — otherwise the layout, the bell and the page title stop reacting.
  • Every page prerenders before the circuit opens, and the prerender's NavigationManager has no location-changing handlers: RegisterLocationChangingHandler in OnInitialized throws there. An unsaved-changes guard is a <NavigationLock>, which registers after the first render (PlanEditor is the example).
  • Realtime is in-process (RealtimeBus): a change publishes an event; subscribed circuits reload. One server only — a backplane is the upgrade path.
  • Time is stored UTC and displayed through Local (UAE, +4, no DST). Never ToLocalTime().

Things deliberately not done

No repository layer, no MediatR, no separate Domain/Application/Infrastructure projects, no AutoMapper. Each was considered and rejected because it hides what we need to see here (Include, tracking, the actual query). Add a practice when it prevents a failure that happened here, not because it is on a list.