ADR-008: Avoid Traditional Builder Pattern¶
Status¶
PROPOSED
Date: 2024-10-17
Context¶
The current API can be verbose for complex queries with many parameters:
response = await get_all_products.asyncio_detailed(
client=client,
is_sellable=True,
is_producible=True,
is_purchasable=False,
batch_tracked=False,
created_at_min=datetime(2024, 1, 1),
created_at_max=datetime(2024, 12, 31),
limit=100,
page=1
)
The Builder Pattern is a common approach for complex object construction:
query = (
ProductQuery(client)
.sellable()
.producible()
.not_purchasable()
.not_batch_tracked()
.created_between(datetime(2024, 1, 1), datetime(2024, 12, 31))
.limit(100)
.execute()
)
Question: Should we implement the builder pattern?
Decision¶
We will NOT implement traditional builder pattern.
Instead, we will:
- Keep the direct API as primary - it's transparent and type-safe
- Add domain helpers (ADR-007) for common operations
- Provide cookbook examples for complex queries
The direct API is better because:
- ✅ Transparent: Clear what's happening
- ✅ Type-safe: Perfect IDE autocomplete
- ✅ Debuggable: Easy to trace
- ✅ Matches OpenAPI: Direct mapping to specification
- ✅ No learning curve: Just function parameters
Builders would add:
- ❌ Abstraction: Hides underlying API calls
- ❌ Learning curve: Need to learn builder methods
- ❌ Two ways to do everything: Confusing
- ❌ Type safety challenges: Dynamic chaining is hard to type
- ❌ Maintenance burden: every endpoint needs a builder class
Consequences¶
Positive Consequences¶
- Simplicity: Single, straightforward API
- Transparency: Users see exactly what API calls are made
- Type Safety: Perfect type hints and IDE support
- Debuggability: Easy to trace and debug
- Less Code: No builder classes to maintain
- No Confusion: One way to call each endpoint
- Matches OpenAPI: Direct correspondence to spec
Negative Consequences¶
- Verbosity: Complex queries can be long
- No Method Chaining: Can't chain operations
- No Validation: Parameters validated by API, not client
Neutral Consequences¶
- Domain Helpers: Provides ergonomics without builder downsides
- Cookbook Examples: Shows patterns for complex scenarios
Detailed Analysis¶
What Builders Would Look Like¶
# Fluent API (hypothetical)
query = (
OrderQuery(client)
.with_status_code("st000002")
.exclude_cancelled()
.due_before(datetime(2026, 3, 1))
.per_page(100)
.all()
)
Why This is Worse Than Direct API¶
1. Abstraction
# Builder: What API call is this making?
orders = await OrderQuery(client).with_status_code("st000002").all()
# Direct: Clear what's happening
response = await list_orders.asyncio_detailed(
client=client,
status_code="st000002",
)
orders = unwrap_data(response)
2. Type Safety
# Builder: Dynamic chaining breaks autocomplete
query.with_status_code("st000002").exclude_cancelled().??? # What methods exist?
# Direct: Perfect autocomplete
await list_orders.asyncio_detailed(
client=client,
status_code= # IDE shows: str | None
exclude_cancelled= # IDE shows: bool | None
)
3. Debuggability
# Builder: Need to trace through multiple methods
query = OrderQuery(client).with_status_code("st000002") # Step 1
query = query.exclude_cancelled() # Step 2
result = await query.execute() # Step 3: Where is the actual API call?
# Direct: Single step, clear
response = await list_orders.asyncio_detailed(...) # API call here
4. Two Ways to Do Everything
# Builder way
orders = await OrderQuery(client).with_status_code("st000002").all()
# Direct way
response = await list_orders.asyncio_detailed(client=client, status_code="st000002")
orders = unwrap_data(response)
# Which should users use? Confusion!
Alternatives Considered¶
Alternative 1: Full Builder Pattern¶
Implement builders for every endpoint.
Rejected: Too much code, breaks transparency, hurts type safety.
Alternative 2: Hybrid (Builders for Complex Queries Only)¶
Builders only for complex endpoints (e.g., OrderQuery).
Rejected: Still creates two ways to do things, inconsistent API.
Alternative 3: Partial Application / Bound Client¶
Considered: Essentially what the thin client.orders / client.statuses helpers do — see the helpers/ package in the client.
What We Do Instead¶
1. Domain Helpers (ADR-007)¶
Provide high-level operations without hiding the API:
# Helper provides ergonomics
active = await client.products.active_sellable()
# But users can still see what it does
async def active_sellable(self):
return await self.list(
is_sellable=True,
include_deleted=False,
include_archived=False
)
# And can use direct API if needed
response = await get_all_products.asyncio_detailed(client=client, ...)
2. Cookbook Examples¶
Provide examples for complex scenarios:
# docs/COOKBOOK.md
## Complex Product Filtering
# Scenario: Find sellable, producible products created in 2024
response = await get_all_products.asyncio_detailed(
client=client,
is_sellable=True,
is_producible=True,
created_at_min=datetime(2024, 1, 1),
created_at_max=datetime(2024, 12, 31),
limit=250 # Use max limit for efficiency
)
products = unwrap_data(response)
3. Utility Functions¶
Provide utilities that work with responses:
# Instead of builder methods
products = unwrap_data(response)
# Can compose with helpers
active_products = [p for p in products if p.is_sellable and not p.deleted]
When Users Ask for Builders¶
If users request builders, we can:
- Explain the tradeoffs (this ADR)
- Show domain helpers as alternative (ADR-007)
- Provide cookbook examples for their specific use case
- Consider their use case - might reveal need for specific helper method
If builders are truly needed, revisit this decision with:
- Concrete use cases builders solve better
- Evidence current approach is limiting
- Proposal that maintains type safety
References¶
- BUILDER_PATTERN_ANALYSIS.md - Detailed analysis
- ADR-007 - Domain helpers (better alternative)
- DOMAIN_HELPERS_DESIGN.md - Complete helper design
- Issue #29: Generate Domain Helper Classes (recommended approach)