Files
baya-monorepo/archive/docs/rules/server/structure.md
T
2026-08-02 20:01:31 +03:30

13 KiB

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 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/ AddPersistenceServicesnot 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 KavenegarSmsSenderlaunch-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 §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.