Design Patterns / Article

Adapter: translate a cabinet SDK without leaking its contract

Connect KitDesk's reservation source to an incompatible cabinet SDK with explicit phase, timestamp, identity and error rules.

Shared case study: KitDesk community workshop pickups

KitDesk prepares pickup manifests for a community workshop. Its ordering logic expects normalized reservation rows: an ID, a kit code, readiness, and integer request and pickup times. An older cabinet SDK exposes the same underlying facts through an incompatible method and representation. This lesson builds an object Adapter inside the application, then tests the meaning of the translation rather than merely renaming fields.

The problem: useful behavior behind the wrong interface

The cabinet SDK has fetchQueue({ cabinet_code }). It resolves to an array of records with ticket_code, kit_code, phase, entered_minute and pickup_minute. Its fictional contract defines three phases: HELD, READY and COLLECTED. The new application asks a source for listReservations(), with no SDK vocabulary in the result. We cannot change the SDK, and we do not want its field names or errors spread through session code.

The competing forces are compatibility, semantic accuracy and a narrow dependency. A wrapper that simply aliases the method solves spelling but leaves the rest of the mismatch intact. A wrapper that guesses what an unknown phase means creates apparently valid, potentially wrong data. A large “cabinet manager” that also orders pickups and formats manifests hides too many responsibilities behind the boundary.

Our target interface will resolve to a new array of normalized, uncollected reservations. It rejects malformed records and unsupported phases. A valid collected record is deliberately omitted. SDK failures are preserved as causes of a source-level error. These choices are part of KitDesk's contract; another application could legitimately make different choices.

An adapter owns translation and leaves policy alone

class ReservationSourceError extends Error {
  constructor(message, cause) {
    super(message, { cause });
    this.name = "ReservationSourceError";
  }
}

function normalizeTicket(ticket) {
  if (!ticket || typeof ticket !== "object") {
    throw new TypeError("ticket must be an object");
  }
  const {
    ticket_code: id, kit_code: kit, phase,
    entered_minute: requestedAt, pickup_minute: pickupAt
  } = ticket;
  if (typeof id !== "string" || !/^R[0-9]+$/.test(id) ||
      typeof kit !== "string" || !/^[A-Z][A-Z0-9-]*$/.test(kit)) {
    throw new TypeError("invalid ticket or kit code");
  }
  if (!Number.isSafeInteger(requestedAt) || requestedAt < 0 ||
      !Number.isSafeInteger(pickupAt) || pickupAt < requestedAt) {
    throw new TypeError("invalid teaching-day minutes");
  }
  if (!["HELD", "READY", "COLLECTED"].includes(phase)) {
    throw new TypeError("unsupported cabinet phase");
  }
  if (phase === "COLLECTED") return null;
  return Object.freeze({ id, kit, requestedAt, pickupAt,
    ready: phase === "READY" });
}

class CabinetReservationAdapter {
  constructor(sdk, cabinetCode) {
    this.sdk = sdk;
    this.cabinetCode = cabinetCode;
  }

  async listReservations() {
    try {
      const tickets = await this.sdk.fetchQueue({
        cabinet_code: this.cabinetCode
      });
      if (!Array.isArray(tickets)) throw new TypeError("queue must be an array");
      const rows = tickets.map(normalizeTicket).filter(row => row !== null);
      if (new Set(rows.map(row => row.id)).size !== rows.length) {
        throw new TypeError("duplicate active ticket");
      }
      return rows;
    } catch (cause) {
      throw new ReservationSourceError("Cabinet reservations unavailable", cause);
    }
  }
}

const sdk = {
  async fetchQueue({ cabinet_code }) {
    if (cabinet_code !== "WEST") throw new Error("cabinet missing");
    return [
      { ticket_code: "R31", kit_code: "DRILL", phase: "READY",
        entered_minute: 10, pickup_minute: 90 },
      { ticket_code: "R33", kit_code: "SANDER", phase: "HELD",
        entered_minute: 5, pickup_minute: 40 }
    ];
  }
};
const source = new CabinetReservationAdapter(sdk, "WEST");
const normalized = await source.listReservations();
console.log(normalized.map(row => [row.id, row.ready]));
// [["R31", true], ["R33", false]]

The snippet runs as an ECMAScript module. The cabinet client is a deliberately small test double, not an actual vendor API. All times are teaching-day minutes, so this example does not pretend to solve time zones or dates. In a real integration, document the vendor's time representation before choosing a domain representation.

The adapter includes HELD rows with ready: false. Strategy: separate pickup ordering from session rules keeps readiness filtering in the session. That separation prevents a change of source from changing the meaning of a pickup policy. Omitting COLLECTED rows has a different purpose: those records are outside the active-reservation target contract.

Map the participants and one failure path

The client is KitDesk's load action. The target is ReservationSource, expressed by the asynchronous listReservations() convention. CabinetReservationAdapter satisfies that target and holds a reference to the adaptee, the cabinet SDK. Delegation makes this an object adapter. The client does not inherit from the SDK or call its methods.

A successful sequence is load action → source → SDK → raw tickets → normalization → normalized rows → session. The adapter does not order or select them. For an unknown phase, normalization throws, the adapter rejects with ReservationSourceError, and the load action shows a failure instead of feeding partial data into preparation. No manifest is prepared from a silently truncated response.

That all-or-nothing policy is intentional. A workshop might prefer an explicit partial result containing accepted rows and visible rejections. If so, change the target contract to represent partial success; do not quietly skip bad records while returning an ordinary array. Similarly, a transport failure must not become an empty array: “no reservations” and “could not read reservations” lead to different volunteer actions.

A standard-library illustration of interface conversion is Java's Collections.enumeration, which exposes a collection through an Enumeration for legacy APIs. Calling that an adapter is our interpretation of its documented behavior. KitDesk additionally translates values and errors because its two contracts differ in those ways.

Live model · Sequence view

Inspect the translation boundary

The KitDesk model should separate ReservationSource from the cabinet SDK. CabinetReservationAdapter translates values and errors, while PickupSession retains readiness and ordering decisions.

Follow a READY ticket into a normalized row, then follow an unsupported phase into a source error. Identify why neither branch should silently produce an empty queue. Open the public diagram ↗
Explore scenarios in this model

These links open the same public model in a selected view. They do not change its source.

Open this viewer on its own ↗ if the frame is unavailable. The explanation remains readable without the viewer.

Exploration is local to your viewer. Reload or Reset view returns to the source layout.

Trade-offs and when an adapter cannot help

An adapter adds a translation layer that can become stale when either side changes. The strict phase check makes new SDK phases visible as failures, but it can temporarily block otherwise valid work. This is a chosen safety and availability trade-off, not a universal rule. Characterization tests against authorized sample responses help expose changes before volunteers encounter them.

Do not add automatic retries to hide uncertain behavior. This read operation may be safe to retry under a documented SDK policy, but a future “collect kit” operation could have side effects. Interface conversion alone says nothing about idempotency. Keep retry ownership and error classification explicit and outside this lesson's read adapter.

Use a mapping function alone when callers already use a compatible source interface and only one value needs conversion. Use the SDK directly if its contract is already the application contract and it has one stable caller. An adapter cannot manufacture a missing pickup deadline or prove that a physical kit is present. If the source lacks facts required by the target, renegotiate the target or obtain the facts elsewhere.

The wrapper in this lesson changes the interface the client sees. Decorator: compose manifest options without a subclass explosion will preserve an existing interface while adding optional behavior. The distinction is purpose, not the mere presence of a field named inner.

Practice: test a contract mismatch, not a method name

  1. Replace READY with HELD in the sample. Expect one normalized, non-ready row, not an error. Then add an otherwise valid COLLECTED record and confirm it is absent from active results.
  2. Supply an unknown phase, duplicate active ID, reversed timestamps and a rejected SDK promise. Each must reject with a source-level error whose cause identifies the original problem. Confirm that the caller never receives partial rows.
  3. Create a tiny in-memory source with the same listReservations() contract. Feed each implementation into the same load action and session. If the session needs an SDK phase check, the adapter boundary is incomplete.

A completed exercise should explain what is converted, which information is intentionally excluded, and how failure differs from an empty queue. Continue with Factory Method: let export workflows create their encoders, or revisit the KitDesk learning path and Design Patterns hub.