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