ADR-014: API Response Standardization
Status
Superseded by ADR-019 (2026-06-08). Never left Proposed. Its core finding holds — the envelope is already uniform and services should return { items, total } — but its whole-codebase migration framing is replaced by forward-and-at-seams convergence.
Context
A CTO assessment (March 2, 2026) flagged that the API returns data in 3+ different formats depending on which endpoint you hit. An audit of the current codebase reveals the response envelope itself is consistent — all routes use the {success, data, error} envelope from createSuccessResponse/createErrorResponse. The real inconsistencies are in the service layer and list endpoint patterns:
Service layer return shapes
Services return paginated data in at least three shapes:
{resources, page, limit, totalItems}— ResourceService, LocationService{data, pagination: {page, limit, total}}— WaitlistService, InquiryService{users, page, limit, totalCount}— UserService, AuthUserService{items, pagination: {page, limit, total}}— OrganizationService
Route handlers manually destructure these into createListSuccessResponse(items, {page, limit, total}), each with its own extraction logic.
List endpoint inconsistencies
- Most list endpoints use
createListSuccessResponsewhich wraps items in{items, pagination: {page, limit, total, totalPages, hasNextPage, hasPreviousPage}} auth/usersmanually constructs pagination fields instead of usingcreateListSuccessResponsestaff/searchreturns{results: T[]}viacreateSuccessResponsewith no pagination at allproductsreturns raw service results without standard pagination
Response helper duplication
The factory exports both Response and NextResponse variants of every helper: createSuccessResponse / createNextSuccessResponse, createErrorResponse / createNextErrorResponse, createListSuccessResponse / createNextListSuccessResponse. The NextResponse variants exist because a few routes were written with them, but Response works everywhere in Next.js App Router — NextResponse adds nothing.
Why this matters now
The Staff Entity Detail Page Redesign (Q3 2026) will touch every staff endpoint. Building new UI components against inconsistent response shapes means each component needs custom data extraction. Standardizing first means the new components can use generic hooks.
Decision
Keep the existing response envelope
The {success: true, data: T} / {success: false, error, message, status} envelope is already consistent across all routes. No changes needed to the envelope shape or the isApiSuccess/isApiError type guards.
Standardize service list return shape
All services that return paginated results adopt a single return type:
type PaginatedResult<T> = {
items: T[]
total: number
}
Route handlers pass page and limit (which they already parse from query params) to createListSuccessResponse:
const result = await LocationService.list(filters, tx)
return createListSuccessResponse(result.items, { page, limit, total: result.total })
Services no longer return page or limit — they receive these as inputs and return only what the route doesn't already know: the items and the total count.
Remove NextResponse variants
Delete createNextSuccessResponse, createNextErrorResponse, createNextListSuccessResponse. Migrate the handful of routes that use them to the standard Response variants. NextResponse is only needed for middleware (setting cookies/headers) — none of these routes do that.
Standardize non-paginated list endpoints
Endpoints that return arrays without pagination (staff search, product listings) use createSuccessResponse({ items: T[] }) — wrapping the array in an items key rather than returning a raw array. This makes every list response extractable with data.items regardless of whether pagination exists.
No changes to error handling
The throw AppError vs return createErrorResponse distinction is intentional and documented in CLAUDE.md. throw is for unexpected failures (tracked in Sentry), return is for expected client errors. No changes.
Alternatives considered
GraphQL or tRPC. Would eliminate response shape inconsistencies by construction. Rejected as disproportionate to the problem — the envelope is already consistent, and the service layer inconsistencies are a half-day migration.
Cursor-based pagination. More scalable than offset-based for large datasets. Rejected because the largest table (payments) is ~10k rows, and offset-based pagination is already working. Can revisit if dataset sizes change.
Return raw arrays for list endpoints. Simpler — skip the {items} wrapper for non-paginated lists. Rejected because it creates a branching path in frontend hooks: response.data is sometimes an array and sometimes an object with items. Consistent items key eliminates the branch.
Implementation
Phase 1: Remove NextResponse variants
Delete createNextSuccessResponse, createNextErrorResponse, createNextListSuccessResponse from the factory. Update the ~3 routes that use them. Low risk.
Phase 2: Standardize service return shapes
Migrate services one domain at a time. Each migration:
1. Update service to return {items, total}
2. Update route handler to pass page, limit from query params
3. Run domain tests
4. Commit
Order: credits → users → waitlist → organizations → auth/users → staff/search → products.
Phase 3: Frontend hook cleanup
After all services return consistent shapes, audit frontend hooks for redundant response extraction logic. This naturally leads into the Q3 UI redesign work.
Consequences
Benefits:
- Frontend hooks can use generic list data extraction instead of per-endpoint unwrapping
- New staff UI components (Q3) build against a consistent API from day one
- Service method signatures become predictable — agents produce consistent code
- ~6 fewer exports from the response factory
Tradeoffs:
- Phase 2 touches every list endpoint and its service — ~20-30 files across 7 domains
- Existing frontend hooks need updating if they destructure service-specific keys like
resourcesorusers
Risks:
- Service return shape changes must be coordinated with frontend deployments. Since frontend hooks centralize API calls, the blast radius per change is small (one hook per domain).