Design Patterns (GoF and modern) Module 3 – Structural patterns: Adapter to Proxy
Adapter: make incompatible interfaces fit
Wrap a class you cannot change behind the interface your code expects: object and class adapters, fakes for tests, and the adapters in standard libraries.
What you will learn
- Implement an object adapter that wraps a third-party API behind your own interface
- Compare object adapters, class adapters and language conversions such as Rust's From and Into
- Use adapters to keep external SDKs out of your tests
- Recognise adapters in standard libraries even when their documentation uses another name
- Design one interface with an implementation per platform, held together by shared test cases
Before you start
What you’ll learn · Before you start
What you will learn
- Implement an object adapter that wraps a third-party API behind your own interface
- Compare object adapters, class adapters and language conversions such as Rust's From and Into
- Use adapters to keep external SDKs out of your tests
- Recognise adapters in standard libraries even when their documentation uses another name
- Design one interface with an implementation per platform, held together by shared test cases
Before you start
On this page
A clinic’s booking app sends reminders through a text-message provider whose library wants a dictionary with the
number under the key msisdn, as 12 digits with no plus sign, and reports failures as numeric status codes. Let forty
places in the app build those dictionaries and read those codes, and a change of provider means forty edits, while
every test needs the provider’s library. Adapter puts the whole translation in one class: it implements the
interface your code expects, holds the object you cannot change, and turns each call into calls on that object.
Below, that adapter runs in 13 languages, a fake takes its place in tests, and object adapters, class adapters and conversions are compared on the same code. Then you will find the adapters already inside standard libraries and give one interface an implementation on three platforms.
An SDK whose habits would spread through your code
The app needs one operation: send(phone, text), which returns an id for the message or raises an error the app
understands. The provider’s client library, invented for this lesson but with habits that real ones share, offers
something else:
- one
dispatch()method that takes a dictionary, with the number undermsisdn(the telecom term for a full mobile number); - the number as 12 digits with the country code, no plus sign and no spaces;
- a status code in the reply, 0 for a message it accepted and another number for one it refused, never an exception.
You cannot change the library, and you should not let its habits spread. The pattern has four parts. The
client is the code that needs the operation, here remind(). The
target interface: The interface that client code calls and the application owns, such as a Notifier with send(phone, text). An adapter implements it on top of an object with another interface. is the one the client calls, Notifier, and it belongs to the
application. The adaptee: The existing object whose interface does not fit and which you cannot change, such as a provider's SDK client. An adapter holds it and translates calls into calls on it. is the class you cannot change, the provider’s
SmsGatewayClient. The adapter, SmsNotifier, implements the target interface and holds the adaptee:
An object adapter between the application and a text-message SDK
Text description of the diagram
The diagram shows four boxes in one column, joined by arrows from top to bottom.
- The application code, remind() and SignupService, calls the Notifier interface.
- Notifier is the application's own interface: send(phone, text) returns a message id or raises NotifierError. A dashed arrow says it is implemented by the adapter.
- SmsNotifier, the adapter, reshapes each call, converts the phone number to the SDK's format and maps the SDK's status codes to NotifierError.
- SmsGatewayClient, the SDK, is held by the adapter, which calls its dispatch() method. The SDK takes a dictionary and reports failures as status codes, never as exceptions.
The application code has no arrow to the SDK: only the adapter knows it.
The client has no arrow to the SDK at all. Follow both reminders of the program below through the three objects, one accepted and one refused, and watch what each side gets to see:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- The client calls Notifier. remind() calls send("+91 98765 43210", "Visit at 10:30."). It knows the Notifier interface and nothing about the provider.
- The adapter translates. The adapter keeps the digits of the number, 919876543210, and builds the dictionary the SDK wants: msisdn, body and the sender CLINIC, which was fixed when the adapter was made.
- The SDK is called its way. dispatch() receives the dictionary in the SDK's own format, built in this one class. The SDK prints the msisdn it was given.
- A status code comes back. The number has 12 digits, so the SDK accepts the message and answers with code 0 and the id GW-000001.
- The reply is mapped. Code 0 becomes an ordinary return value, the id alone. remind() prints sent to +91 98765 43210: GW-000001.
- A number that is too short. The second reminder goes to +91 98765. The client calls the same send() and cannot tell that anything is different.
- Translated the same way. The adapter keeps the 7 digits it finds, 9198765, and passes them on. Deciding which numbers are valid is the SDK's rule, not the adapter's.
- Refused with a code. The SDK refuses a number without 12 digits. It raises nothing: it answers with status code 4011.
- The code becomes an error. The adapter turns 4011 into a NotifierError in the application's words, cannot send to +91 98765: invalid number. remind() catches it and prints failed, and code 4011 never reaches it.
The client never learns that a dictionary, a 12-digit number or code 4011 exist, and the SDK never sees a
NotifierError. Everything that knows both sides is in the one class in the middle.
An object adapter does four jobs, all in one class
Here is that adapter as a program, written once for every language of the tabs. The 13 versions printed identical lines, shown once below the tabs, and the C tab explains why C sits this one out:
Python 3.14 · main.py
from abc import ABC, abstractmethod
class NotifierError(Exception):
pass
class Notifier(ABC):
@abstractmethod
def send(self, phone: str, text: str) -> str: ...
class SmsGatewayClient:
"""The SDK, which cannot change: a dictionary in, a status code out."""
def __init__(self) -> None:
self.accepted = 0
def dispatch(self, payload: dict[str, str]) -> tuple[int, str]:
msisdn = payload["msisdn"]
print(f" SDK got {msisdn}")
if not (msisdn.isdigit() and len(msisdn) == 12):
return 4011, ""
self.accepted += 1
return 0, f"GW-{self.accepted:06d}" # code 0: accepted
class SmsNotifier(Notifier):
"""The adapter: a Notifier outside, calls to the SDK inside."""
def __init__(self, client: SmsGatewayClient, sender: str) -> None:
self._client = client
self._sender = sender
def send(self, phone: str, text: str) -> str:
digits = "".join(ch for ch in phone if ch.isdigit())
payload = {"msisdn": digits, "body": text, "sender": self._sender}
code, message_id = self._client.dispatch(payload)
if code != 0:
reason = "invalid number" if code == 4011 else f"code {code}"
raise NotifierError(f"cannot send to {phone}: {reason}")
return message_id
def remind(notifier: Notifier, phone: str, time: str) -> None:
try:
receipt = notifier.send(phone, f"Visit at {time}.")
print(f"sent to {phone}: {receipt}")
except NotifierError as error:
print(f"failed: {error}")
notifier = SmsNotifier(SmsGatewayClient(), sender="CLINIC")
remind(notifier, "+91 98765 43210", "10:30")
remind(notifier, "+91 98765", "11:00") In Python: Notifier is an abstract base class, so a subclass that leaves out send() cannot be created. The SDK answers with a tuple, the status code and the id, which send() unpacks.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
class NotifierError extends Error {}
/** @typedef {{ send(phone: string, text: string): string }} Notifier */
/** The SDK, which cannot change: a dictionary in, a status code out. */
class SmsGatewayClient {
accepted = 0;
dispatch(payload) {
const { msisdn } = payload;
console.log(` SDK got ${msisdn}`);
if (!/^\d{12}$/.test(msisdn)) return [4011, ""];
this.accepted += 1;
return [0, `GW-${String(this.accepted).padStart(6, "0")}`]; // code 0: accepted
}
}
/** The adapter: a Notifier outside, calls to the SDK inside. */
class SmsNotifier {
#client;
#sender;
constructor(client, sender) {
this.#client = client;
this.#sender = sender;
}
send(phone, text) {
const digits = phone.replace(/\D/g, "");
const [code, messageId] = this.#client.dispatch({
msisdn: digits,
body: text,
sender: this.#sender,
});
if (code !== 0) {
const reason = code === 4011 ? "invalid number" : `code ${code}`;
throw new NotifierError(`cannot send to ${phone}: ${reason}`);
}
return messageId;
}
}
/** @param {Notifier} notifier @param {string} phone @param {string} time */
function remind(notifier, phone, time) {
try {
const receipt = notifier.send(phone, `Visit at ${time}.`);
console.log(`sent to ${phone}: ${receipt}`);
} catch (error) {
if (!(error instanceof NotifierError)) throw error;
console.log(`failed: ${error.message}`);
}
}
const notifier = new SmsNotifier(new SmsGatewayClient(), "CLINIC");
remind(notifier, "+91 98765 43210", "10:30");
remind(notifier, "+91 98765", "11:00"); In JavaScript: JavaScript has no interfaces, so Notifier is a JSDoc type that editors check.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.Locale;
import java.util.Map;
public class Main {
static class NotifierException extends Exception {
NotifierException(String message) {
super(message);
}
}
interface Notifier {
String send(String phone, String text) throws NotifierException;
}
record Reply(int code, String messageId) {} // code 0: accepted
/** The SDK, which cannot change: a dictionary in, a status code out. */
static class SmsGatewayClient {
private int accepted = 0;
Reply dispatch(Map<String, String> payload) {
String msisdn = payload.get("msisdn");
System.out.println(" SDK got " + msisdn);
if (msisdn.length() != 12 || !msisdn.chars().allMatch(Character::isDigit)) {
return new Reply(4011, "");
}
accepted++;
return new Reply(0, String.format("GW-%06d", accepted));
}
}
/** The adapter: a Notifier outside, calls to the SDK inside. */
static class SmsNotifier implements Notifier {
private final SmsGatewayClient client;
private final String sender;
SmsNotifier(SmsGatewayClient client, String sender) {
this.client = client;
this.sender = sender;
}
@Override
public String send(String phone, String text) throws NotifierException {
var digits = new StringBuilder();
phone.chars().filter(Character::isDigit).forEach(digits::appendCodePoint);
var reply =
client.dispatch(Map.of("msisdn", digits.toString(), "body", text, "sender", sender));
if (reply.code() != 0) {
var reason = reply.code() == 4011 ? "invalid number" : "code " + reply.code();
throw new NotifierException("cannot send to " + phone + ": " + reason);
}
return reply.messageId();
}
}
static void remind(Notifier notifier, String phone, String time) {
try {
var receipt = notifier.send(phone, "Visit at " + time + ".");
System.out.println("sent to " + phone + ": " + receipt);
} catch (NotifierException error) {
System.out.println("failed: " + error.getMessage());
}
}
public static void main(String[] args) {
Locale.setDefault(Locale.ROOT);
var notifier = new SmsNotifier(new SmsGatewayClient(), "CLINIC");
remind(notifier, "+91 98765 43210", "10:30");
remind(notifier, "+91 98765", "11:00");
}
} In Java: The SDK's failure becomes a checked NotifierException, so the compiler makes remind() catch it or declare it. Java names its exception classes with Exception rather than Error.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <algorithm>
#include <format>
#include <map>
#include <print>
#include <stdexcept>
#include <string>
struct NotifierError : std::runtime_error {
using std::runtime_error::runtime_error;
};
struct Notifier {
virtual ~Notifier() = default;
virtual std::string send(std::string_view phone, std::string_view text) = 0;
};
using Payload = std::map<std::string, std::string>;
bool is_digit(char ch) { return ch >= '0' && ch <= '9'; }
// The SDK, which cannot change: a dictionary in, a status code out.
struct SmsGatewayClient {
int accepted = 0;
int dispatch(const Payload &payload, std::string &message_id) {
const std::string &msisdn = payload.at("msisdn");
std::println(" SDK got {}", msisdn);
if (msisdn.size() != 12 || !std::ranges::all_of(msisdn, is_digit)) {
return 4011;
}
message_id = std::format("GW-{:06}", ++accepted);
return 0;
}
};
// The adapter: a Notifier outside, calls to the SDK inside.
class SmsNotifier : public Notifier {
public:
SmsNotifier(SmsGatewayClient &client, std::string sender)
: client_(client), sender_(std::move(sender)) {}
std::string send(std::string_view phone, std::string_view text) override {
std::string digits;
std::ranges::copy_if(phone, std::back_inserter(digits), is_digit);
Payload payload{
{"msisdn", digits}, {"body", std::string(text)}, {"sender", sender_}};
std::string message_id;
int code = client_.dispatch(payload, message_id);
if (code != 0) {
auto reason = code == 4011 ? std::string("invalid number")
: std::format("code {}", code);
throw NotifierError(std::format("cannot send to {}: {}", phone, reason));
}
return message_id;
}
private:
SmsGatewayClient &client_;
std::string sender_;
};
void remind(Notifier ¬ifier, std::string_view phone, std::string_view time) {
try {
auto receipt = notifier.send(phone, std::format("Visit at {}.", time));
std::println("sent to {}: {}", phone, receipt);
} catch (const NotifierError &error) {
std::println("failed: {}", error.what());
}
}
int main() {
SmsGatewayClient client;
SmsNotifier notifier(client, "CLINIC");
remind(notifier, "+91 98765 43210", "10:30");
remind(notifier, "+91 98765", "11:00");
} In C++: The adapter keeps a reference to the SDK it was given, so the SDK must outlive the adapter. Here the SDK writes the id through a reference and returns the code, in the style of a C library.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var notifier = new SmsNotifier(new SmsGatewayClient(), "CLINIC");
Remind(notifier, "+91 98765 43210", "10:30");
Remind(notifier, "+91 98765", "11:00");
static void Remind(INotifier notifier, string phone, string time)
{
try
{
var receipt = notifier.Send(phone, $"Visit at {time}.");
Console.WriteLine($"sent to {phone}: {receipt}");
}
catch (NotifierException error)
{
Console.WriteLine($"failed: {error.Message}");
}
}
sealed class NotifierException(string message) : Exception(message);
interface INotifier
{
string Send(string phone, string text);
}
/// <summary>The SDK, which cannot change: a dictionary in, a status code out.</summary>
sealed class SmsGatewayClient
{
int accepted;
public (int Code, string MessageId) Dispatch(Dictionary<string, string> payload)
{
var msisdn = payload["msisdn"];
Console.WriteLine($" SDK got {msisdn}");
if (msisdn.Length != 12 || !msisdn.All(char.IsAsciiDigit))
{
return (4011, "");
}
accepted++;
return (0, $"GW-{accepted:D6}"); // code 0: accepted
}
}
/// <summary>The adapter: a Notifier outside, calls to the SDK inside.</summary>
sealed class SmsNotifier(SmsGatewayClient client, string sender) : INotifier
{
public string Send(string phone, string text)
{
var digits = string.Concat(phone.Where(char.IsAsciiDigit));
var (code, messageId) = client.Dispatch(
new() { ["msisdn"] = digits, ["body"] = text, ["sender"] = sender });
if (code != 0)
{
var reason = code == 4011 ? "invalid number" : $"code {code}";
throw new NotifierException($"cannot send to {phone}: {reason}");
}
return messageId;
}
} In C#: Interface names start with I, as .NET's naming guidelines ask. The SDK returns a named tuple, (int Code, string MessageId), which the adapter deconstructs.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
C is left out of this example: C has no interfaces or exceptions, so the target would be a struct of function pointers that reports failures through out parameters, another design.
PHP 8.5 · main.php
<?php
declare(strict_types=1);
final class NotifierException extends Exception {}
interface Notifier
{
public function send(string $phone, string $text): string;
}
/** The SDK, which cannot change: a dictionary in, a status code out. */
final class SmsGatewayClient
{
private int $accepted = 0;
/**
* @param array{msisdn: string, body: string, sender: string} $payload
* @return array{int, string} the code, 0 when accepted, and the message's id
*/
public function dispatch(array $payload): array
{
$msisdn = $payload['msisdn'];
echo " SDK got {$msisdn}\n";
if (preg_match('/^\d{12}$/', $msisdn) !== 1) {
return [4011, ''];
}
$this->accepted++;
return [0, sprintf('GW-%06d', $this->accepted)];
}
}
/** The adapter: a Notifier outside, calls to the SDK inside. */
final class SmsNotifier implements Notifier
{
public function __construct(private SmsGatewayClient $client, private string $sender) {}
public function send(string $phone, string $text): string
{
$digits = preg_replace('/\D/', '', $phone);
$payload = ['msisdn' => $digits, 'body' => $text, 'sender' => $this->sender];
[$code, $messageId] = $this->client->dispatch($payload);
if ($code !== 0) {
$reason = $code === 4011 ? 'invalid number' : "code {$code}";
throw new NotifierException("cannot send to {$phone}: {$reason}");
}
return $messageId;
}
}
function remind(Notifier $notifier, string $phone, string $time): void
{
try {
$receipt = $notifier->send($phone, "Visit at {$time}.");
echo "sent to {$phone}: {$receipt}\n";
} catch (NotifierException $error) {
echo "failed: {$error->getMessage()}\n";
}
}
$notifier = new SmsNotifier(new SmsGatewayClient(), 'CLINIC');
remind($notifier, '+91 98765 43210', '10:30');
remind($notifier, '+91 98765', '11:00'); In PHP: Constructor promotion, private SmsGatewayClient $client, declares and sets the adapter's fields in one line. The payload's keys are written down as a PHPDoc array shape.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
use std::collections::HashMap;
use std::fmt;
#[derive(Debug)]
struct NotifierError(String);
impl fmt::Display for NotifierError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(&self.0)
}
}
trait Notifier {
fn send(&mut self, phone: &str, text: &str) -> Result<String, NotifierError>;
}
/// The SDK, which cannot change: a dictionary in, a status code out.
#[derive(Default)]
struct SmsGatewayClient {
accepted: u32,
}
impl SmsGatewayClient {
/// Ok with the message's id when it was accepted, or Err with a status code.
fn dispatch(&mut self, payload: &HashMap<&str, String>) -> Result<String, u32> {
let msisdn = &payload["msisdn"];
println!(" SDK got {msisdn}");
if msisdn.len() != 12 || !msisdn.chars().all(|ch| ch.is_ascii_digit()) {
return Err(4011);
}
self.accepted += 1;
Ok(format!("GW-{:06}", self.accepted))
}
}
/// The adapter: a Notifier outside, calls to the SDK inside.
struct SmsNotifier {
client: SmsGatewayClient,
sender: String,
}
impl Notifier for SmsNotifier {
fn send(&mut self, phone: &str, text: &str) -> Result<String, NotifierError> {
let digits: String = phone.chars().filter(char::is_ascii_digit).collect();
let payload = HashMap::from([
("msisdn", digits),
("body", text.to_string()),
("sender", self.sender.clone()),
]);
let reason = match self.client.dispatch(&payload) {
Ok(message_id) => return Ok(message_id),
Err(4011) => "invalid number".to_string(),
Err(code) => format!("code {code}"),
};
Err(NotifierError(format!("cannot send to {phone}: {reason}")))
}
}
fn remind(notifier: &mut impl Notifier, phone: &str, time: &str) {
match notifier.send(phone, &format!("Visit at {time}.")) {
Ok(receipt) => println!("sent to {phone}: {receipt}"),
Err(error) => println!("failed: {error}"),
}
}
fn main() {
let client = SmsGatewayClient::default();
let mut notifier = SmsNotifier {
client,
sender: "CLINIC".to_string(),
};
remind(&mut notifier, "+91 98765 43210", "10:30");
remind(&mut notifier, "+91 98765", "11:00");
} In Rust: The SDK answers with Result<String, u32>, the Rust way to say "an id or a status code", and a match turns Err(4011) into the application's NotifierError.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
class NotifierError extends Error {}
interface Notifier {
send(phone: string, text: string): string;
}
type Payload = Record<"msisdn" | "body" | "sender", string>;
/** The SDK, which cannot change: a dictionary in, a status code out. */
class SmsGatewayClient {
#accepted = 0;
dispatch(payload: Payload): [code: number, messageId: string] {
const { msisdn } = payload;
console.log(` SDK got ${msisdn}`);
if (!/^\d{12}$/.test(msisdn)) return [4011, ""];
this.#accepted += 1;
return [0, `GW-${String(this.#accepted).padStart(6, "0")}`]; // code 0: accepted
}
}
/** The adapter: a Notifier outside, calls to the SDK inside. */
class SmsNotifier implements Notifier {
readonly #client: SmsGatewayClient;
readonly #sender: string;
constructor(client: SmsGatewayClient, sender: string) {
this.#client = client;
this.#sender = sender;
}
send(phone: string, text: string): string {
const digits = phone.replace(/\D/g, "");
const [code, messageId] = this.#client.dispatch({
msisdn: digits,
body: text,
sender: this.#sender,
});
if (code !== 0) {
const reason = code === 4011 ? "invalid number" : `code ${code}`;
throw new NotifierError(`cannot send to ${phone}: ${reason}`);
}
return messageId;
}
}
function remind(notifier: Notifier, phone: string, time: string): void {
try {
const receipt = notifier.send(phone, `Visit at ${time}.`);
console.log(`sent to ${phone}: ${receipt}`);
} catch (error) {
if (!(error instanceof NotifierError)) throw error;
console.log(`failed: ${error.message}`);
}
}
const notifier = new SmsNotifier(new SmsGatewayClient(), "CLINIC");
remind(notifier, "+91 98765 43210", "10:30");
remind(notifier, "+91 98765", "11:00"); In TypeScript: The compiler checks the adapter against Notifier where it says implements, and nothing of the interface is left when the program runs.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"strings"
"unicode"
)
// NotifierError says, in the application's words, why a message could not be sent.
type NotifierError struct{ Phone, Reason string }
func (e *NotifierError) Error() string { return "cannot send to " + e.Phone + ": " + e.Reason }
// Notifier is the interface the application owns.
type Notifier interface {
Send(phone, text string) (string, error)
}
// SmsGatewayClient is the SDK, which cannot be changed: a dictionary in, a status code out.
type SmsGatewayClient struct{ accepted int }
// Dispatch returns the status code, 0 when the message was accepted, and the message's id.
func (c *SmsGatewayClient) Dispatch(payload map[string]string) (code int, messageID string) {
msisdn := payload["msisdn"]
fmt.Println(" SDK got", msisdn)
if len(msisdn) != 12 || strings.Trim(msisdn, "0123456789") != "" {
return 4011, ""
}
c.accepted++
return 0, fmt.Sprintf("GW-%06d", c.accepted)
}
// SmsNotifier is the adapter: a Notifier outside, calls to the SDK inside.
type SmsNotifier struct {
client *SmsGatewayClient
sender string
}
func (n SmsNotifier) Send(phone, text string) (string, error) {
digits := strings.Map(func(r rune) rune {
if unicode.IsDigit(r) {
return r
}
return -1
}, phone)
payload := map[string]string{"msisdn": digits, "body": text, "sender": n.sender}
code, messageID := n.client.Dispatch(payload)
switch code {
case 0:
return messageID, nil
case 4011:
return "", &NotifierError{Phone: phone, Reason: "invalid number"}
default:
return "", &NotifierError{Phone: phone, Reason: fmt.Sprintf("code %d", code)}
}
}
func remind(notifier Notifier, phone, time string) {
receipt, err := notifier.Send(phone, "Visit at "+time+".")
if err != nil {
fmt.Println("failed:", err)
return
}
fmt.Printf("sent to %s: %s\n", phone, receipt)
}
func main() {
notifier := SmsNotifier{client: &SmsGatewayClient{}, sender: "CLINIC"}
remind(notifier, "+91 98765 43210", "10:30")
remind(notifier, "+91 98765", "11:00")
} In Go: Dispatch returns two values, the code and the id, and Send returns the id and an error value. NotifierError is a type of its own, so a caller can look for it with errors.As.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
struct NotifierError: Error, CustomStringConvertible {
let description: String
}
protocol Notifier {
func send(to phone: String, text: String) throws(NotifierError) -> String
}
/// The SDK, which cannot change: a dictionary in, a status code out.
final class SmsGatewayClient {
private var accepted = 0
func dispatch(_ payload: [String: String]) -> (code: Int, messageID: String) {
let msisdn = payload["msisdn", default: ""]
print(" SDK got \(msisdn)")
guard msisdn.count == 12, msisdn.allSatisfy(\.isASCIIDigit) else { return (4011, "") }
accepted += 1
let number = String(accepted)
return (0, "GW-" + String(repeating: "0", count: 6 - number.count) + number) // 0: accepted
}
}
extension Character {
var isASCIIDigit: Bool { isASCII && isNumber }
}
/// The adapter: a Notifier outside, calls to the SDK inside.
struct SmsNotifier: Notifier {
let client: SmsGatewayClient
let sender: String
func send(to phone: String, text: String) throws(NotifierError) -> String {
let digits = String(phone.filter(\.isASCIIDigit))
let reply = client.dispatch(["msisdn": digits, "body": text, "sender": sender])
guard reply.code == 0 else {
let reason = reply.code == 4011 ? "invalid number" : "code \(reply.code)"
throw NotifierError(description: "cannot send to \(phone): \(reason)")
}
return reply.messageID
}
}
func remind(_ notifier: any Notifier, phone: String, at time: String) {
do {
let receipt = try notifier.send(to: phone, text: "Visit at \(time).")
print("sent to \(phone): \(receipt)")
} catch {
print("failed: \(error)")
}
}
let notifier = SmsNotifier(client: SmsGatewayClient(), sender: "CLINIC")
remind(notifier, phone: "+91 98765 43210", at: "10:30")
remind(notifier, phone: "+91 98765", at: "11:00") In Swift: send(to:text:) is declared throws(NotifierError), so every caller knows the one error it can throw. Without Foundation the zeros of the message's id are padded by hand.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
# Raised when a message could not be sent.
class NotifierError < StandardError; end
# The SDK, which cannot change: a dictionary in, a status code out.
class SmsGatewayClient
def initialize
@accepted = 0
end
def dispatch(payload)
msisdn = payload[:msisdn]
puts " SDK got #{msisdn}"
return [4011, ''] unless msisdn.match?(/\A\d{12}\z/)
@accepted += 1
[0, format('GW-%06d', @accepted)] # code 0: accepted
end
end
# The adapter: a Notifier outside, calls to the SDK inside. Ruby has no interface
# declarations: a Notifier is anything with deliver(phone, text).
class SmsNotifier
def initialize(client, sender)
@client = client
@sender = sender
end
def deliver(phone, text)
payload = { msisdn: phone.delete('^0-9'), body: text, sender: @sender }
code, message_id = @client.dispatch(payload)
return message_id if code.zero?
reason = code == 4011 ? 'invalid number' : "code #{code}"
raise NotifierError, "cannot send to #{phone}: #{reason}"
end
end
def remind(notifier, phone, time)
receipt = notifier.deliver(phone, "Visit at #{time}.")
puts "sent to #{phone}: #{receipt}"
rescue NotifierError => e
puts "failed: #{e.message}"
end
notifier = SmsNotifier.new(SmsGatewayClient.new, 'CLINIC')
remind(notifier, '+91 98765 43210', '10:30')
remind(notifier, '+91 98765', '11:00') In Ruby: Every Ruby object already has a send method, which calls another method by its name, so the interface's method is called deliver here. phone.delete('^0-9') keeps only the digits.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
class NotifierException(
message: String,
) : Exception(message)
interface Notifier {
fun send(
phone: String,
text: String,
): String
}
/** The SDK, which cannot change: a dictionary in, a status code out. */
class SmsGatewayClient {
private var accepted = 0
fun dispatch(payload: Map<String, String>): Pair<Int, String> {
val msisdn = payload.getValue("msisdn")
println(" SDK got $msisdn")
if (msisdn.length != 12 || !msisdn.all { it in '0'..'9' }) return 4011 to ""
accepted++
return 0 to "GW-" + accepted.toString().padStart(6, '0') // code 0: accepted
}
}
/** The adapter: a Notifier outside, calls to the SDK inside. */
class SmsNotifier(
private val client: SmsGatewayClient,
private val sender: String,
) : Notifier {
override fun send(
phone: String,
text: String,
): String {
val digits = phone.filter { it in '0'..'9' }
val payload = mapOf("msisdn" to digits, "body" to text, "sender" to sender)
val (code, messageId) = client.dispatch(payload)
if (code != 0) {
val reason = if (code == 4011) "invalid number" else "code $code"
throw NotifierException("cannot send to $phone: $reason")
}
return messageId
}
}
fun remind(
notifier: Notifier,
phone: String,
time: String,
) {
try {
val receipt = notifier.send(phone, "Visit at $time.")
println("sent to $phone: $receipt")
} catch (error: NotifierException) {
println("failed: ${error.message}")
}
}
fun main() {
val notifier = SmsNotifier(SmsGatewayClient(), "CLINIC")
remind(notifier, "+91 98765 43210", "10:30")
remind(notifier, "+91 98765", "11:00")
} In Kotlin: Kotlin has no checked exceptions, so nothing makes remind() catch NotifierException. The SDK returns a Pair, and val (code, messageId) takes it apart.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
final class NotifierException implements Exception {
NotifierException(this.message);
final String message;
@override
String toString() => message;
}
abstract interface class Notifier {
String send(String phone, String text);
}
/// The SDK, which cannot change: a dictionary in, a status code out.
final class SmsGatewayClient {
int _accepted = 0;
(int, String) dispatch(Map<String, String> payload) {
final msisdn = payload['msisdn']!;
print(' SDK got $msisdn');
if (!RegExp(r'^\d{12}$').hasMatch(msisdn)) return (4011, '');
_accepted++;
return (
0,
'GW-${_accepted.toString().padLeft(6, '0')}',
); // code 0: accepted
}
}
/// The adapter: a Notifier outside, calls to the SDK inside.
final class SmsNotifier implements Notifier {
SmsNotifier(this._client, this._sender);
final SmsGatewayClient _client;
final String _sender;
@override
String send(String phone, String text) {
final digits = phone.replaceAll(RegExp(r'\D'), '');
final payload = {'msisdn': digits, 'body': text, 'sender': _sender};
final (code, messageId) = _client.dispatch(payload);
if (code != 0) {
final reason = code == 4011 ? 'invalid number' : 'code $code';
throw NotifierException('cannot send to $phone: $reason');
}
return messageId;
}
}
void remind(Notifier notifier, String phone, String time) {
try {
final receipt = notifier.send(phone, 'Visit at $time.');
print('sent to $phone: $receipt');
} on NotifierException catch (error) {
print('failed: $error');
}
}
void main() {
final notifier = SmsNotifier(SmsGatewayClient(), 'CLINIC');
remind(notifier, '+91 98765 43210', '10:30');
remind(notifier, '+91 98765', '11:00');
} In Dart: Notifier is an abstract interface class, which code in other libraries may implement but not extend. The SDK returns a record, (int, String), which a pattern takes apart.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
SDK got 919876543210 sent to +91 98765 43210: GW-000001 SDK got 9198765 failed: cannot send to +91 98765: invalid number
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 72 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 155 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 3 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 30 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 726 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 192 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 13 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
SmsNotifier.send() does the four jobs of an adapter, and each one is a line or two:
- It reshapes the call. Two arguments become one dictionary with the SDK’s key names.
- It converts the data. The number as people write it, “+91 98765 43210”, becomes the SDK’s 919876543210.
- It supplies what the SDK needs and the interface leaves out. The sender name is fixed once, when the adapter is created, so callers never pass it.
- It maps the reply. Code 0 becomes the message’s id. Any other code becomes a
NotifierErrorwith a reason in the application’s words, whichremind()caught for the short number without ever reading code 4011.
The indented lines were printed by the SDK during each call. Both times it received its own dictionary, with the number already reduced to digits, and only one place in the program builds that dictionary.
An adapter that hands back the SDK's reply
A send() that returns the SDK’s reply object, return reply, looks shorter, but now every caller must test
reply.code, and the SDK’s status codes have leaked into forty places again. Return what the target interface
promises, the message’s id, and turn every failure into the interface’s own error, as SmsNotifier does.
An adapter translates; it does not decide. Rules such as retries, message templates and rate limits belong in the application, or in a wrapper around the adapter, so that the adapter for a second provider does not have to copy them.
Key idea
The adapter is the only code that knows both interfaces: it reshapes each call, converts the data, adds what the SDK needs and maps every reply, so the rest of the program depends on its own interface alone.
Checkpoint
Check: What an adapter translates, and what it leaves alone · The client, the target, the adapter and the adaptee
A fake behind the same interface keeps the SDK out of tests
Because the application depends only on Notifier, a test can hand it a fake: A working stand-in for a real dependency, used in tests: for example a notifier that records the messages it is asked to send instead of sending them.: a few lines
that implement the same interface and record what they are asked to send. The service below sends sign-up codes,
and its tests give it a RecordingNotifier instead of the adapter:
fake/test_signup.py
"""Tests of SignupService with a fake Notifier: no provider, no network, and the SDK is never imported."""
import sys
from notifier import Notifier, NotifierError
from signup import SignupService
class RecordingNotifier(Notifier):
"""A fake: it keeps every message, and fails for the numbers it is told to fail."""
def __init__(self, failing: frozenset[str] = frozenset()) -> None:
self.messages: list[tuple[str, str]] = []
self._failing = failing
def send(self, phone: str, text: str) -> str:
if phone in self._failing:
raise NotifierError(f"cannot send to {phone}")
self.messages.append((phone, text))
return f"fake-{len(self.messages)}"
def test_sends_the_code():
fake = RecordingNotifier()
service = SignupService(fake, make_code=lambda: "482913")
assert service.start("+91 98765 43210")
assert fake.messages == [("+91 98765 43210", "Your sign-up code is 482913")]
def test_failed_send_keeps_nothing_pending():
fake = RecordingNotifier(failing=frozenset({"+91 90000 00000"}))
service = SignupService(fake, make_code=lambda: "111111")
assert not service.start("+91 90000 00000")
assert service.pending == {}
for name, test in list(globals().items()):
if name.startswith("test_"):
test()
print("passed:", name)
print("SDK module loaded:", "sms_sdk" in sys.modules) Output
passed: test_sends_the_code passed: test_failed_send_keeps_nothing_pending SDK module loaded: False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 test_signup.py
fake/signup.py
"""A service that sends texts. It is given a Notifier when it is created; it never makes one itself."""
from collections.abc import Callable
from notifier import Notifier, NotifierError
class SignupService:
def __init__(self, notifier: Notifier, make_code: Callable[[], str]) -> None:
self._notifier = notifier
self._make_code = make_code
self.pending: dict[str, str] = {}
def start(self, phone: str) -> bool:
"""Send a sign-up code to phone; remember it only if the text went out."""
code = self._make_code()
try:
self._notifier.send(phone, f"Your sign-up code is {code}")
except NotifierError:
return False
self.pending[phone] = code
return True fake/notifier.py
"""The interface the application owns: what any notifier must offer."""
from abc import ABC, abstractmethod
class NotifierError(Exception):
"""A message could not be sent; the text says why."""
class Notifier(ABC):
@abstractmethod
def send(self, phone: str, text: str) -> str:
"""Send text to phone (written as "+91 98765 43210") and return the message's id.""" fake/sms_sdk.py
"""A stand-in for a text-message provider's client library, invented for this lesson.
Its interface is the one you cannot change: one dispatch() call takes a dictionary, the phone number is called
"msisdn" and must be 12 digits with the country code and no plus sign, and failures come back as status codes
instead of exceptions.
"""
class SmsGatewayClient:
def __init__(self) -> None:
self.accepted = 0
def dispatch(self, payload: dict[str, str]) -> tuple[int, str]:
"""Return the status code, 0 when the message was accepted, and the message's id."""
msisdn = payload["msisdn"]
print(f" SDK got {msisdn}")
if not (msisdn.isdigit() and len(msisdn) == 12):
return 4011, ""
self.accepted += 1
return 0, f"GW-{self.accepted:06d}" Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Both tests passed, and the last line shows that the SDK’s module, which sits in the same folder, was never loaded:
the tests need no provider account, no network and no test phone numbers. Press Edit, give the second test’s fake an empty set of failing
numbers, frozenset(), and run it again: the second test now fails, because the service sends the code and keeps it
pending. The adapter still needs tests of its own, but only a few, against the provider’s test environment or
recorded replies, because it is the one class that knows the SDK.
Scaled up to a whole application, this is what Alistair Cockburn named
ports and adapters: An architecture, also called hexagonal architecture, in which the application defines an interface, a port, for each conversation with the outside world, and each technology plugs in through an adapter., also called hexagonal architecture. The application defines
a port, an interface such as Notifier, for each conversation with the outside world, and each technology that plugs
into a port gets its own adapter. His article describes the
idea as a particular use of the Adapter pattern.
Object adapter, class adapter or a conversion
There are three ways to make one interface fit another, and the choice depends on what your language offers and on what the adapter’s clients should be able to reach.
- An object adapter: An adapter that keeps the adaptee in a field and implements the target interface by forwarding each call to it, so it can wrap any instance, including one created elsewhere. holds the adaptee in a field and forwards calls to it, as
SmsNotifierdoes. It works with any instance of the adaptee’s class, subclasses included. - A class adapter: An adapter that inherits from the adaptee's class and implements the target interface, so one object is both, and every public method of the adaptee stays reachable. inherits from the adaptee’s class and implements the target interface, so one object is both. It needs multiple inheritance (C++, Python) or a class plus an interface (Java).
- A conversion does the adapting without a new class, when the target is a function type or a value type.
The program below builds the first two over the same SDK, with the classes of the block above split into modules:
kinds/two_adapters.py
"""The same adapter two ways: holding an SDK client (object adapter) and inheriting from the SDK class (class adapter)."""
from notifier import Notifier, NotifierError
from sms_adapter import SmsNotifier
from sms_sdk import SmsGatewayClient
class SmsNotifierByInheritance(SmsGatewayClient, Notifier):
"""A class adapter: it is an SmsGatewayClient and a Notifier at once."""
def __init__(self, sender: str) -> None:
super().__init__()
self._sender = sender
def send(self, phone: str, text: str) -> str:
digits = "".join(ch for ch in phone if ch.isdigit())
code, message_id = self.dispatch({"msisdn": digits, "body": text, "sender": self._sender})
if code != 0:
raise NotifierError(f"cannot send to {phone}: code {code}")
return message_id
class CountingGatewayClient(SmsGatewayClient):
"""A client that was set up elsewhere: this one counts the requests it is given."""
def __init__(self) -> None:
super().__init__()
self.requests = 0
def dispatch(self, payload: dict[str, str]) -> tuple[int, str]:
self.requests += 1
return super().dispatch(payload)
counting = CountingGatewayClient()
by_object = SmsNotifier(counting, sender="CLINIC")
by_class = SmsNotifierByInheritance(sender="CLINIC")
for name, adapter in [("object adapter", by_object), ("class adapter", by_class)]:
print(f"{name}: a Notifier: {isinstance(adapter, Notifier)}, has dispatch(): {hasattr(adapter, 'dispatch')}")
print(" sent:", adapter.send("+91 98765 43210", "Hello"))
print("requests the counting client saw:", counting.requests)
# Code holding the class adapter can step around the interface and call the SDK itself.
print("around the interface:", by_class.dispatch({"msisdn": "1", "body": "", "sender": "?"})) Output
object adapter: a Notifier: True, has dispatch(): False SDK got 919876543210 sent: GW-000001 class adapter: a Notifier: True, has dispatch(): True SDK got 919876543210 sent: GW-000001 requests the counting client saw: 1 SDK got 1 around the interface: (4011, '')
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 two_adapters.py
kinds/sms_adapter.py
"""The adapter: a Notifier on the outside, calls to SmsGatewayClient on the inside."""
from notifier import Notifier, NotifierError
from sms_sdk import SmsGatewayClient
class SmsNotifier(Notifier):
def __init__(self, client: SmsGatewayClient, sender: str) -> None:
self._client = client
self._sender = sender
def send(self, phone: str, text: str) -> str:
digits = "".join(ch for ch in phone if ch.isdigit())
payload = {"msisdn": digits, "body": text, "sender": self._sender}
code, message_id = self._client.dispatch(payload)
if code != 0:
reason = "invalid number" if code == 4011 else f"code {code}"
raise NotifierError(f"cannot send to {phone}: {reason}")
return message_id kinds/sms_sdk.py
"""A stand-in for a text-message provider's client library, invented for this lesson.
Its interface is the one you cannot change: one dispatch() call takes a dictionary, the phone number is called
"msisdn" and must be 12 digits with the country code and no plus sign, and failures come back as status codes
instead of exceptions.
"""
class SmsGatewayClient:
def __init__(self) -> None:
self.accepted = 0
def dispatch(self, payload: dict[str, str]) -> tuple[int, str]:
"""Return the status code, 0 when the message was accepted, and the message's id."""
msisdn = payload["msisdn"]
print(f" SDK got {msisdn}")
if not (msisdn.isdigit() and len(msisdn) == 12):
return 4011, ""
self.accepted += 1
return 0, f"GW-{self.accepted:06d}" kinds/notifier.py
"""The interface the application owns: what any notifier must offer."""
from abc import ABC, abstractmethod
class NotifierError(Exception):
"""A message could not be sent; the text says why."""
class Notifier(ABC):
@abstractmethod
def send(self, phone: str, text: str) -> str:
"""Send text to phone (written as "+91 98765 43210") and return the message's id.""" Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The object adapter wrapped a client that was set up elsewhere, a subclass that counts its requests, without any new
code: it takes whatever instance it is given, and the count shows that its one request went through that client. The
class adapter is an SmsGatewayClient itself, so it has dispatch(), and the last line called the SDK directly,
around the interface the adapter exists to enforce. To count its requests you would need yet another subclass.
Rust shows the conversion style. Implementing the From trait for a type gives you the matching Into through a
blanket implementation in the standard library, so a function can accept anything that converts into the type it
wants:
/// Tenths of a degree Fahrenheit, as a sensor library reports a reading.
struct SensorTenthsF(i32);
/// The unit this program works in.
struct Celsius(f64);
impl From<SensorTenthsF> for Celsius {
fn from(reading: SensorTenthsF) -> Self {
Celsius((f64::from(reading.0) / 10.0 - 32.0) * 5.0 / 9.0)
}
}
/// Accepts anything that converts into Celsius, so callers never convert by hand.
fn report(reading: impl Into<Celsius>) -> String {
let c: Celsius = reading.into();
format!("{:.1} °C", c.0)
}
fn main() {
println!("{}", report(SensorTenthsF(986))); // 98.6 °F
println!("{}", report(SensorTenthsF(-400))); // -40.0 °F
println!("{}", report(Celsius(21.5))); // every type converts into itself
} Output
37.0 °C -40.0 °C 21.5 °C
Recorded with rustc 1.99.0 (b940084d7) on macOS 26 arm64. To run it yourself: rustc --edition 2024 -O from_into.rs && ./from_into
report() took the sensor’s tenths of a degree Fahrenheit and a ready-made Celsius alike, because every type
converts into itself. The standard library’s
documentation of From asks for conversions that cannot fail
and points to TryFrom for any that can. When the adaptee is a type from another crate, Rust’s usual adapter is a
newtype: A wrapper type with a single field, such as Rust's struct Celsius(f64), that gives an existing type a name and methods of its own at no run-time cost. such as Celsius, with your own methods on it, which the Rust API guidelines call a
zero-cost way to wrap an existing type.
| Criterion | Object adapter | Class adapter | Conversion |
|---|---|---|---|
| How it reaches the adaptee | Holds an instance and forwards each call | Inherits from the adaptee's class | Turns a function or a value into the target type |
| What its clients can call | Only the target interface | The target interface and every public method of the adaptee | Whatever the target type offers |
| Adapts an object made elsewhere | Yes, any instance, subclasses included | No: it is its own instance of one class | Yes: the value is converted where it is passed |
| Needs from the language | Nothing special | Multiple inheritance, or a class plus an interface | Function types, conversion traits or higher-order functions |
| When to choose | The default: SDK clients, legacy modules, anything you are handed | Rarely: when you must override the adaptee's own methods | One function or one value at a boundary |
Start with the object adapter
Reach for an object adapter first. Choose a class adapter only when you must override one of the adaptee’s own methods, and a conversion when the boundary is a single function or value.
Checkpoint
Check: Fakes behind the target interface · Object adapter, class adapter or a conversion
Standard libraries ship adapters under other names
Standard libraries are full of adapters, and their documentation seldom uses the word.
Go’s net/http. The package documentation says it outright: the HandlerFunc type is an adapter that lets an
ordinary function serve as an HTTP handler.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
// greet is an ordinary function. On its own it is not an http.Handler: it has no ServeHTTP method.
func greet(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello, %s", r.URL.Query().Get("name"))
}
func main() {
var h http.Handler = http.HandlerFunc(greet) // a type conversion is the whole adapter
rec := httptest.NewRecorder() // a request and a response in memory: no network
h.ServeHTTP(rec, httptest.NewRequest("GET", "/greet?name=Asha", nil))
fmt.Println(rec.Code, rec.Body.String())
} Output
200 hello, Asha
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run handler.go
greet has the right parameters but no ServeHTTP method, so on its own it is not an http.Handler.
http.HandlerFunc(greet) is a type conversion, and the ServeHTTP method of HandlerFunc calls the function. The
request and the response stayed in memory through the httptest package, so no network was involved.
Java’s InputStreamReader and Arrays.asList. The JDK documents InputStreamReader as a bridge from byte streams
to character streams, but in the classic catalogue’s terms it is an adapter: it takes an InputStream, which reads
bytes, and offers the Reader interface, which reads characters, decoding with a character set. Arrays.asList
returns a fixed-size list backed by an array, so the array gets the List interface.
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
/** Two adapters of the JDK: bytes read as characters, and an array used as a List. */
public class Streams {
public static void main(String[] args) throws IOException {
byte[] bytes = "नमस्ते".getBytes(StandardCharsets.UTF_8);
Reader reader = new InputStreamReader(new ByteArrayInputStream(bytes), StandardCharsets.UTF_8);
int chars = 0;
while (reader.read() != -1) chars++;
System.out.println(bytes.length + " bytes in, " + chars + " chars out");
String[] seats = {"A1", "A2", "A3"};
List<String> list = Arrays.asList(seats);
list.set(0, "B1");
System.out.println("array after list.set: " + Arrays.toString(seats));
try {
list.add("A4");
} catch (UnsupportedOperationException e) {
System.out.println("list.add: UnsupportedOperationException");
}
}
} Output
18 bytes in, 6 chars out array after list.set: [B1, A2, A3] list.add: UnsupportedOperationException
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Streams.java
The Hindi greeting is six Java characters, and it took 18 bytes in UTF-8, three each; the reader turned the bytes back
into 6 characters. set() on the list changed the array underneath, because the list is only a view of it. add()
failed: an array cannot grow, so the adapter cannot offer that part of List. An adapter can promise only what its
adaptee can do.
Python and Node.js. Python’s io.TextIOWrapper is a text stream on top of a buffered binary stream, the same job
that InputStreamReader does in Java. Node.js has util.promisify, which takes a function in the error-first
callback style and returns a version that returns promises, the same move that the exercise below asks you to make by
hand.
One interface on three platforms: Indian digit grouping
Indian digit grouping
Amounts in India are written with the last three digits grouped first and then pairs: 12,34,567 reads as 12 lakh,
34 thousand and 567, and 1,00,00,000 is one crore. Many platforms group digits only in threes, so an app that shows
rupee amounts on several platforms needs one interface, IndianAmountFormatter, with one implementation per
platform. Its format(paise) takes a whole number of paise, never a floating-point number of rupees, and returns text
such as ₹12,34,567.50.
All three implementations below read the same nine cases from one file. Python first:
india/amounts.py
"""IndianAmountFormatter in Python: the digits are grouped by hand, so no locale setting is involved."""
from abc import ABC, abstractmethod
class IndianAmountFormatter(ABC):
@abstractmethod
def format(self, paise: int) -> str:
"""Return the amount as rupees with Indian digit grouping, such as "₹12,34,567.50"."""
class GroupingFormatter(IndianAmountFormatter):
def format(self, paise: int) -> str:
sign = "-" if paise < 0 else ""
rupees, rest = divmod(abs(paise), 100)
digits = str(rupees)
head, groups = digits[:-3], [digits[-3:]] # the last three digits, then pairs
while head:
groups.insert(0, head[-2:])
head = head[:-2]
return f"{sign}₹{','.join(groups)}.{rest:02d}"
print("Python's own format(1234567.5, ','):", format(1234567.5, ","))
formatter: IndianAmountFormatter = GroupingFormatter()
passed = 0
cases = [line.split() for line in open("cases.txt", encoding="utf-8") if not line.startswith("#")]
for paise, expected in cases:
got = formatter.format(int(paise))
passed += got == expected
print(f"{paise:>12} -> {got}")
print(f"{passed} of {len(cases)} cases match") Output
Python's own format(1234567.5, ','): 1,234,567.5
50 -> ₹0.50
99999 -> ₹999.99
100000 -> ₹1,000.00
1234567 -> ₹12,345.67
10000000 -> ₹1,00,000.00
123456750 -> ₹12,34,567.50
1000000000 -> ₹1,00,00,000.00
12345678900 -> ₹12,34,56,789.00
-123450 -> -₹1,234.50
9 of 9 cases match
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 amounts.py
india/cases.txt
# amount in paise, then the text every IndianAmountFormatter must return
50 ₹0.50
99999 ₹999.99
100000 ₹1,000.00
1234567 ₹12,345.67
10000000 ₹1,00,000.00
123456750 ₹12,34,567.50
1000000000 ₹1,00,00,000.00
12345678900 ₹12,34,56,789.00
-123450 -₹1,234.50 Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Python’s own format() with a comma groups in threes, as the first line shows, so the Python version groups the
digits itself. It could ask the locale module instead, but that works only where the operating system provides an
Indian English locale, and setlocale() changes a setting of the whole process, which its documentation warns is
not thread-safe on most systems.
Node.js already groups digits this way. Its Intl.NumberFormat for the locale en-IN printed 12,34,567.5, so the
Node.js version is a true adapter: it turns paise into rupees and lets Intl do the rest.
// IndianAmountFormatter in Node.js: an adapter over Intl.NumberFormat, which already groups digits this way for en-IN.
import { readFileSync } from 'node:fs';
/** @typedef {{ format(paise: number): string }} IndianAmountFormatter */
class IntlAmountFormatter {
#intl = new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' });
/** @param {number} paise a whole number of paise */
format(paise) {
return this.#intl.format(paise / 100);
}
}
console.log("Intl's own format for en-IN:", new Intl.NumberFormat('en-IN').format(1234567.5));
/** @type {IndianAmountFormatter} */
const formatter = new IntlAmountFormatter();
const cases = readFileSync('cases.txt', 'utf8')
.split('\n')
.filter((line) => line && !line.startsWith('#'))
.map((line) => line.split(' '));
let passed = 0;
for (const [paise, expected] of cases) {
const got = formatter.format(Number(paise));
if (got === expected) passed++;
console.log(`${paise.padStart(12)} -> ${got}`);
}
console.log(`${passed} of ${cases.length} cases match`); Output
Intl's own format for en-IN: 12,34,567.5
50 -> ₹0.50
99999 -> ₹999.99
100000 -> ₹1,000.00
1234567 -> ₹12,345.67
10000000 -> ₹1,00,000.00
123456750 -> ₹12,34,567.50
1000000000 -> ₹1,00,00,000.00
12345678900 -> ₹12,34,56,789.00
-123450 -> -₹1,234.50
9 of 9 cases match
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node amounts.mjs
Java does not. Its NumberFormat for en-IN printed the amount in groups of three, and so did a DecimalFormat
given the pattern #,##,##0.00. A DecimalFormat keeps a single grouping size, the number of digits after the last
separator of its pattern, so the extra separator changes nothing, as the class’s documentation explains, and the
Java version groups by hand too.
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Locale;
/** IndianAmountFormatter in Java: the JDK's formatters group in threes, so the digits are grouped by hand. */
public class Amounts {
interface IndianAmountFormatter {
String format(long paise);
}
static final class GroupingFormatter implements IndianAmountFormatter {
public String format(long paise) {
String sign = paise < 0 ? "-" : "";
long abs = Math.abs(paise);
String digits = Long.toString(abs / 100);
Deque<String> groups = new ArrayDeque<>();
int end = digits.length();
groups.addFirst(digits.substring(Math.max(0, end - 3))); // the last three digits, then pairs
for (end -= 3; end > 0; end -= 2) {
groups.addFirst(digits.substring(Math.max(0, end - 2), end));
}
return String.format(Locale.ROOT, "%s₹%s.%02d", sign, String.join(",", groups), abs % 100);
}
}
public static void main(String[] args) throws Exception {
System.out.println("NumberFormat for en-IN: " + NumberFormat.getCurrencyInstance(Locale.of("en", "IN")).format(1234567.5));
System.out.println("DecimalFormat(\"#,##,##0.00\"): " + new DecimalFormat("#,##,##0.00").format(1234567.5));
IndianAmountFormatter formatter = new GroupingFormatter();
int passed = 0;
int total = 0;
for (String line : Files.readAllLines(Path.of("cases.txt"), StandardCharsets.UTF_8)) {
if (line.isEmpty() || line.startsWith("#")) continue;
String[] parts = line.split(" ");
String got = formatter.format(Long.parseLong(parts[0]));
total++;
if (got.equals(parts[1])) passed++;
System.out.printf("%12s -> %s%n", parts[0], got);
}
System.out.println(passed + " of " + total + " cases match");
}
} Output
NumberFormat for en-IN: ₹1,234,567.50
DecimalFormat("#,##,##0.00"): 1,234,567.50
50 -> ₹0.50
99999 -> ₹999.99
100000 -> ₹1,000.00
1234567 -> ₹12,345.67
10000000 -> ₹1,00,000.00
123456750 -> ₹12,34,567.50
1000000000 -> ₹1,00,00,000.00
12345678900 -> ₹12,34,56,789.00
-123450 -> -₹1,234.50
9 of 9 cases match
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Amounts.java
All three pass the same nine cases. Only one of them adapts anything; the other two exist because their platform’s formatter does something else. That is the point of owning the interface: callers cannot tell an adapter from a hand-written implementation, and when a platform gains the feature later, only its implementation changes, checked by the same file of cases.
Lakh Crore Converter (to Million, Billion) See how any amount reads in the Indian and the international systems.Checkpoint
Check: Adapters inside standard libraries · One interface, an implementation per platform
Practise: an adapter for a callback-style weather client
The legacy weather client of this exercise answers through callbacks, in degrees Fahrenheit, with numbers as strings. Write the adapter that gives the rest of the program a typed result instead; the sample tests run in your browser, in Python or in JavaScript:
Exercise · Easy · Python, JavaScript
Put a modern interface on a legacy weather client
A legacy weather client has an awkward interface: you call fetch_conditions(code, on_success, on_failure) in Python, or fetchConditions(code, onSuccess, onFailure) in JavaScript. It reports through callbacks, in degrees Fahrenheit, with every number as a string:
- on success it calls
on_success({"tempF": "98.6", "humidity": "54"}); - on failure it calls
on_failure(code, message)with a number:404when it has no station for the city, anything else when the service is down.
Write WeatherService, an adapter that gives the rest of the program a small interface instead:
current(city)asks the legacy client for the city code with surrounding spaces removed and in capitals, so" bom "asks for"BOM".- It returns a reading of the city code in capitals, the temperature in degrees Celsius rounded to one decimal place, and the humidity as a whole number. Celsius is (F − 32) × 5 / 9.
- Python:
current()returns aReading(the dataclass inweather.py), withcelsiusafloatandhumidityanint. Round withround(value, 1). - JavaScript:
current()returns a Promise of{ city, celsius, humidity }, with numbers, not strings. Round withMath.round(value * 10) / 10.
- Python:
- Errors become the adapter's own: code
404raises (Python) or rejects with (JavaScript)UnknownCity/UnknownCityError; any other code givesWeatherUnavailable/WeatherUnavailableError, whose message contains the code. - Nothing of the legacy client leaks out: no dictionaries, no strings for numbers, no callbacks.
In Python the legacy client calls one of the callbacks before fetch_conditions returns. In JavaScript it calls back later, so wait for it with a Promise. The sample tests import your code from weather.py or weather.mjs.
Python · Starter code · weather.py
"""An adapter that puts a small, typed interface on a legacy weather client."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Reading:
city: str # the city code in capitals, such as "DEL"
celsius: float # rounded to one decimal place
humidity: int # per cent
class UnknownCity(Exception):
"""The legacy client has no station for the city (its code 404)."""
class WeatherUnavailable(Exception):
"""Any other failure of the legacy client; the message contains its code."""
class WeatherService:
def __init__(self, legacy) -> None:
self._legacy = legacy
def current(self, city: str) -> Reading:
# Replace this line with your code.
raise NotImplementedError("current() is not written yet") The sample tests · test_weather.py
from weather import Reading, UnknownCity, WeatherService, WeatherUnavailable
class FakeLegacy:
"""Behaves like the legacy client: callbacks, Fahrenheit, numbers as strings."""
def __init__(self, stations=None, failure=None):
self.stations = stations or {}
self.failure = failure
self.asked = []
def fetch_conditions(self, code, on_success, on_failure):
self.asked.append(code)
if self.failure:
on_failure(*self.failure)
elif code in self.stations:
on_success(dict(self.stations[code]))
else:
on_failure(404, "no such station")
def test_celsius():
"""converts the Fahrenheit string to degrees Celsius"""
legacy = FakeLegacy({"DEL": {"tempF": "98.6", "humidity": "54"}})
assert WeatherService(legacy).current("DEL") == Reading("DEL", 37.0, 54)
def test_rounding():
"""rounds to one decimal place, below zero too"""
legacy = FakeLegacy({"BLR": {"tempF": "71", "humidity": "80"}, "LEH": {"tempF": "-4", "humidity": "31"}})
service = WeatherService(legacy)
assert service.current("BLR").celsius == 21.7
assert service.current("LEH").celsius == -20.0
def test_city_code():
"""asks for the code without spaces and in capitals, and reports it that way"""
legacy = FakeLegacy({"BOM": {"tempF": "86", "humidity": "70"}})
reading = WeatherService(legacy).current(" bom ")
assert legacy.asked == ["BOM"]
assert reading.city == "BOM"
def test_no_legacy_types():
"""returns a Reading with a float and an int, never the legacy strings"""
legacy = FakeLegacy({"MAA": {"tempF": "95", "humidity": "66"}})
reading = WeatherService(legacy).current("MAA")
assert type(reading) is Reading
assert type(reading.celsius) is float
assert type(reading.humidity) is int
def test_reading_is_a_value():
"""returns a reading that callers cannot change by accident"""
reading = WeatherService(FakeLegacy({"DEL": {"tempF": "98.6", "humidity": "54"}})).current("DEL")
try:
reading.celsius = 0.0
except AttributeError:
return
raise AssertionError("a Reading should be frozen")
def test_unknown_city():
"""turns code 404 into UnknownCity"""
try:
WeatherService(FakeLegacy()).current("XYZ")
except UnknownCity:
return
raise AssertionError("expected UnknownCity")
def test_service_down():
"""turns any other code into WeatherUnavailable, naming the code"""
try:
WeatherService(FakeLegacy(failure=(503, "maintenance"))).current("DEL")
except WeatherUnavailable as error:
assert "503" in str(error)
return
raise AssertionError("expected WeatherUnavailable") JavaScript · Starter code · weather.mjs
// An adapter that puts a small, promise-based interface on a legacy weather client.
export class UnknownCityError extends Error {}
export class WeatherUnavailableError extends Error {}
export class WeatherService {
constructor(legacy) {
this.legacy = legacy;
}
/** Resolves to { city, celsius, humidity }; rejects with UnknownCityError or WeatherUnavailableError. */
current(city) {
// Replace this line with your code.
return Promise.reject(new Error('current() is not written yet'));
}
} The sample tests · weather.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { UnknownCityError, WeatherService, WeatherUnavailableError } from './weather.mjs';
/** Behaves like the legacy client: callbacks a moment later, Fahrenheit, numbers as strings. */
class FakeLegacy {
constructor(stations = {}, failure = null) {
this.stations = stations;
this.failure = failure;
this.asked = [];
}
fetchConditions(code, onSuccess, onFailure) {
this.asked.push(code);
setTimeout(() => {
if (this.failure) onFailure(...this.failure);
else if (code in this.stations) onSuccess({ ...this.stations[code] });
else onFailure(404, 'no such station');
}, 5);
}
}
test('converts the Fahrenheit string to degrees Celsius', async () => {
const legacy = new FakeLegacy({ DEL: { tempF: '98.6', humidity: '54' } });
assert.deepEqual(await new WeatherService(legacy).current('DEL'), { city: 'DEL', celsius: 37, humidity: 54 });
});
test('rounds to one decimal place, below zero too', async () => {
const service = new WeatherService(new FakeLegacy({ BLR: { tempF: '71', humidity: '80' }, LEH: { tempF: '-4', humidity: '31' } }));
assert.equal((await service.current('BLR')).celsius, 21.7);
assert.equal((await service.current('LEH')).celsius, -20);
});
test('asks for the code without spaces and in capitals, and reports it that way', async () => {
const legacy = new FakeLegacy({ BOM: { tempF: '86', humidity: '70' } });
const reading = await new WeatherService(legacy).current(' bom ');
assert.deepEqual(legacy.asked, ['BOM']);
assert.equal(reading.city, 'BOM');
});
test('returns numbers, never the legacy strings', async () => {
const reading = await new WeatherService(new FakeLegacy({ MAA: { tempF: '95', humidity: '66' } })).current('MAA');
assert.equal(typeof reading.celsius, 'number');
assert.equal(typeof reading.humidity, 'number');
assert.deepEqual(Object.keys(reading).sort(), ['celsius', 'city', 'humidity']);
});
test('rejects with UnknownCityError for code 404', async () => {
await assert.rejects(new WeatherService(new FakeLegacy()).current('XYZ'), UnknownCityError);
});
test('rejects with WeatherUnavailableError for any other code, naming the code', async () => {
const service = new WeatherService(new FakeLegacy({}, [503, 'maintenance']));
await assert.rejects(service.current('DEL'), WeatherUnavailableError);
await assert.rejects(service.current('DEL'), /503/);
}); A hint
The adapter's job is to turn the two callbacks into one result. In Python, write two small functions inside current() that store what they receive (the data, or the code and message) in a variable that current() can read after fetch_conditions returns, then convert or raise. In JavaScript, put the call inside new Promise((resolve, reject) => …), and let one callback call resolve with the converted reading and the other call reject with the right error.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Finally, a round of questions on clients, adapters, fakes and the libraries above. Weak spots come up first, and each correct answer raises the difficulty of the next:
Practice that adapts to you
Practise this lesson
17 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between an object adapter and a class adapter, and which would you use by default?
An object adapter holds the adaptee in a field and implements the target interface by forwarding each call to it, translating names, arguments, units and errors on the way. A class adapter inherits from the adaptee’s class and implements the target interface, so one object is both. The object adapter is the usual default: it works with any instance of the adaptee, including subclasses and objects configured elsewhere, and its clients can reach only the target’s methods. The class adapter needs multiple inheritance, or a class plus an interface as in Java, is tied to one concrete class when the code is written, and exposes the adaptee’s public methods to every client, so code can step around the interface. Choose it only when you must override the adaptee’s own methods.
A good answer to the warm-up invites three more questions about adapters. Choose your reply to each, and read why each reply holds up or falls short:
Checkpoint
Interview round: three follow-up questions
Key takeaways
- An adapter implements the interface your code expects and translates each call into calls on an object you cannot change: names, argument shapes, units, errors and replies.
- Only the adapter knows both sides, so a provider that renames a method or a status code changes one class, and the client never sees the SDK’s habits.
- A fake that implements the same interface keeps the SDK out of your tests, and the same idea for a whole application is ports and adapters.
- Prefer the object adapter: it holds the adaptee, works with any instance and keeps the adaptee’s other methods out of reach, while a class adapter exposes them and a conversion suits a single function or value.
- Standard libraries ship adapters under other names, such as Go’s
HandlerFunc, Java’sInputStreamReaderandArrays.asList, Python’sTextIOWrapperand Node.js’sutil.promisify, and each can promise only what its adaptee can do. - Owning the interface lets each platform have its own implementation behind it, whether that is an adapter or code written by hand, all held to one shared file of cases.
Terms in this lesson
- adaptee
- The existing object whose interface does not fit and which you cannot change, such as a provider's SDK client. An adapter holds it and translates calls into calls on it.
- class adapter
- An adapter that inherits from the adaptee's class and implements the target interface, so one object is both, and every public method of the adaptee stays reachable.
- fake
- A working stand-in for a real dependency, used in tests: for example a notifier that records the messages it is asked to send instead of sending them.
- newtype
- A wrapper type with a single field, such as Rust's struct Celsius(f64), that gives an existing type a name and methods of its own at no run-time cost.
- object adapter
- An adapter that keeps the adaptee in a field and implements the target interface by forwarding each call to it, so it can wrap any instance, including one created elsewhere.
- ports and adapters
- An architecture, also called hexagonal architecture, in which the application defines an interface, a port, for each conversation with the outside world, and each technology plugs in through an adapter.
- target interface
- The interface that client code calls and the application owns, such as a Notifier with send(phone, text). An adapter implements it on top of an object with another interface.
References
- Design Patterns: Elements of Reusable Object-Oriented Software (Adapter, pp. 139-150) (Addison-Wesley (Pearson))
- Hexagonal architecture: the pattern of ports and adapters (Alistair Cockburn)
- Package net/http: type HandlerFunc (The Go Authors)
- Java SE 25 API: InputStreamReader (Oracle)
- Java SE 25 API: Arrays.asList (Oracle)
- Java SE 25 API: DecimalFormat (grouping separators) (Oracle)
- ECMAScript Internationalization API Specification (Intl.NumberFormat) (Ecma International)
- Node.js v24 documentation: util.promisify() (OpenJS Foundation)
- Python 3.14 documentation: locale (setlocale and grouping) (Python Software Foundation)
- Python 3.14 documentation: io.TextIOWrapper (Python Software Foundation)
- Python 3.14 documentation: abc, abstract base classes (Python Software Foundation)
- Rust standard library: trait std::convert::From (The Rust Project)
- Rust API Guidelines: type safety (newtypes) (The Rust Project)
- Names of classes, structs and interfaces (.NET design guidelines) (Microsoft)
- Class modifiers (Dart language documentation) (Dart (Google))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress