Skip to content

Adapter Type Design Decisions

This document organizes the rationale for which LanguageExt IO advanced features to apply for the 4 external service scenarios defined in the technical requirements.

External Service Requirements -> IO Pattern Mapping

Section titled “External Service Requirements -> IO Pattern Mapping”
External ServiceProblem ScenarioRequired GuaranteeSelected IO Pattern
Model health checkIntermittent slow responses (>10s)Limit maximum wait time, fallback on timeoutTimeout + Catch
Model monitoringIntermittent 503 errorsAutomatic recovery from transient failures, retry interval controlRetry + Schedule
Parallel compliance5 independent checks, slow when sequentialParallel execution, collect all resultsFork + awaitAll
Model registrySession-based resource managementGuarantee session release even on exceptionsBracket

Problem: The health check service intermittently delays responses by 12 seconds or more. Waiting indefinitely slows down the entire system.

Why Timeout? When you cannot control the response time of an external service, you declaratively set the maximum wait time the system allows. LanguageExt’s Timeout imposes a time limit on IO operations, raising Errors.TimedOut.

Why Catch chaining? The timeout must be converted from an “error” to a “fallback result.” A health check timeout means the model is “not healthy,” not that there is a system error.

Catch OrderConditionResult
1ste.Is(Errors.TimedOut)TimedOut fallback result (not an error)
2nde.IsExceptionalConvert to AdapterError

Problem: The monitoring service temporarily returns 503. The first attempt fails with 60% probability, but retrying usually succeeds.

Why Retry? Transient network errors (503, timeout) are often resolved by retrying. LanguageExt’s Retry automatically retries IO operations according to a Schedule.

Schedule design:

exponential(100ms) | jitter(0.3) | recurs(3) | maxDelay(5s)
ComponentRoleValue
exponentialBase delay: 100ms -> 200ms -> 400msBased on 100ms
jitterDistribute concurrent retries (prevent thundering herd)30% variation
recursMaximum retry count3 times
maxDelayDelay upper bound5 seconds

Why this Schedule?

  • exponential: Gradually reduces server load
  • jitter: Prevents the thundering herd problem where multiple clients retry simultaneously
  • recurs(3): 3 retries recover most transient errors; beyond that, it is a permanent error
  • maxDelay(5s): Limits user wait time upper bound

3. Fork + awaitAll — Parallel Compliance Check

Section titled “3. Fork + awaitAll — Parallel Compliance Check”

Problem: Running 5 compliance criteria sequentially takes 100~500ms x 5 = up to 2.5 seconds. Each check is independent, so parallel execution is possible.

Why Fork? LanguageExt’s Fork runs IO operations in separate fibers (lightweight threads) to achieve parallelism. Since each check is independent, there are no result dependencies, making it safe to Fork.

Why awaitAll? awaitAll collects results from all Forks. Even if one check is slow, the rest are already completed, so the total elapsed time converges to the slowest check’s time.

Performance comparison:

Execution ModeWorst-case TimeExpected Time
Sequential500ms x 5 = 2,500ms~1,500ms
Parallel (Fork)max(500ms) = 500ms~350ms

Problem: Registry lookup must acquire a session, use it, and then release it. Even if an exception occurs during lookup, the session must not leak.

Why Bracket? The Bracket pattern guarantees the resource lifecycle in three stages: Acquire -> Use -> Release. Release (the Fin parameter) always executes regardless of whether the Use stage succeeds or fails. It is similar to C#‘s try-finally, but can be composed within an IO context.

Acquire: Session acquisition (50~150ms delay, 5% failure)
|
v
Use: Registry lookup (100~400ms delay, 5% failure)
|
v
Fin(Release): Session release (guaranteed regardless of success/failure)

Why Bracket instead of try-finally?

  • Can be used naturally within IO composition chains
  • Release can have IO effects (async release)
  • Transparently composable in FinT LINQ chains

Naming Conventions: {Subject}{Role}{Variant}

Section titled “Naming Conventions: {Subject}{Role}{Variant}”

Adapter layer filenames follow a 3-dimensional naming convention:

DimensionExpressed ByExample
Subject (what)Aggregate nameAIModel, Deployment, Assessment, Incident
Role (role)CQRS roleRepository, Query, DetailQuery
Variant (how)Technology suffixInMemory, EfCore, Dapper

Applied examples:

FilenameSubjectRoleVariant
AIModelRepositoryInMemory.csAIModelRepositoryInMemory
AIModelRepositoryEfCore.csAIModelRepositoryEfCore
AIModelQueryInMemory.csAIModelQueryInMemory
DeploymentDetailQueryInMemory.csDeploymentDetailQueryInMemory
UnitOfWorkInMemory.cs(common)UnitOfWorkInMemory

This convention also applies to Observable wrappers: {Subject}{Role}{Variant}Observable (e.g., AIModelRepositoryInMemoryObservable).

All external services and Repositories apply the [GenerateObservablePort] Source Generator. This attribute auto-generates an Observable class that wraps the original class, adding logging, metrics, and tracing to each method call.

IModelHealthCheckService
|
[GenerateObservablePort]
|
v
ModelHealthCheckServiceObservable (auto-generated by Source Generator)
|-- Method entry/exit logging
|-- Execution time metrics
|-- Distributed tracing spans
|
v
ModelHealthCheckService (actual implementation)
// Register Observable wrapper to interface
services.AddScoped<IModelHealthCheckService, ModelHealthCheckService>();
services.RegisterScopedObservablePort<IAIModelRepository, InMemoryAIModelRepositoryObservable>();

External services are registered directly; Repositories are registered through Observable wrappers.

In the next step, we implement this design in C# code in Code Design.