Skip to content

L1 Proxy Pattern

An L1 proxy is the thinnest wrapper around an external service’s transport surface — REST, gRPC, or vendor SDK. It owns wire-format concerns (serialization, authentication headers, retry of pure-transport errors) and exposes typed outcomes that callers can interpret without catching exceptions. L1 proxies are business-blind: they never import the calling module’s business package and never know what a successful API response means in domain terms.

The L1/L2 split lets the service layer (L2) own all business logic — validation, sequencing, state transitions — while the proxy stays a transport adapter that can be swapped, mocked, or replaced with a different vendor without touching business code.

A proxy method returns a sealed hierarchy (or enum) of named outcomes — never a raw response object, never a Result<HttpResponse>, never a throw.

sealed interface CreateSignatureOutcome {
data class Created(val signatureId: PostmarkSignatureId, val dns: SignatureDnsRecords) : CreateSignatureOutcome
data object AlreadyExists : CreateSignatureOutcome
data class Rejected(val reason: String) : CreateSignatureOutcome
data class TransportFailure(val cause: AppError.ExternalService) : CreateSignatureOutcome
}
interface PostmarkApiProxy {
suspend fun createSignature(request: SignatureRequest): CreateSignatureOutcome
}

Every outcome that the caller must distinguish in code becomes its own variant. Outcomes that share caller-side handling collapse into one variant carrying the discriminating fields (e.g., Rejected(reason)).

When a proxy method catches a Throwable (HTTP transport failure, JSON parse failure, etc.) and wraps it in AppError.ExternalService(msg, code, description), the current common-module constructor does not accept a cause parameter. Add a comment at the throw site as audit trail:

// TODO(PDEV-767): pass cause = e once AppError.ExternalService accepts cause
AppError.ExternalService(msg, code, description)

Other AppError variants do accept cause — pass cause = e when constructing Infrastructure, IncompatibleState, or InternalService from a caught exception:

} catch (e: IOException) {
AppError.Infrastructure("Connection to Postmark failed", cause = e)
}

This ensures the original stack trace is preserved through the AppError chain and visible in logs.

The proxy returns the outcome verbatim. It does not decide whether AlreadyExists is success or failure, whether to retry, or whether to escalate. Those decisions are business-shaped and belong to L2.

// L2 service layer
suspend fun provisionSignature(req: SignatureRequest): Result<SignatureProfile> =
when (val outcome = proxy.createSignature(req)) {
is Created -> Result.success(SignatureProfile.from(outcome))
is AlreadyExists -> reprovisionExistingSignature(req) // business decision
is Rejected -> Result.failure(AppError.ContextValidation(outcome.reason))
is TransportFailure -> Result.failure(outcome.cause)
}

Exhaustive when over the sealed outcome makes the caller-side interpretation total — adding a new variant fails compilation everywhere a decision is owed.

A proxy lives in servers/<vendor>/<area>/ and imports only:

  • kotlin.*, kotlinx.* (stdlib, coroutines, serialization).
  • cards.arda.common.lib.* (shared infra: AppError, Result extensions, HTTP client wrappers).
  • cards.arda.<component>.common.lib.util.* (component-local staging types — DnsRecord, TokenCipherEnvelope, EmailAddress).
  • Its own servers/<vendor>/shared/ package (vendor-shaped value classes: PostmarkServerId, SignatureDnsRecords).

It must not import:

  • The calling module’s business/ package (no EmailConfiguration, no OrderHeader).
  • The calling module’s service/ or persistence/ packages.
  • Other modules’ business types.

The check is mechanical: grep the proxy package for imports of any sibling business/service/persistence package. A clean result is the proof that the proxy is genuinely an L1 transport adapter.

ConcernLives in proxy?
Wire serialization (JSON, Protobuf)Yes
Authentication headers, signingYes
Idempotency keys (transport-level)Yes — when the protocol demands them
Vendor SDK constructionYes — closure over a shared HTTP client / SDK handle
Transport-error classification (5xx, network) into TransportFailure variantsYes
Mapping vendor’s 409 Conflict to AlreadyExistsYes — that’s a wire-shape decision
Whether AlreadyExists is OK, an error, or triggers reprovisionNo — L2 decides
Retries with backoffOnly for pure-transport errors (timeouts, 5xx). Domain-level retries belong to L2.
Cache lookup, conditional fetchNo — that’s a service-level concern

Because outcomes are a sealed hierarchy, a proxy stub for unit tests is trivial:

class PostmarkProxyStub(
val createSignatureOutcome: CreateSignatureOutcome = AlreadyExists,
val getSignatureOutcome: GetSignatureOutcome = GetSignatureOutcome.NotFound,
) : PostmarkApiProxy {
override suspend fun createSignature(req: SignatureRequest) = createSignatureOutcome
override suspend fun getSignature(id: PostmarkSignatureId) = getSignatureOutcome
}

The stub is exhaustive by construction: every method returns a literal outcome from the sealed hierarchy. There is no risk of an “uncovered” code path, and no need for coVerify(exactly = 0) { ... } assertions to prove a method was not called — the alternative-branch logic in the service layer is what proves it.

AntipatternWhat goes wrongFix
Returning Result<VendorResponse> from the proxyCaller has to inspect HTTP status codes or vendor JSON to decide outcomeMap to a sealed *Outcome in the proxy
Throwing on 404, 409, 429Forces every caller into runCatching; loses outcome typingReturn a typed variant (NotFound, AlreadyExists, RateLimited)
Letting the proxy decide that AlreadyExists means successBusiness decisions move into the transport layer; L2 cannot overrideProxy returns the variant; L2’s when decides
Importing a business enum to “make outcomes meaningful”Couples vendor wire shape to business state; vendor swap requires business editsOutcomes use vendor-shaped or neutral types; L2 maps them to business state
Wrapping the proxy in another “validating proxy” layerMixes business validation into the transport stackValidation belongs in L2; if a method is hard to call correctly, redesign its signature
  • cards.arda.operations.shopaccess.email.servers.postmark.signature.PostmarkSignatureProxyCreateSignatureOutcome, GetSignatureOutcome, DeleteSignatureOutcome sealed hierarchies; Impl constructed with HttpClient and accountToken only.
  • cards.arda.operations.shopaccess.email.servers.postmark.server.PostmarkServerProxy — same pattern for Postmark Server resources.
  • cards.arda.operations.shopaccess.email.servers.postmark.webhook.PostmarkWebhookProxy — same pattern; outcomes include Created, AlreadyExists, NotFound.