# 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.
