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.
Three rules
Section titled “Three rules”1. Declared outcome types
Section titled “1. Declared outcome types”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)).
Cause preservation
Section titled “Cause preservation”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 causeAppError.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.
2. Caller-side result interpretation
Section titled “2. Caller-side result interpretation”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 layersuspend 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.
3. No business-package dependencies
Section titled “3. No business-package dependencies”A proxy lives in servers/<vendor>/<area>/ and imports only:
kotlin.*,kotlinx.*(stdlib, coroutines, serialization).cards.arda.common.lib.*(shared infra:AppError,Resultextensions, 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 (noEmailConfiguration, noOrderHeader). - The calling module’s
service/orpersistence/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.
What a proxy does own
Section titled “What a proxy does own”| Concern | Lives in proxy? |
|---|---|
| Wire serialization (JSON, Protobuf) | Yes |
| Authentication headers, signing | Yes |
| Idempotency keys (transport-level) | Yes — when the protocol demands them |
| Vendor SDK construction | Yes — closure over a shared HTTP client / SDK handle |
Transport-error classification (5xx, network) into TransportFailure variants | Yes |
Mapping vendor’s 409 Conflict to AlreadyExists | Yes — that’s a wire-shape decision |
Whether AlreadyExists is OK, an error, or triggers reprovision | No — L2 decides |
| Retries with backoff | Only for pure-transport errors (timeouts, 5xx). Domain-level retries belong to L2. |
| Cache lookup, conditional fetch | No — that’s a service-level concern |
Stub doubles for testing
Section titled “Stub doubles for testing”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.
Common antipatterns
Section titled “Common antipatterns”| Antipattern | What goes wrong | Fix |
|---|---|---|
Returning Result<VendorResponse> from the proxy | Caller has to inspect HTTP status codes or vendor JSON to decide outcome | Map to a sealed *Outcome in the proxy |
Throwing on 404, 409, 429 | Forces every caller into runCatching; loses outcome typing | Return a typed variant (NotFound, AlreadyExists, RateLimited) |
Letting the proxy decide that AlreadyExists means success | Business decisions move into the transport layer; L2 cannot override | Proxy 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 edits | Outcomes use vendor-shaped or neutral types; L2 maps them to business state |
| Wrapping the proxy in another “validating proxy” layer | Mixes business validation into the transport stack | Validation belongs in L2; if a method is hard to call correctly, redesign its signature |
Worked references
Section titled “Worked references”cards.arda.operations.shopaccess.email.servers.postmark.signature.PostmarkSignatureProxy—CreateSignatureOutcome,GetSignatureOutcome,DeleteSignatureOutcomesealed hierarchies;Implconstructed withHttpClientandaccountTokenonly.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 includeCreated,AlreadyExists,NotFound.
Related pages
Section titled “Related pages”- Data Authority Module Pattern — the four-layer module structure into which L1 proxies fit at the bottom.
- Exception Handling —
AppErrorhierarchy thatTransportFailureoutcomes carry. - Functional Programming — Railway-Oriented Programming and
Resultchains used by L2. - DAG Package Discipline — the package-layering rule that keeps proxies business-blind.
Copyright: © Arda Systems 2025-2026, All rights reserved