Adapter Pattern — Hands-on Tasks¶
1. How to use this file¶
Fifteen progressive tasks. Each has:
- Problem statement — the scenario.
- Acceptance criteria — checkboxes you should satisfy.
- Hints (collapsible) — reach for them if stuck.
- Solution (collapsible) — full compilable Go.
- Discussion — trade-offs you missed.
All code is written for Go 1.22+. Use go run to verify; the solutions assume a package main unless otherwise noted.
Task 1 — Adapt a custom reader to io.Reader¶
You have:
type StringStream struct{ data string; pos int }
func (s *StringStream) Next(n int) []byte {
end := s.pos + n
if end > len(s.data) { end = len(s.data) }
chunk := []byte(s.data[s.pos:end])
s.pos = end
return chunk
}
Make *StringStream usable as an io.Reader.
Acceptance criteria: - [ ] An adapter type whose method satisfies io.Reader. - [ ] EOF is reported correctly when the stream is exhausted. - [ ] The original StringStream is unchanged.
Hints
- `io.Reader.Read(p []byte) (n int, err error)` — read up to `len(p)` bytes, return `io.EOF` when exhausted. - The adapter holds a `*StringStream`. Its `Read` method calls `Next` and copies into `p`.Solution
package main
import (
"fmt"
"io"
)
type StringStream struct{ data string; pos int }
func (s *StringStream) Next(n int) []byte {
end := s.pos + n
if end > len(s.data) { end = len(s.data) }
chunk := []byte(s.data[s.pos:end])
s.pos = end
return chunk
}
type StreamReader struct{ S *StringStream }
func (r *StreamReader) Read(p []byte) (int, error) {
chunk := r.S.Next(len(p))
if len(chunk) == 0 {
return 0, io.EOF
}
return copy(p, chunk), nil
}
var _ io.Reader = (*StreamReader)(nil)
func main() {
s := &StringStream{data: "hello world"}
r := &StreamReader{S: s}
data, _ := io.ReadAll(r)
fmt.Println(string(data))
}
Discussion: The adapter is 6 lines. Note the compile-time check var _ io.Reader = ... — it catches signature drift without runtime cost. io.ReadAll repeatedly calls Read until EOF.
Task 2 — Build a ChargerFunc adapter¶
Given:
Make any plain function satisfy Charger.
Acceptance criteria: - [ ] A named function type with a method that satisfies Charger. - [ ] Compile-time check at the type definition. - [ ] Example showing a plain function being passed where Charger is required.
Solution
package main
import (
"context"
"fmt"
)
type Charger interface {
Charge(ctx context.Context, amount int) error
}
type ChargerFunc func(ctx context.Context, amount int) error
func (f ChargerFunc) Charge(ctx context.Context, amount int) error {
return f(ctx, amount)
}
var _ Charger = ChargerFunc(nil)
func process(c Charger) error {
return c.Charge(context.Background(), 100)
}
func main() {
c := ChargerFunc(func(_ context.Context, amount int) error {
fmt.Printf("charging %d cents\n", amount)
return nil
})
process(c)
}
Discussion: This is the http.HandlerFunc pattern. Five lines of code (named type + method + check) and now callers can pass either a struct or a function.
Task 3 — Bridge a callback API to context¶
A library exposes:
Adapt to:
Acceptance criteria: - [ ] Adapter type with Fetch(ctx, id). - [ ] Cancellation: if ctx cancels before the callback fires, return ctx.Err(). - [ ] No goroutine leak after cancellation.
Hints
- Channel + `select` between `ctx.Done()` and the callback's result. - Buffer the channel so the callback never blocks even if the ctx cancelled first.Solution
package main
import (
"context"
"fmt"
"time"
)
type Fetcher struct{}
func (f *Fetcher) FetchAsync(id string, cb func(data []byte, err error)) {
go func() {
time.Sleep(100 * time.Millisecond)
cb([]byte("data for "+id), nil)
}()
}
type ContextFetcherAdapter struct{ F *Fetcher }
type fetchResult struct {
data []byte
err error
}
func (a *ContextFetcherAdapter) Fetch(ctx context.Context, id string) ([]byte, error) {
done := make(chan fetchResult, 1) // buffered so callback doesn't block
a.F.FetchAsync(id, func(data []byte, err error) {
done <- fetchResult{data, err}
})
select {
case r := <-done:
return r.data, r.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
func main() {
a := &ContextFetcherAdapter{F: &Fetcher{}}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
data, err := a.Fetch(ctx, "user-42")
fmt.Printf("data=%s err=%v\n", string(data), err)
}
Discussion: The callback always fires (the goroutine completes), but if the context cancelled first, the result is silently discarded via the buffered channel. The underlying call cannot be aborted — document this limitation.
Task 4 — Polling to streaming¶
Given:
Adapt to a streaming channel:
Acceptance criteria: - [ ] Returns a channel that produces events from successive polls. - [ ] Closes the channel when ctx is cancelled or polling errors. - [ ] No goroutine leak.
Solution
package main
import (
"context"
"fmt"
"time"
)
type Event struct{ ID int }
type Pollable interface {
Poll(ctx context.Context) ([]Event, error)
}
type Streamer interface {
Stream(ctx context.Context) (<-chan Event, error)
}
type fakePollable struct{ next int }
func (f *fakePollable) Poll(_ context.Context) ([]Event, error) {
f.next++
return []Event{{ID: f.next}}, nil
}
type PollAdapter struct {
Pollable Pollable
Interval time.Duration
}
func (a *PollAdapter) Stream(ctx context.Context) (<-chan Event, error) {
out := make(chan Event, 16)
go func() {
defer close(out)
t := time.NewTicker(a.Interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
events, err := a.Pollable.Poll(ctx)
if err != nil { return }
for _, e := range events {
select {
case out <- e:
case <-ctx.Done(): return
}
}
}
}
}()
return out, nil
}
func main() {
p := &fakePollable{}
a := &PollAdapter{Pollable: p, Interval: 50 * time.Millisecond}
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
defer cancel()
ch, _ := a.Stream(ctx)
for ev := range ch {
fmt.Println("got", ev.ID)
}
}
Discussion: The adapter introduces a goroutine — make sure it terminates when ctx is cancelled. The inner select for sending guards against blocked consumers if the channel is full.
Task 5 — Adapt slog to a printf-style logger¶
Wrap a legacy logger that takes printf-style strings, exposing it as log/slog.
Build an adapter such that you can do:
Acceptance criteria: - [ ] Implements slog.Handler. - [ ] Each slog.Record becomes one Print call with a formatted string. - [ ] Key-value pairs included in the formatted string.
Solution
package main
import (
"context"
"fmt"
"log/slog"
"strings"
)
type Legacy interface {
Print(format string, args ...any)
}
type legacyHandler struct {
L Legacy
}
func NewLegacyHandler(l Legacy) slog.Handler { return &legacyHandler{L: l} }
func (h *legacyHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }
func (h *legacyHandler) Handle(_ context.Context, r slog.Record) error {
var sb strings.Builder
sb.WriteString(r.Level.String())
sb.WriteString(" ")
sb.WriteString(r.Message)
r.Attrs(func(a slog.Attr) bool {
sb.WriteString(fmt.Sprintf(" %s=%v", a.Key, a.Value))
return true
})
h.L.Print("%s", sb.String())
return nil
}
func (h *legacyHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
func (h *legacyHandler) WithGroup(_ string) slog.Handler { return h }
type stdoutLegacy struct{}
func (stdoutLegacy) Print(format string, args ...any) { fmt.Printf(format+"\n", args...) }
func main() {
log := slog.New(NewLegacyHandler(stdoutLegacy{}))
log.Info("loading", "user", "alice", "tenant", 42)
}
Discussion: WithAttrs and WithGroup would normally need to bind extra context. For brevity we return h unchanged — a production adapter handles them. The translation is lossy: structured fields collapse into a string. Document this.
Task 6 — Bridge two repository interfaces¶
Given:
package userv1
type Repo interface {
GetUser(id string) (User, error)
PutUser(u User) error
}
type User struct{ ID, Name string }
Adapt to:
package userv2
type Repo interface {
Find(ctx context.Context, id string) (User, error)
Save(ctx context.Context, u User) error
}
type User struct{ ID, Name string }
Acceptance criteria: - [ ] Adapter v1 → v2. - [ ] Adapter v2 → v1 (other direction). - [ ] Both ignore context where the inner side doesn't support it but check ctx.Err() first.
Solution
// v2 adapter wrapping v1
type V1ToV2 struct{ V1 userv1.Repo }
func (a *V1ToV2) Find(ctx context.Context, id string) (userv2.User, error) {
if err := ctx.Err(); err != nil { return userv2.User{}, err }
u1, err := a.V1.GetUser(id)
if err != nil { return userv2.User{}, err }
return userv2.User{ID: u1.ID, Name: u1.Name}, nil
}
func (a *V1ToV2) Save(ctx context.Context, u userv2.User) error {
if err := ctx.Err(); err != nil { return err }
return a.V1.PutUser(userv1.User{ID: u.ID, Name: u.Name})
}
// v1 adapter wrapping v2
type V2ToV1 struct{ V2 userv2.Repo }
func (a *V2ToV1) GetUser(id string) (userv1.User, error) {
u2, err := a.V2.Find(context.Background(), id)
if err != nil { return userv1.User{}, err }
return userv1.User{ID: u2.ID, Name: u2.Name}, nil
}
func (a *V2ToV1) PutUser(u userv1.User) error {
return a.V2.Save(context.Background(), userv2.User{ID: u.ID, Name: u.Name})
}
Discussion: v2→v1 has to synthesise a context (context.Background()) because v1's API doesn't accept one. The v1 callers can no longer cancel. Acceptable during migration; problematic if v1 calls live in latency-sensitive paths.
Task 7 — Wrap an HTTP client as a custom Fetcher¶
Wrap *http.Client.
Acceptance criteria: - [ ] Adapter satisfies Fetcher. - [ ] Context cancellation works (via http.NewRequestWithContext). - [ ] Non-2xx responses return an error.
Solution
type HTTPClientAdapter struct{ Client *http.Client }
func (a *HTTPClientAdapter) Get(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { return nil, fmt.Errorf("HTTPClientAdapter.Get: %w", err) }
resp, err := a.Client.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTPClientAdapter.Get: status %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
Discussion: Always close the response body. The adapter folds three operations (build request, do, read body) into one — appropriate for a Get abstraction. For more complex needs, the adapter would expose more methods.
Task 8 — Adapt database/sql to a QueryRunner¶
type QueryRunner interface {
Query(ctx context.Context, query string, args ...any) (Rows, error)
}
type Rows interface {
Next() bool
Scan(dest ...any) error
Close() error
}
Wrap *sql.DB.
Acceptance criteria: - [ ] Adapter satisfies QueryRunner. - [ ] Returned Rows correctly implements Next, Scan, Close.
Solution
type SQLAdapter struct{ DB *sql.DB }
func (a *SQLAdapter) Query(ctx context.Context, query string, args ...any) (Rows, error) {
rows, err := a.DB.QueryContext(ctx, query, args...)
if err != nil { return nil, err }
return &sqlRows{rows: rows}, nil
}
type sqlRows struct{ rows *sql.Rows }
func (r *sqlRows) Next() bool { return r.rows.Next() }
func (r *sqlRows) Scan(dest ...any) error { return r.rows.Scan(dest...) }
func (r *sqlRows) Close() error { return r.rows.Close() }
Discussion: Two adapters — one for the query runner, one for the rows. Each is thin. *sql.Rows already has these methods; we're effectively just re-exporting them under our own interface. The point is that consumers depend on our Rows interface, not *sql.Rows, so we could swap to a different SQL library later.
Task 9 — Adapt any iterable to Stream[T]¶
Using Go 1.18+ generics, build:
Adapt slices, channels, and bufio.Scanner (as a Stream[string]) to this interface.
Acceptance criteria: - [ ] Three constructors: FromSlice[T](...), FromChan[T](...), FromScanner(...). - [ ] Each returns a Stream[T].
Solution
type Stream[T any] interface { Next() (T, bool) }
// FromSlice
type sliceStream[T any] struct {
items []T
pos int
}
func (s *sliceStream[T]) Next() (T, bool) {
var zero T
if s.pos >= len(s.items) { return zero, false }
v := s.items[s.pos]
s.pos++
return v, true
}
func FromSlice[T any](items []T) Stream[T] { return &sliceStream[T]{items: items} }
// FromChan
type chanStream[T any] struct { ch <-chan T }
func (c *chanStream[T]) Next() (T, bool) { v, ok := <-c.ch; return v, ok }
func FromChan[T any](ch <-chan T) Stream[T] { return &chanStream[T]{ch: ch} }
// FromScanner
type scannerStream struct { s *bufio.Scanner }
func (s *scannerStream) Next() (string, bool) {
if !s.s.Scan() { return "", false }
return s.s.Text(), true
}
func FromScanner(s *bufio.Scanner) Stream[string] { return &scannerStream{s: s} }
Discussion: Three structurally different sources, one shared abstraction. Consumers write generic code against Stream[T] and don't care where the data comes from. Generics make this clean; pre-generics, you'd need three separate interfaces or interface{}.
Task 10 — Adapt Future/Promise to context-aware Get()¶
A library exposes:
Adapt to:
Acceptance criteria: - [ ] Adapter type satisfies Future. - [ ] Context cancellation works.
Solution
type PromiseAdapter struct{ P *Promise }
func (a *PromiseAdapter) Get(ctx context.Context) (string, error) {
type result struct { val string; err error }
done := make(chan result, 1)
a.P.Then(func(v string, err error) { done <- result{v, err} })
select {
case r := <-done: return r.val, r.err
case <-ctx.Done(): return "", ctx.Err()
}
}
Discussion: Same idiom as callbacks (Task 3). Promise/Future APIs from JS-style libraries are common; this adapter pattern bridges them to Go's context-driven world.
Task 11 — Custom metrics → prometheus.Collector¶
Build an adapter that makes a custom metrics struct satisfy prometheus.Collector.
Adapt to:
(Simplified Desc and Metric shown.)
Acceptance criteria: - [ ] Adapter struct holds a *MyCounter. - [ ] Describe emits one desc. - [ ] Collect emits one metric with the current value.
Solution
type Desc struct{ Name string }
type Metric struct{ Name string; Value int64 }
type Collector interface {
Describe(chan<- *Desc)
Collect(chan<- Metric)
}
type MyCounter struct{ name string; value int64 }
func (c *MyCounter) Inc() { c.value++ }
type CounterAdapter struct{ C *MyCounter }
func (a *CounterAdapter) Describe(ch chan<- *Desc) {
ch <- &Desc{Name: a.C.name}
}
func (a *CounterAdapter) Collect(ch chan<- Metric) {
ch <- Metric{Name: a.C.name, Value: a.C.value}
}
Discussion: Prometheus' real Collector interface is similar. This pattern lets you instrument any custom metric source uniformly.
Task 12 — Implement io.NopCloser from scratch¶
Without looking at the stdlib source, build a NopCloser that turns any io.Reader into an io.ReadCloser.
Acceptance criteria: - [ ] One unexported struct, one method. - [ ] Exported constructor returning io.ReadCloser. - [ ] Uses embedding for Read.
Solution
Discussion: Five lines. The embedded io.Reader provides Read; Close is a no-op. The struct is unexported — callers can't pattern-match on it. The real io.NopCloser adds a small extra: if the wrapped reader is already an io.WriterTo, the wrapper exposes WriteTo too. Optional interface in action.
Task 13 — Hexagonal layout¶
Build a tiny order package with two adapter targets (Repo and Notifier) plus a stub for each.
Layout:
order/service.go # domain
order/repo.go # Repo interface
order/notifier.go # Notifier interface
adapters/inmem.go # InMemRepo
adapters/log.go # LogNotifier
main.go # wires them
Acceptance criteria: - [ ] order package has no imports outside stdlib. - [ ] Each adapter lives in adapters/ and imports both order and (in real life) the external system. - [ ] main.go constructs adapters and injects.
Solution sketch
// order/service.go
package order
type Order struct{ ID, User string }
type Service struct {
Repo Repo
Notifier Notifier
}
func (s *Service) Place(ctx context.Context, o Order) error {
if err := s.Repo.Save(ctx, o); err != nil { return err }
return s.Notifier.Notify(ctx, "order placed: "+o.ID)
}
// order/repo.go
package order
type Repo interface { Save(ctx context.Context, o Order) error }
// order/notifier.go
package order
type Notifier interface { Notify(ctx context.Context, msg string) error }
// adapters/inmem.go
package adapters
type InMemRepo struct{ orders map[string]order.Order; mu sync.Mutex }
func (r *InMemRepo) Save(_ context.Context, o order.Order) error {
r.mu.Lock(); defer r.mu.Unlock()
if r.orders == nil { r.orders = map[string]order.Order{} }
r.orders[o.ID] = o
return nil
}
// main.go
func main() {
svc := &order.Service{
Repo: &adapters.InMemRepo{},
Notifier: &adapters.LogNotifier{Log: log.Default()},
}
svc.Place(context.Background(), order.Order{ID: "o1", User: "alice"})
}
Discussion: Replacing InMemRepo with a Postgres adapter is a one-line main() change. Order tests can use the in-memory adapter without spinning up Postgres. This is the payoff of hexagonal layout.
Task 14 — Refactor: Convert duplicated try/log/return into a decorator¶
You have three services that each look like:
func (s *OrderService) Place(ctx context.Context, o Order) error {
s.log.Printf("Place: %v", o)
err := s.repo.Save(ctx, o)
if err != nil {
s.log.Printf("Place failed: %v", err)
return err
}
s.log.Printf("Place ok")
return nil
}
// repeated for Edit, Cancel, etc.
Refactor by extracting a logging decorator around the inner repo, leaving the service free of logging code.
Acceptance criteria: - [ ] A LoggingRepo decorator that wraps a Repo and adds logging. - [ ] The service no longer has any log.Printf calls. - [ ] Logging happens at the same call sites.
Solution
type Repo interface {
Save(ctx context.Context, o Order) error
}
type LoggingRepo struct {
Inner Repo
Log *log.Logger
}
func (l *LoggingRepo) Save(ctx context.Context, o Order) error {
l.Log.Printf("Save: %v", o)
err := l.Inner.Save(ctx, o)
if err != nil { l.Log.Printf("Save failed: %v", err); return err }
l.Log.Printf("Save ok")
return nil
}
// In main:
repo := &PostgresRepo{...}
repo = &LoggingRepo{Inner: repo, Log: log.Default()}
svc := &OrderService{Repo: repo}
Discussion: This is a decorator, not an adapter — it preserves the Repo interface. The service no longer cares about logging. Cross-cutting concerns (logging, metrics, retry, tracing) belong in decorators wrapping adapters, not inside the service or the adapter itself.
Task 15 — Mini-project: Payment service with Stripe + PayPal¶
Build a tiny payment service:
package payment
type Payer interface {
Charge(ctx context.Context, amount int, currency string) (chargeID string, err error)
}
type Service struct{ Payer Payer }
func (s *Service) Process(ctx context.Context, amount int) (string, error) {
return s.Payer.Charge(ctx, amount, "USD")
}
Provide two adapter implementations: StripeAdapter and PayPalAdapter. Each wraps a fake "SDK" (just a struct that returns a deterministic ID).
Acceptance criteria: - [ ] Both adapters satisfy payment.Payer. - [ ] Each adapter lives in its own subpackage. - [ ] main shows the service running against each. - [ ] A routing.SmartPayer (composite) picks an adapter based on a region flag.
Solution sketch
// adapters/stripe/stripe.go
package stripe
type SDK struct{}
func (SDK) ChargeCard(amount int, currency string) (string, error) {
return "ch_stripe_" + currency, nil
}
type Adapter struct{ SDK SDK }
func (a *Adapter) Charge(_ context.Context, amount int, currency string) (string, error) {
return a.SDK.ChargeCard(amount, currency)
}
// adapters/paypal/paypal.go
package paypal
type SDK struct{}
func (SDK) Execute(amt int, ccy string) (string, error) {
return "pp_paypal_" + ccy, nil
}
type Adapter struct{ SDK SDK }
func (a *Adapter) Charge(_ context.Context, amount int, currency string) (string, error) {
return a.SDK.Execute(amount, currency)
}
// routing/smart.go
package routing
type SmartPayer struct {
Stripe payment.Payer
PayPal payment.Payer
Region string
}
func (s *SmartPayer) Charge(ctx context.Context, amount int, currency string) (string, error) {
if s.Region == "EU" {
return s.PayPal.Charge(ctx, amount, currency)
}
return s.Stripe.Charge(ctx, amount, currency)
}
// main.go
func main() {
p := &routing.SmartPayer{
Stripe: &stripe.Adapter{SDK: stripe.SDK{}},
PayPal: &paypal.Adapter{SDK: paypal.SDK{}},
Region: "EU",
}
svc := &payment.Service{Payer: p}
id, _ := svc.Process(context.Background(), 1000)
fmt.Println("charge id:", id)
}
Discussion: Three layers of adaptation working together: - stripe.Adapter / paypal.Adapter translate vendor SDKs to the Payer interface. - routing.SmartPayer is itself a Payer — composes the two underlying adapters. - payment.Service doesn't know about Stripe, PayPal, or routing — just Payer.
To add a third vendor (Square), write a square.Adapter and add a branch to SmartPayer.Charge. No other changes.
This is the architecture in senior.md §5 made concrete. Build it once; reach for it whenever you have multiple external dependencies behind one domain operation.