210 lines
13 KiB
Markdown
210 lines
13 KiB
Markdown
# Server structure
|
|
|
|
The layers, the projects, startup wiring, and the seam catalogue.
|
|
|
|
> Last verified: 2026-07-30 against commit `d3ec723` — 14 `.csproj` projects, 55 V1 controllers.
|
|
|
|
---
|
|
|
|
## 1. Clean Architecture, and the one hard boundary
|
|
|
|
**Dependencies point inward.**
|
|
|
|
```
|
|
Domain ← Application ← Infrastructure
|
|
← API
|
|
```
|
|
|
|
- **Domain** references nothing.
|
|
- **Application** references only Domain.
|
|
- **Infrastructure** and **API** implement and consume Application contracts.
|
|
- **Never** make Domain or Application reference Infrastructure or the API. This is not a preference; it is
|
|
the thing that keeps handlers unit-testable and lets a mock become a real vendor without touching a caller.
|
|
|
|
## 2. The projects
|
|
|
|
```
|
|
src/
|
|
├── Core/
|
|
│ ├── Baya.Domain Entities, value objects, status-code sets, transition tables
|
|
│ └── Baya.Application Features/ (CQRS slices) · Contracts/ (the seams) · Models/ · pipeline behaviors
|
|
├── Infrastructure/
|
|
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext · ValueConversion/ · Repositories/ · Configuration/<Area>Config/ · Migrations/ · Interceptors/ · Services/ (DB-backed facades, Scheduling/, Search/, Seeding/)
|
|
│ ├── Baya.Infrastructure.Identity Jwt/ · Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
|
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring · Seams/ (mocks) · Seams/Real/ (vendor adapters) · AddCrossCuttingSeams
|
|
│ └── Baya.Infrastructure.Monitoring HealthChecks (live/ready) · OpenTelemetry
|
|
├── API/
|
|
│ ├── Baya.Web.Api Program.cs · Controllers/V1/ · appsettings*.json
|
|
│ ├── Baya.WebFramework BaseController · Filters/ · Middlewares/ · Swagger/ · Routing/ · ServiceConfiguration/
|
|
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto (User only)
|
|
├── Shared/Baya.SharedKernel Extensions + validation base
|
|
└── Tests/
|
|
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup, TestFieldEncryptor)
|
|
├── Baya.Test.Infrastructure.Identity xUnit identity tests
|
|
├── Baya.Test.Foundation Cross-cutting plumbing + identity handler unit tests
|
|
└── Baya.Test.Api WebApplicationFactory integration tests (in-memory SQLite, env "Testing")
|
|
```
|
|
|
|
**Domain entity folders**, one per bounded area: `User/`, `Identity/`, `Geography/`, `Catalog/`,
|
|
`Verification/`, `Search/`, `Booking/`, `Payments/`, `Refunds/`, `Invoices/`, `Bnpl/`, `Payouts/`, `Reviews/`,
|
|
`Messaging/`, `PartnerCenters/`, plus `Configuration/`, `Audit/`, `Analytics/`, `Holidays/`,
|
|
`Notifications/`, `SupportAlerts/`. `Common/` holds `BaseEntity`, `IEntity`, `ITimeModification`,
|
|
`IAuditableEntity`, `IAuditable`, `[AuditRedacted]`.
|
|
|
|
**Application feature areas** mirror them: `Identity`, `Geography`, `ServiceAreas`, `Addresses`, `Catalog`,
|
|
`Variants`, `Verification`, `Search`, `Booking` (singular — pre-payment requests), `Bookings` (plural — the
|
|
post-payment engine), `Payments`, `Refunds`, `Invoices`, `Bnpl`, `Payouts`, `Reviews`, `PatientCareRecords`,
|
|
`Messaging`, `PartnerCenters`, `Configuration`, `Audit`, `Analytics`, `Holidays`, `Notifications`,
|
|
`SupportAlerts`, `System`.
|
|
|
|
> `Booking` (singular) and `Bookings` (plural) are **different areas, not a rename.** A booking request is
|
|
> the money-free pre-payment intent; a booking exists only after capture. The entity type `Booking` is
|
|
> aliased where the two namespaces collide. The same split is load-bearing in the client's
|
|
> `bookingRequests`/`bookings` domains and in Persian copy («درخواست رزرو» vs «رزرو»).
|
|
|
|
**Database schemas**, one per area, mirroring how Identity uses `usr`: `usr`, `ops`, `geo`, `catalog`,
|
|
`verif`, `search`, `booking`, `payments`, `payouts`, `reviews`, `messaging`, `partner`.
|
|
|
|
**Keeping this current is mandatory.** When a change adds, removes, or renames a project, a layer, or a major
|
|
folder, or changes a cross-layer dependency, update the **Project map** in
|
|
[server/CLAUDE.md](../../../server/CLAUDE.md) and this section in the **same** change. A map is only canonical
|
|
if it stays accurate.
|
|
|
|
---
|
|
|
|
## 3. The seams
|
|
|
|
The Application layer defines every mock-able external dependency as an interface. Implementations live in
|
|
Infrastructure and are chosen by **registration**, never by a branch in a handler.
|
|
|
|
| Contracts folder | Seams |
|
|
| --- | --- |
|
|
| `Contracts/Common/` | `IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`, `INotificationDispatcher`, `IGeocoder`, `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`, `ILicenseVerificationService`, `IBankAccountOwnershipVerifier`, `IVariantSnapshotSerializer`, `IPaymentCaptureSimulator`, `ISmsSender`, `ICurrentUser` |
|
|
| `Contracts/Payments/` | `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock`, `IBnplProvider`, `IBnplProviderResolver`, `ICurrencyNormalizer`, `IBankTransferProvider`, `IMoadianClient`, `INursePayoutStatus` |
|
|
| `Contracts/Search/` | `INurseSearch` (read), `ISearchIndexMaintainer` (write) |
|
|
| `Contracts/Reviews/` | `IReviewModerationService` (the AI pre-screen) |
|
|
| `Contracts/Persistence/` | The per-domain repositories, all exposed on `IUnitOfWork` |
|
|
| Platform facades | `IPlatformConfig`, `IHolidayCalendar`, `IAnalyticsSink`, `IAuditLogger`, `INotificationService`, `ISupportAlertService` |
|
|
|
|
### Where each implementation lives
|
|
|
|
| Kind | Location | Registered by |
|
|
| --- | --- | --- |
|
|
| Mocks | `CrossCutting/Seams/` | `AddCrossCuttingSeams(configuration)` — config section `Seams` |
|
|
| Real vendor adapters | `CrossCutting/Seams/Real/` | the same, selected per rail |
|
|
| Platform facades (DB-backed) | `Persistence/Services/` | `AddPersistenceServices` — **not** CrossCutting, because they are DB-backed |
|
|
| `ICurrentUser` | `Infrastructure.Identity` | `RegisterIdentityServices` |
|
|
|
|
Audit fields are stamped by `AuditFieldInterceptor` (Persistence), never in a handler.
|
|
|
|
### Real rails are config-selected, and the default falls closed
|
|
|
|
Every vendor rail has a real HTTP adapter, selected by a per-rail **`Seams:*:Provider`** key in
|
|
`AddCrossCuttingSeams`. **The default is the mock, and a typo falls closed to the mock** — so an unconfigured
|
|
environment behaves exactly as before, and a misconfigured one does not silently reach a live vendor.
|
|
|
|
Real adapters use `HttpClient` (typed via `IHttpClientFactory`), `System.Text.Json`, and BCL crypto —
|
|
**no new NuGet packages**. Credentials come from `Seams:*`.
|
|
|
|
| Rail | Selector | Adapter |
|
|
| --- | --- | --- |
|
|
| SMS/OTP | `Sms:Provider=kavenegar` | `KavenegarSmsSender` — **launch-critical** |
|
|
| SMS/OTP (demo) | `Sms:Provider=telegram` | `TelegramSmsSender` — a **broadcast, not a gateway** |
|
|
| Shahkar / KYC / IBAN ownership | `{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech` | `Finnotech*`, shared `Seams:Finnotech` creds |
|
|
| Geocoding | `Geocoding:Provider=neshan` | `NeshanGeocoder` |
|
|
| Object storage | `ObjectStorage:Provider=s3` | `S3ObjectStorage` — MinIO/S3/ArvanCloud via manual AWS SigV4; presigned GET is the real signed-URL contract |
|
|
| PSP | `Payments:Provider=zarinpal` | `ZarinPalPaymentProvider` + `HmacWebhookVerifier` + `ProviderSettlementSplitProvider` |
|
|
| BNPL | `Bnpl:Provider=real` | `SnappPayBnplProvider` / `DigipayBnplProvider` + `ConfiguredBnplProviderResolver`. **`balinyaar` = in-house, resolving to the net-of-fee model with no external API** |
|
|
| Bank transfer | `BankTransfer:Provider=jibit` | `JibitBankTransferProvider` — an **async rail**: it accepts as `submitted`, and the HMAC-verified reconciliation callback `POST webhooks/payouts/{provider}` flips `submitted → paid/failed` |
|
|
| e-invoicing | `Moadian:Provider=moadian` | `MoadianClient` + a 6-hour `MoadianReconciliationJob` walking `pending/submitted → registered` |
|
|
|
|
Three deliberate exceptions:
|
|
|
|
- **`IPaymentCaptureSimulator` is out of the production registration.** Production gets the fail-closed
|
|
`DisabledPaymentCaptureSimulator`; Dev and Testing re-register the succeeding mock. The `bookings/convert`
|
|
path is a Dev/Testing affordance — production converts through the b10 webhook confirm.
|
|
- **`ICredentialVerifier` / `ILicenseVerificationService` stay mock**, because **manual MoH / INO / eNamad
|
|
review is the intended MVP** — there is no public B2B API. Don't "finish" them.
|
|
- **There is no telephony/VoIP seam.** The emergency call is an out-of-platform `tel:` link by design.
|
|
|
|
`ICurrencyNormalizer` is already config-driven with a real implementation.
|
|
|
|
---
|
|
|
|
## 4. Startup wiring
|
|
|
|
Service registration is composed from per-layer extension methods, each in that project's
|
|
`ServiceConfiguration/` folder. **`Program.cs` is an orchestrator: extension-method calls only, no logic and
|
|
no inline registration.**
|
|
|
|
```
|
|
builder.ValidateRequiredSecrets() // fail fast on a missing/placeholder DB or crypto secret
|
|
ConfigureHealthChecks() · SetupOpenTelemetry()
|
|
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
|
|
RegisterIdentityServices(…, requireHttpsMetadata)
|
|
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories,
|
|
// the IRecurringJob crons + RecurringJobSchedulerHostedService
|
|
AddCrossCuttingSeams(config)
|
|
AddWebFrameworkServices() // API versioning + snake_case routing
|
|
AddCorsPolicies(config) // from Cors:AllowedOrigins
|
|
AddForwardedHeadersConfiguration(config) // trust ForwardedHeaders:KnownProxies/KnownNetworks
|
|
AddRateLimitingPolicies() // per-resolved-IP global + named (otp/auth/sensitive/webhook)
|
|
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
|
|
ConfigureGrpcPluginServices(builder.Environment) // gRPC reflection: Development only
|
|
// Development-only: AddDevelopmentOtpCapture() decorates ISmsSender to capture each OTP in memory for
|
|
// GET /api/v1/dev/last_otp/{phone}. Never wired outside Development, and only for a capture-safe
|
|
// Seams:Sms:Provider (mock/unset, or the Development-only telegram relay). Kavenegar disables it.
|
|
```
|
|
|
|
**When you add infrastructure, expose it as an extension method and call it from `Program.cs`.**
|
|
|
|
### Middleware order, and why each position matters
|
|
|
|
```
|
|
forwarded headers → exception handler → Swagger → routing → CORS → rate limiter
|
|
→ authentication → authorization → controllers → metrics → health checks → gRPC
|
|
```
|
|
|
|
- **`UseForwardedHeaders()` is first**, so the resolved client IP (`X-Forwarded-For` from a trusted proxy) is
|
|
in place before the rate limiter partitions on it. Behind a proxy without it, the limiter sees one IP and
|
|
throttles everyone together.
|
|
- **`UseCors(...)` sits after `UseRouting()` and before `UseRateLimiter()`**, so a pre-flight `OPTIONS` is
|
|
answered before the limiter and auth run.
|
|
- **`UseRateLimiter()` is before `UseAuthentication()`**, so over-limit auth and OTP attempts are rejected
|
|
with 429 before hitting the auth stack.
|
|
|
|
### Fail-fast on secrets
|
|
|
|
`StartupSecretsGuard` (via `ValidateRequiredSecrets()`) refuses to start if a load-bearing secret is missing
|
|
or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder: the DB connection strings always, plus the
|
|
JWE and field-encryption keys in deployed environments.
|
|
|
|
> The placeholder's *name* is stale — `dotnet user-secrets` is **not used** and the `<UserSecretsId>` was
|
|
> removed, so that store is never read. The behaviour is correct; the string is a legacy name. See
|
|
> [code-quality.md](../shared/code-quality.md) §6 for where config actually lives.
|
|
|
|
---
|
|
|
|
## 5. Observability and health
|
|
|
|
One **OpenTelemetry** stack (`Baya.Infrastructure.Monitoring`, `SetupOpenTelemetry`):
|
|
|
|
- **Metrics** — runtime + ASP.NET Core + the `mediator_meter` histogram, scraped at `/metrics` via the OTel
|
|
Prometheus exporter. (The duplicate prometheus-net stack was removed.)
|
|
- **Tracing** — ASP.NET Core + EF Core, sharing `service.name = Baya.Web.Api`.
|
|
- **OTLP export (traces + metrics) is opt-in** — wired only when `OpenTelemetry:Otlp:Endpoint` is set, so an
|
|
MVP running Prometheus alone is unchanged.
|
|
- **`ApiResult.RequestId` IS the W3C trace id** (`Activity.Current.TraceId`, `Activity.DefaultIdFormat = W3C`),
|
|
so a support ticket maps 1:1 to a trace. Don't replace it with a random correlation id.
|
|
|
|
**Health checks are split**: `/healthz/live` (process only, dependency-free — for a liveness probe),
|
|
`/healthz/ready` (app DB + `logDb` in deployed environments + an `IObjectStorage` write probe), and
|
|
`/HealthCheck` (the aggregate, kept for compatibility).
|
|
|
|
**Logs**: deployed environments write Information+ to `Baya_Logs`, with framework categories held at Warning.
|
|
**No PII, no secrets** — the mock SMS sender never logs the OTP code, and clinical text and IBANs are
|
|
encrypted or masked. Set the OTLP collector to ship logs off-box; the SQL sink is the deployed default.
|
|
|
|
**gRPC reflection is Development-only** (`GrpcPluginStartup` gates it on `IsDevelopment`); the plugin shares
|
|
the mixed-protocol Kestrel listener.
|