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 theIModulecontract. - Host wires everything:
Program.cs, the layout (menus come fromIModule.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/*/*.csprojwith a glob and discovers theIModuleclass 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 |
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
DbContextwould live for the user's whole session and return stale entities. AlwaysIDbContextFactory<AppDbContext>and a short-lived context per operation. - Interactivity is set once on
<Routes>inApp.razor, andHeadOutletis interactive too — otherwise the layout, the bell and the page title stop reacting. - Every page prerenders before the circuit opens, and the prerender's
NavigationManagerhas no location-changing handlers:RegisterLocationChangingHandlerinOnInitializedthrows there. An unsaved-changes guard is a<NavigationLock>, which registers after the first render (PlanEditoris 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). NeverToLocalTime().
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.