Skip to content
Back to skills

Hex Capability Adapter

ASecurity

Use when implementing an `ICan<Verb>` capability against a real external system — the adapter under `infrastructure/<technology>/` wrapping an SDK client, an HTTP gateway or a pure-CPU library, translating the library's own errors into `exception-catalog` classes. Not aggregate CRUD — that is `hex-persistence` or `hex-store-repository`; the protocol is `hex-domain-ports`.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 14, 2026
developmentpythontestingapibackend

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 14, 2026

npx -y skills add wntic/pyhouse --skill hex-capability-adapter --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Hex Capability Adapter?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Hex Capability Adapter
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/wntic-hex-capability-adapter/badge)](https://www.skillsdirectory.com/skills/wntic-hex-capability-adapter)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: hex-capability-adapter
description: Use when implementing an `ICan<Verb>` capability against a real external system — the adapter under `infrastructure/<technology>/` wrapping an SDK client, an HTTP gateway or a pure-CPU library, translating the library's own errors into `exception-catalog` classes. Not aggregate CRUD — that is `hex-persistence` or `hex-store-repository`; the protocol is `hex-domain-ports`.
paths: ["**/infrastructure/**"]
---

# Hex — Capability Adapter (infrastructure)

Produces one adapter class that adapts a domain `ICan<Verb>` capability protocol to a concrete external
system. It is the last place the external library's own exceptions exist: translation happens here,
into the classes `exception-catalog` owns.

## When to use vs. neighbours

- Aggregate-root CRUD over a relational store → `hex-persistence`, not this skill.
- Aggregate-root persistence on a key-value or document store, or an index kept beside it → `hex-store-repository`; an injected SDK client alone does not make something a capability.
- The `ICan<Verb>` protocol file this adapter satisfies → `hex-domain-ports`.
- The obligations the adapter's settings class must meet → `python-settings`, its name and prefix → `naming`; the HTTP gateway's own is shown here, beside it.
- The lifetime and declaration-order rules the adapter's binding follows, and the base composition root it merges into → `hex-wiring`; the binding itself is shown here, beside the adapter.
- The catalogue exception classes the SDK's own errors are translated into → `exception-catalog`.
- The undo a compensating handler calls on this adapter (`delete` beside `upload`) → an ordinary method that raises on failure, declared on a port by `hex-domain-ports`; the handler-side guard that tolerates its failure is `hex-application`'s (Compensation).
- An in-memory test stand-in for this capability (the `Fake<Capability>` flavor) → `hex-test-application-handler`.
- Testing the real adapter — containerized backend, HTTP over a stub transport, or pure CPU → `hex-test-capability-adapter`.
- A token verifier — its port, its adapter and the dependency that resolves it → `hex-restapi-auth`; it is a pure-CPU adapter in this skill's sense, bound there.
- The `infrastructure/<technology>/` folder, the module name and the class name → `hex-conventions` and `naming`.

## Template — httpx2, pydantic-settings, dishka

### Template — async HTTP gateway (httpx2)

In `infrastructure/http/http_foo_classifier.py`, with its settings class beside it:

```python
import httpx2

from myapp.domain.exceptions import (
    UpstreamError,
    ValidationError,  # only where the upstream judges input the caller can correct
)
from myapp.domain.foos import Foo, FooKind

from .foo_classifier_settings import FooClassifierSettings

__all__ = ["HttpFooClassifier"]


class HttpFooClassifier:
    def __init__(self, client: httpx2.AsyncClient, settings: FooClassifierSettings) -> None:
        self._client = client
        self._base_url = settings.base_url
        self._api_key = settings.api_key.get_secret_value()  # only where the upstream takes a credential

    async def classify(self, foo: Foo) -> FooKind:
        foo_id = str(foo.id)
        try:
            response = await self._client.post(
                f"{self._base_url}/classifications",
                json={"name": foo.name},
                headers={"Authorization": f"Bearer {self._api_key}"},  # only where the upstream takes a credential
            )
            response.raise_for_status()
        except httpx2.HTTPStatusError as exc:
            status = exc.response.status_code
            # only where the upstream judges input the caller can correct
            if status == 400:
                raise ValidationError("foo classifier rejected foo", {"foo_id": foo_id, "status": status}) from exc
            raise UpstreamError("foo classifier failed", {"foo_id": foo_id, "status": status}) from exc
        except httpx2.HTTPError as exc:
            raise UpstreamError("foo classifier unreachable", {"foo_id": foo_id}) from exc
        try:
            return FooKind(response.json()["kind"])
        except (KeyError, TypeError, ValueError) as exc:
            raise UpstreamError("foo classifier returned a malformed body", {"foo_id": foo_id}) from exc
```

Where the call returns a value, a `200` is not a result until its body has been read, and whatever the
conversion to the domain type raises is the upstream's fault: a body that is not JSON, a missing field, a
value the domain type refuses — an enum's `ValueError` here, a value object's own `ValidationError`, a
decimal's `InvalidOperation` — arrives as `UpstreamError` from a translated scope of its own (rule 8). A
call that returns nothing reads no body.

A call the service makes on its own behalf — a notification — maps every status to the fallback, as
rule 9 does the adapter's own credential.

Its settings class, in `infrastructure/http/foo_classifier_settings.py` beside it — the module named for
the class (`python-packaging`). The adapter reads the base URL and, where the upstream takes one, the
credential; the composition root reads the timeout when it builds this integration's own
`httpx2.AsyncClient` (`hex-wiring`).

```python
from pydantic import SecretStr  # only where the upstream takes a credential
from pydantic_settings import BaseSettings, SettingsConfigDict

__all__ = ["FooClassifierSettings"]


class FooClassifierSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="MYAPP_FOO_CLASSIFIER_",
        env_file=".env",  # only where the project keeps a dotenv file for development
        extra="ignore",
        hide_input_in_errors=True,
    )

    base_url: str
    api_key: SecretStr  # only where the upstream takes a credential
    timeout_seconds: float
```

**A timeout is a per-integration decision.** It is set from the integration's own observed latency plus
headroom, and bounded above by what the caller can wait for — a request-path adapter whose timeout
exceeds the app's own request timeout can never fire usefully. What the template does fix is that the
timeout is a **settings field**, read once by the composition root and injected — never a constant
hardcoded inside the adapter. It is a tunable with no single right value, so it carries no default
(`python-settings` rule 5).

A pure-CPU adapter over a third-party library — a parser, a renderer, a verifier — has no client to
inject, and settings only where it has something to configure; it still translates the library's own
failure into a catalogue exception and returns a domain type. Work the standard library can do is domain
logic, not an adapter (`hex-domain-ports`).

### The composition-root binding — dishka

The adapter's binding is an add-on to `hex-wiring`'s base composition root, merged as `CONTAINER.md`
says. It is process-lifetime — an adapter keeps no state across calls (rule 12).

The HTTP gateway: the settings provider, and one factory that builds this integration's own client — the
timeout is read here, where the client is built — hands it to the adapter, binds the adapter to its port
by its return type and closes the client after its yield. A second integration adds a factory of its
own, so no integration's timeout or retry policy reaches another's calls.

```python
from collections.abc import AsyncIterator

import httpx2
from dishka import Provider, Scope, provide

from myapp.domain.foos import ICanClassifyFoos
from myapp.infrastructure.http import FooClassifierSettings, HttpFooClassifier


class SettingsProvider(Provider):
    scope = Scope.APP

    @provide
    def foo_classifier_settings(self) -> FooClassifierSettings:
        return FooClassifierSettings()


class InfrastructureProvider(Provider):
    scope = Scope.APP

    @provide
    async def foo_classifier(self, settings: FooClassifierSettings) -> AsyncIterator[ICanClassifyFoos]:
        async with httpx2.AsyncClient(timeout=settings.timeout_seconds) as client:
            yield HttpFooClassifier(client=client, settings=settings)
```

## Other bindings

- **An object store.** The bucket is read from settings, the SDK's error codes map to
  `NotFoundError`/`ValidationError` with an `UpstreamError` fallback, and one adapter may satisfy the
  store port and the fetch port together (`AnyOf`).
- **Another vendor SDK for the same capability** — a different mail provider or model API.
  The client type, its exception family and the code/status vocabulary change together; the port
  conformance, the inject-client-plus-settings constructor, the translate-before-it-escapes rule and the
  context keys are unchanged. Budget the swap as one new error map, not a new adapter shape.
- **A different async HTTP client** — `httpx`, the predecessor, with the same API under the old import,
  or `aiohttp`, stubbed in its test with `aioresponses`. The call and the exception family change; the
  status-to-catalogue mapping, the fallback and the thinness rules do not.
- **A sync-only SDK inside an async service.** The port stays async and the adapter runs the blocking
  call on a worker thread (`asyncio.to_thread`) — calling it inline blocks the event loop for every other
  request. Translation, context and no-instance-state are unchanged.

## Rules

### Form

1. **One adapter class per module**, named for the concrete technology it wraps (`HttpFooClassifier`, not
   `FooClassifier`), so two implementations of one port can coexist. Module structure is
   `python-packaging`'s; the names are `naming`'s.
2. **The adapter satisfies its protocol structurally** — it neither inherits nor imports it
   (`hex-architecture` rule 7).
3. **Method signatures match the protocol exactly** — parameter names, keyword-only markers and
   async/sync mode included. A near-match satisfies no structural check and fails at the injection site,
   not at the adapter.
4. **The module's directory is the technology it wraps** — `hex-conventions` block A's adapter token —
   never the domain concern it serves.

### Constructor

5. **The client and the settings arrive by injection, never by construction.** Both are built once by
   the composition root (`hex-wiring`) and handed in; a method that builds its own client
   (`httpx2.AsyncClient(...)`, an SDK's own client factory) defeats connection pooling and cannot be replaced in a
   test without patching the library. A client carries one upstream's timeout and whatever is set on it,
   so a second upstream gets a client of its own, built from its own settings and bound so neither
   adapter can be handed the other's — a second factory for the same client type replaces the first
   rather than joining it.
6. **Stash the fields the methods use, not the settings object** — unless several methods read several
   fields. What each method reads is then visible in the constructor; the settings object itself arrives
   whole, by type (`hex-wiring`).
7. **Where the adapter holds a secret, it is unwrapped once, in the adapter's constructor, and kept out
   of the URL** — `python-settings` rule 9.

### Exception translation

8. **The library's exception type never escapes the adapter, and neither does a bare `Exception`** — each
   is raised as a catalogue class `from` its cause (`exception-catalog` rule 8). No SDK error, HTTP status
   error or parse error crosses into `application/` or an entrypoint — the application layer catches only
   `MyappError`. Where the request does not say which external errors a method raises, derive the mapping
   from the library's documented exception family: the specific cases are judgement, the broad
   catch-and-translate fallback (rule 9) is not — no method is left able to raise an untranslated
   external exception. Nor does the upstream's vocabulary escape: the domain type's values are the
   domain's own, the adapter maps the upstream's labels onto them wherever the two differ, and a label it
   cannot map is a malformed body.
9. **Which class, the fallback, `context` and never swallowing are `exception-catalog`'s rules 9–12, 14
   and 15, unchanged.** In an adapter: `ValidationError` only when the upstream rejected the inputs as
   malformed, never when the domain type refuses what the upstream sent back; `NotFoundError` when the
   object does not exist; the fallback `UpstreamError` for the rest — a network fault, a 5xx, an unmapped
   code, the upstream refusing the adapter's own credential; `UnauthorizedError` only in an adapter
   verifying the caller's credential (`hex-restapi-auth`); a failed undo is stopped in the calling
   handler (`hex-application`, Compensation), never here.

### No business logic, no logging

10. **Adapters are thin.** No retries, no caching, no batching, no domain reasoning. Retry and backoff
    belong to the client's own policy (configured where the client is built) or to a dedicated wrapper
    class, so that the adapter stays one call wide and its failure modes stay readable.
11. **An adapter never logs.** Not the call, not the failure: the central error handler owns failure logs
    and the calling handler owns success logs, so a line here is a second entry for one event.
12. **No instance state across calls** beyond constructor-injected handles. An adapter is then safe to
    bind at process lifetime and share across concurrent requests.

### Compensating-transaction contract

13. **A capability whose write a handler compensates exposes the undo beside the forward operation** (`hex-application`, Compensation, decides which writes need one): a storage adapter has `upload` *and* `delete`; a publisher that supports retraction has `publish` *and* `retract`; a write nothing compensates has no undo. The catch-and-undo logic lives in the application handler (`hex-application`, Compensation), not in the adapter. The adapter's job is to make the undo callable; like every other method it raises a catalogue exception when it fails, and the handler decides whether that failure may be tolerated.

## Inlined typing / import rules

- Domain imports absolute (`from myapp.domain.foos import Foo, FooKind` — the domain types the signatures name). **Never import the capability protocol the adapter satisfies** (`ICanStoreFoos`, `ICanClassifyFoos`, …; rule 2). Sibling modules within the same `infrastructure/<adapter>/` package use relative imports (`from .foo_classifier_settings import FooClassifierSettings`).
- No `from __future__ import annotations`. Full annotations on every method.
- `X | None` over `Optional`. `Mapping[K, V]` / `Sequence[T]` (from `collections.abc`) for read-only views.
- **A raw SDK value typed `Any` is narrowed with `cast`, never silenced.** An SDK return that mypy sees as `Any` (an untyped client method, a body read the SDK does not type) flowing into a typed protocol return is a `[no-any-return]`/`[return-value]` error — fix it with `cast(<protocol-return-type>, …)` at the boundary, the same way `hex-restapi-auth`'s `dependency-injector` binding casts a verifier resolved by attribute name. An adapter can always restate the vendor type, so an inline ignore never meets `python-style`'s last-resort test here.
- SDK types stay inside the adapter; method signatures use domain types or primitives only.
- No `Any` except at the immediate raw-SDK-payload boundary (e.g. `payload: dict[str, Any] = response.json()` — convert to the domain type on the next line).

## Package wiring

For package wiring, see `python-packaging`; for infrastructure placement, see `hex-architecture`.

## Hard stops

- The adapter is asked to carry relational aggregate CRUD — a table, the statements against it and the
  migration that ships it → stop, that is a repository and not a capability; use `hex-persistence`, or
  `hex-store-repository` for an aggregate held in a key-value or document store.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.