Skip to content

SaaS Domain Implementation Patterns

Wave-2 domain implementation patterns shared across all five ConnectSoft.Saas.<Context>Template bounded-context repositories. Use this guide when adding validators, business metrics, structured logging, or gRPC rich-error handling in DomainModel.Impl.

Reference implementation: ConnectSoft.Saas.EntitlementsTemplateDefaultEntitlementsProcessor, DefaultEntitlementsRetriever, EntitlementsMetrics, and owned GrpcRichErrorInterceptor.

Per-repo inventories (validator filenames, instrumented operations, test commands) live in each template's docs/domain-implementation.md.

Repositories covered

Repository Aggregate root Metrics class Meter name Validators
ConnectSoft.Saas.BillingTemplate Subscription BillingMetrics ConnectSoft.Saas.Billing 7
ConnectSoft.Saas.MeteringTemplate UsageMeter MeteringMetrics ConnectSoft.Saas.Metering 8
ConnectSoft.Saas.TenantsTemplate Tenant TenantsMetrics ConnectSoft.Saas.Tenants 8
ConnectSoft.Saas.EntitlementsTemplate Entitlement EntitlementsMetrics ConnectSoft.Saas.Entitlements 7
ConnectSoft.Saas.ProductsCatalogTemplate Product ProductsCatalogMetrics ConnectSoft.Saas.ProductsCatalog 9

FluentValidation

Location and shape

  • Validators live in src/ConnectSoft.Saas.<Context>.DomainModel.Impl/Validators/.
  • One public validator class per file, with XML documentation on the class and rules.
  • Validators are 1:1 with processor/retriever input types (e.g. CreateEntitlementDraftInputCreateEntitlementDraftInputValidator).

Processor / retriever usage

Processors and retrievers inject IValidator<TInput> and call ValidateAndThrowAsync before any domain work:

await this.createEntitlementDraftInputValidator.ValidateAndThrowAsync(input, token).ConfigureAwait(false);

Validation failures surface as FluentValidation.ValidationException, which the owned gRPC interceptor maps to rich INVALID_ARGUMENT errors (see below).

Registration

In <Context>MicroserviceRegistration, register validators via assembly scan using a marker type:

services.AddConnectSoftFluentValidation<CreateEntitlementDraftInputValidator>(validationOptions);

validationOptions comes from AddConnectSoftFluentValidationOptions in OptionsExtensions.

Unit tests

  • One *ValidatorUnitTests.cs per validator under tests/ConnectSoft.Saas.<Context>.UnitTests/DomainModel/Validators/.
  • Use FluentValidation's TestValidate helper; cover valid inputs and representative rule failures.
dotnet test tests/ConnectSoft.Saas.Entitlements.UnitTests/ConnectSoft.Saas.Entitlements.UnitTests.csproj --filter "FullyQualifiedName~Validator"

Domain metrics

Location and naming

  • <Context>Metrics class in src/ConnectSoft.Saas.<Context>.Metrics/.
  • Meter name: ConnectSoft.Saas.<Context> (constant <Context>MetricsMeterName).
  • Instrument naming convention:
  • Counters: {meter}.{operation}.{succeeded|failed}
  • Histograms: {meter}.{operation}.duration (seconds)
  • Optional up/down counters: {meter}.{aggregate}.total

Example (Entitlements):

ConnectSoft.Saas.Entitlements.entitlement.draft.created
ConnectSoft.Saas.Entitlements.entitlement.draft.create.failed
ConnectSoft.Saas.Entitlements.entitlement.draft.create.duration

Tags typically include module, component, aggregate, and tenant_id when known.

Registration

Override RegisterTemplateMetrics in <Context>MicroserviceRegistration:

protected override void RegisterTemplateMetrics(IServiceCollection services)
{
    services.AddSingleton<EntitlementsMetrics>();
    services.ActivateSingleton<EntitlementsMetrics>();
}

Wiring in domain handlers

Inject <Context>Metrics into Default*Processor and Default*Retriever. For each public operation:

  1. Start a Stopwatch after validation.
  2. On success: record duration + succeeded counter (and up/down counter when applicable).
  3. On catch: record failed counter, then rethrow.

See DefaultEntitlementsProcessor.CreateDraftAsync in Entitlements for the canonical pattern.

Unit tests

Tests live in tests/.../Metrics/*MetricsUnitTests.cs. They use MetricCollector<TMetrics> and Microsoft.Extensions.Diagnostics.Testing to assert instrument names and tag dimensions without a live OTel pipeline.

dotnet test tests/ConnectSoft.Saas.Entitlements.UnitTests/ConnectSoft.Saas.Entitlements.UnitTests.csproj --filter "FullyQualifiedName~Metrics"

Wave-2 totals: 34 metrics unit tests across the five repos (Entitlements 11, Billing 6, Metering 6, Tenants 5, Products Catalog 6).


Processor / retriever logging

Pattern from DefaultEntitlementsProcessor and DefaultEntitlementsRetriever (replicated in all five repos):

  1. using (logger.BeginScopeWithFlow(...)) — scope name is ClassName/MethodName.
  2. logger.Here().LogInformation at operation start (with key identifiers).
  3. try / catch — on failure: LogError with exception + Record*Failed metrics; rethrow.
  4. On success: domain log line + Record*Succeeded metrics + completion LogInformation.
using (this.logger.BeginScopeWithFlow(nameof(DefaultEntitlementsProcessor) + "/" + nameof(this.CreateDraftAsync)))
{
    try
    {
        this.logger.Here(log => log.LogInformation("Create entitlement draft for tenant {TenantId} started...", tenantId));
        await this.validator.ValidateAndThrowAsync(input, token).ConfigureAwait(false);
        // ... domain work ...
        this.metrics.RecordDraftCreated(sw.Elapsed, input.TenantId);
        this.logger.Here(log => log.LogInformation("Create entitlement draft for tenant {TenantId} successfully completed...", input.TenantId));
    }
    catch (Exception ex)
    {
        this.metrics.RecordDraftCreateFailed(tenantId);
        this.logger.Here(log => log.LogError(ex, "Failed to create entitlement draft for tenant {TenantId}", tenantId));
        throw;
    }
}

Apply the same structure to every method on Default*Processor and Default*Retriever.


gRPC rich errors

Each SaaS template owns a context-specific GrpcRichErrorInterceptor in src/ConnectSoft.Saas.<Context>.ApplicationModel/. This is not the generic base-template interceptor — domain exception types and ErrorInfo.Reason codes are per bounded context.

Activation

Active when GrpcErrorHandlingStrategy == RichError in microservice options. Registered alongside gRPC hosting in ApplicationModel.

Exception mapping

The interceptor catches unhandled exceptions from gRPC unary handlers and maps them to Google.Rpc.Status rich errors embedded in RpcException trailers:

Source gRPC Code Notes
ValidationException (FluentValidation) InvalidArgument Field-level BadRequest details
Domain not-found exceptions NotFound Context-specific ErrorInfo.Reason
Conflict / already-exists exceptions AlreadyExists
Tenant mismatch / permission PermissionDenied
Status transition / precondition failures FailedPrecondition
NHibernate / persistence failures Internal or Unavailable Mapped per exception type
ArgumentException / null arguments InvalidArgument

See GrpcRichErrorInterceptor.CreateRpcException in Entitlements for the full mapping table.


PowerShell test quick reference

Run from the template repo root (examples use Entitlements; substitute project name per repo):

# All validator unit tests
dotnet test tests/ConnectSoft.Saas.Entitlements.UnitTests/ConnectSoft.Saas.Entitlements.UnitTests.csproj --filter "FullyQualifiedName~Validator"

# All domain metrics unit tests
dotnet test tests/ConnectSoft.Saas.Entitlements.UnitTests/ConnectSoft.Saas.Entitlements.UnitTests.csproj --filter "FullyQualifiedName~Metrics"

# Both filters in one run
dotnet test tests/ConnectSoft.Saas.Entitlements.UnitTests/ConnectSoft.Saas.Entitlements.UnitTests.csproj --filter "FullyQualifiedName~Validator|FullyQualifiedName~Metrics"
Repo Unit test project
Billing tests/ConnectSoft.Saas.Billing.UnitTests/ConnectSoft.Saas.Billing.UnitTests.csproj
Metering tests/ConnectSoft.Saas.Metering.UnitTests/ConnectSoft.Saas.Metering.UnitTests.csproj
Tenants tests/ConnectSoft.Saas.Tenants.UnitTests/ConnectSoft.Saas.Tenants.UnitTests.csproj
Entitlements tests/ConnectSoft.Saas.Entitlements.UnitTests/ConnectSoft.Saas.Entitlements.UnitTests.csproj
Products Catalog tests/ConnectSoft.Saas.ProductsCatalog.UnitTests/ConnectSoft.Saas.ProductsCatalog.UnitTests.csproj