Adapter — Find the Bug¶
Each section presents an adapter implementation that looks fine but is broken. Read the snippet, find the bug yourself, then check the reveal.
Table of Contents¶
- Bug 1: Currency rounding with double
- Bug 2: Vendor exception leaks across the boundary
- Bug 3: Vendor type returned from adapter (Java)
- Bug 4: Adapter holds a reference to a closed resource (Go)
- Bug 5: Async adapter without timeout (Java)
- Bug 6: Unbounded queue in iterator adapter (Python)
- Bug 7: Adapter mutates shared state (Python)
- Bug 8: Defensive copy missing (Java)
- Bug 9: Interface conversion allocation (Go)
- Bug 10: Cancellation not propagated (Go)
- Bug 11: Adapter calling super constructor wrong (Java class adapter)
- Bug 12: Two-way adapter infinite recursion
- Practice Tips
Bug 1: Currency rounding with double¶
public final class StripeAdapter implements PaymentProcessor {
private final StripeClient client;
public StripeAdapter(StripeClient client) { this.client = client; }
@Override
public void pay(Money amount) {
double major = amount.minorUnits() / 100.0;
client.charge(major, amount.currency());
}
}
Reveal
**Bug:** Money is being computed in `double`. For amounts like `123` cents (`$1.23`), `123 / 100.0` is **not** exactly `1.23` in IEEE-754 — small rounding errors accumulate. After a few million transactions, the books don't match. **Fix:** use `BigDecimal` (or pass minor units to the vendor if it accepts them): **Lesson:** Currency is the canonical example of "the adapter is the boundary where types must be precise." Don't let `double` cross it.Bug 2: Vendor exception leaks across the boundary¶
public final class StripeAdapter implements PaymentProcessor {
private final StripeClient client;
public StripeAdapter(StripeClient c) { this.client = c; }
@Override
public PaymentResult pay(PaymentRequest req) {
Charge ch = client.charges().create(toParams(req)); // throws StripeException
return new PaymentResult(ch.getId(), Money.ofMinor(ch.getAmount(), ch.getCurrency()));
}
}
Reveal
**Bug:** `StripeException` is unchecked but propagates up. Now every caller transitively imports `com.stripe.exception.*` and the domain layer is "infected" with vendor concepts. If you swap to Adyen, every catch block changes. **Fix:** Catch and translate.try {
Charge ch = client.charges().create(toParams(req));
return new PaymentResult(ch.getId(), Money.ofMinor(ch.getAmount(), ch.getCurrency()));
} catch (StripeCardException e) {
throw new PaymentException(PaymentError.DECLINED, e.getMessage(), e);
} catch (StripeException e) {
throw new PaymentException(PaymentError.UNKNOWN, e.getMessage(), e);
}
Bug 3: Vendor type returned from adapter (Java)¶
public final class S3Adapter implements BlobStore {
private final S3Client client;
private final String bucket;
public S3Adapter(S3Client c, String b) { this.client = c; this.bucket = b; }
@Override
public ResponseInputStream<GetObjectResponse> get(String key) {
return client.getObject(b -> b.bucket(bucket).key(key));
}
}
Reveal
**Bug:** The return type `ResponseInputStreampublic final class Blob implements AutoCloseable {
private final InputStream stream;
private final long size;
public Blob(InputStream s, long size) { this.stream = s; this.size = size; }
public InputStream stream() { return stream; }
public long size() { return size; }
@Override public void close() throws IOException { stream.close(); }
}
@Override
public Blob get(String key) {
var resp = client.getObject(b -> b.bucket(bucket).key(key));
return new Blob(resp, resp.response().contentLength());
}
Bug 4: Adapter holds a reference to a closed resource (Go)¶
type FileLogAdapter struct{ f *os.File }
func NewFileLogAdapter(path string) (*FileLogAdapter, error) {
f, err := os.Create(path)
if err != nil { return nil, err }
defer f.Close()
return &FileLogAdapter{f: f}, nil
}
func (a *FileLogAdapter) Info(msg string) {
a.f.WriteString(msg + "\n") // writes to a closed file!
}
Reveal
**Bug:** The `defer f.Close()` runs as the constructor returns, closing the file before the adapter is ever used. Subsequent writes either fail silently or, depending on OS, could corrupt unrelated FDs. **Fix:** Don't `defer Close` in the constructor. Expose `Close()` on the adapter; let the caller manage lifecycle.type FileLogAdapter struct{ f *os.File }
func NewFileLogAdapter(path string) (*FileLogAdapter, error) {
f, err := os.Create(path)
if err != nil { return nil, err }
return &FileLogAdapter{f: f}, nil
}
func (a *FileLogAdapter) Info(msg string) error {
_, err := a.f.WriteString(msg + "\n")
return err
}
func (a *FileLogAdapter) Close() error { return a.f.Close() }
Bug 5: Async adapter without timeout (Java)¶
public final class AsyncPriceAdapter implements PriceFetcher {
private final AsyncPriceClient client;
public AsyncPriceAdapter(AsyncPriceClient c) { this.client = c; }
@Override
public BigDecimal fetch(String symbol) throws Exception {
return client.fetch(symbol).get(); // blocks forever?
}
}
Reveal
**Bug:** `Future.get()` with no timeout. If the vendor hangs (network issue, deadlock, slow upstream), this thread is stuck **forever**. Under load, every request thread eventually parks here. Production goes down. **Fix:** And catch `TimeoutException` separately to translate it. **Lesson:** Every async-to-sync adapter must specify a timeout. "Default forever" is choosing failure.Bug 6: Unbounded queue in iterator adapter (Python)¶
import queue
class CallbackToIterAdapter:
def __init__(self, source):
self._q = queue.Queue() # unlimited capacity
source.on_event(self._q.put)
def __iter__(self): return self
def __next__(self): return self._q.get()
Reveal
**Bug:** `queue.Queue()` with no `maxsize` is unbounded. If the producer is fast and the consumer slow (or never iterates), memory grows until OOM. There's also no termination signal — `__next__` blocks forever once events stop. **Fix:**class CallbackToIterAdapter:
_DONE = object()
def __init__(self, source, max_buffer=1024):
self._q = queue.Queue(maxsize=max_buffer)
source.on_event(self._q.put)
source.on_done(lambda: self._q.put(self._DONE))
def __iter__(self): return self
def __next__(self):
item = self._q.get()
if item is self._DONE:
raise StopIteration
return item
Bug 7: Adapter mutates shared state (Python)¶
DEFAULT_HEADERS = {"User-Agent": "myapp"}
class HttpAdapter:
def __init__(self, client):
self._c = client
def get(self, url, extra_headers=None):
headers = DEFAULT_HEADERS
if extra_headers:
headers.update(extra_headers) # mutates DEFAULT_HEADERS!
return self._c.get(url, headers=headers)
Reveal
**Bug:** `headers = DEFAULT_HEADERS` is a reference, not a copy. `headers.update(...)` mutates the module-level dict. After one call with `extra_headers={"X-Auth": "secret"}`, every subsequent caller — and every other instance of the adapter — sends the auth header. Catastrophic. **Fix:** copy. **Lesson:** Adapters that look stateless can leak state via shared module-level mutables. Copy before merging.Bug 8: Defensive copy missing (Java)¶
public final class LegacyCustomerAdapter implements CustomerRepository {
private final LegacyDb db;
public LegacyCustomerAdapter(LegacyDb db) { this.db = db; }
@Override
public List<Customer> findByCity(String city) {
return db.fetchByCity(city); // returns LegacyDb's internal mutable list
}
}
Reveal
**Bug:** `db.fetchByCity(...)` returns the *internal* list backing the legacy DB's cache. The caller can `.add(...)` or `.clear()` it, corrupting the cache. Or the cache can be invalidated under the caller's feet, mutating the list mid-iteration. **Fix:** copy at the boundary, or return an unmodifiable view. **Lesson:** "Return what the adaptee gave me" is dangerous when the adaptee is sloppy about ownership. The adapter owns the boundary.Bug 9: Interface conversion allocation (Go)¶
type PaymentProcessor interface {
Pay(amount int) error
}
type StripeAdapter struct{ client *stripe.Client }
func (s StripeAdapter) Pay(amount int) error { ... } // value receiver
func main() {
var p PaymentProcessor = StripeAdapter{client: c} // allocates!
for i := 0; i < 1_000_000; i++ {
p.Pay(100)
}
}
Reveal
**Bug:** Two related issues: 1. `StripeAdapter` has a value receiver. Converting a value to an interface requires a stable pointer, so Go allocates a copy on the heap. For an adapter holding a `*stripe.Client`, that's small — but it's still a per-conversion heap allocation. 2. The `for` loop is fine here (the conversion happens once, before the loop), but the same code in a hot path that reconstructs the interface allocates per iteration. **Fix:** pointer receiver, pointer in the interface. **Lesson:** In Go, adapters going through interfaces should use pointer receivers and be passed as pointers. Common subtle perf bug.Bug 10: Cancellation not propagated (Go)¶
type StripeAdapter struct{ client *stripe.Client }
func (a *StripeAdapter) Pay(ctx context.Context, req PaymentRequest) (PaymentResult, error) {
charge, err := a.client.ChargesCreate(req.toParams()) // ignores ctx
if err != nil { return PaymentResult{}, err }
return toResult(charge), nil
}
Reveal
**Bug:** The adapter accepts a `context.Context` but never uses it. If the caller cancels (HTTP client disconnects, parent timeout fires), the adapter keeps waiting on the vendor SDK. Goroutines leak; vendor calls happen when no one cares about the result. **Fix:** pass the context to the SDK if it accepts one. If the SDK doesn't, at minimum check `ctx.Err()` before and after.func (a *StripeAdapter) Pay(ctx context.Context, req PaymentRequest) (PaymentResult, error) {
if err := ctx.Err(); err != nil { return PaymentResult{}, err }
charge, err := a.client.ChargesCreateWithContext(ctx, req.toParams())
if err != nil { return PaymentResult{}, err }
return toResult(charge), nil
}
Bug 11: Adapter calling super constructor wrong (Java class adapter)¶
// Adaptee.
public class LegacyGateway {
public LegacyGateway(String url) { /* opens connection */ }
public void makePayment(double amount) { ... }
}
// Class adapter: extends adaptee.
public class LegacyAdapter extends LegacyGateway implements PaymentProcessor {
public LegacyAdapter() {
super(""); // empty URL — adaptee may open a bad connection!
}
@Override
public void pay(int cents) { makePayment(cents / 100.0); }
}
Reveal
**Bug:** A class adapter must call the adaptee's constructor — but you can't sensibly synthesize the adaptee's required arguments out of thin air. Here, `super("")` instantiates `LegacyGateway` with an empty URL; the legacy code may attempt to open `""` as a host, throw, or quietly use a default localhost. **Fix:** Don't use a class adapter when the adaptee needs constructor args. Use composition (object adapter): **Lesson:** Class adapters' big restriction is they must call `super(...)` with concrete args. Object adapters dodge this entirely. Yet another reason to prefer composition.Bug 12: Two-way adapter infinite recursion¶
public class TwoWayAdapter implements TargetA, TargetB {
private final TwoWayAdapter self = this;
@Override public void aMethod() { self.bMethod(); }
@Override public void bMethod() { self.aMethod(); }
}
Reveal
**Bug:** The two methods call each other forever. The author assumed each method should "translate to the other side," but didn't wire in the actual collaborators (the host and the plugin). Result: stack overflow on first call. **Fix:** A two-way adapter needs **two real collaborators**, not self-references. **Lesson:** A two-way adapter implements both interfaces but each method delegates to *a different* collaborator. Forgetting that flips it into a self-referential loop.Practice Tips¶
- Read the snippet and stop reading before the reveal. Write down what you think is wrong.
- For each bug, ask: "what's the worst production scenario?" Many adapter bugs are dormant — they only fire under load, with bad input, or after a vendor change.
- After fixing, sanity-check by trying to add a unit test that would have caught the bug. If you can't write that test, the fix is incomplete.
- Repeat after a week with the answers covered. The patterns repeat.
- These bugs come from real codebases (sanitized). Once you've seen them, you'll spot them in PRs forever.
← Back to Adapter folder · ↑ Structural Patterns · ↑↑ Roadmap Home
Next: Adapter — Optimize