13 KiB
Server structure
The layers, the projects, startup wiring, and the seam catalogue.
Last verified: 2026-07-30 against commit
d3ec723— 14.csprojprojects, 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) andBookings(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 typeBookingis aliased where the two namespaces collide. The same split is load-bearing in the client'sbookingRequests/bookingsdomains 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 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:
IPaymentCaptureSimulatoris out of the production registration. Production gets the fail-closedDisabledPaymentCaptureSimulator; Dev and Testing re-register the succeeding mock. Thebookings/convertpath is a Dev/Testing affordance — production converts through the b10 webhook confirm.ICredentialVerifier/ILicenseVerificationServicestay 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-Forfrom 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 afterUseRouting()and beforeUseRateLimiter(), so a pre-flightOPTIONSis answered before the limiter and auth run.UseRateLimiter()is beforeUseAuthentication(), 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-secretsis 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 §6 for where config actually lives.
5. Observability and health
One OpenTelemetry stack (Baya.Infrastructure.Monitoring, SetupOpenTelemetry):
- Metrics — runtime + ASP.NET Core + the
mediator_meterhistogram, scraped at/metricsvia 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:Endpointis set, so an MVP running Prometheus alone is unchanged. ApiResult.RequestIdIS 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.