Skip to content

Chapter 43: Data Patterns: Repositories, Adapters, Validation

The architecture has been mostly about local concerns so far — components, boundaries, events, commands. But most applications need to talk to a server. They need to fetch data, send commands, handle errors, manage authentication, and synchronize state with a backend that has its own schema and its own conventions.

This chapter is about how a platform-first application handles the network. The pattern isn’t novel; it’s borrowed from backend application architecture, where the repository pattern has been mature for over two decades. The architectural lift is treating the frontend’s data layer with the same seriousness — as a set of modules that own specific entity types, with explicit adapters between server shapes and client shapes, with validation at well-defined boundaries.

The chapter’s secondary purpose is to engage honestly with Apollo Client, axios, TanStack Query, and the rest of the data-fetching libraries the frontend ecosystem has produced. Some of them earn their place. Others are over-abstraction that the platform-first approach can replace with a couple of hundred lines of straightforward TypeScript.

The platform’s fetch is the network primitive. Native, well-supported, Promise-based, supports cancellation through AbortController, integrates with the Streams API, handles caching through HTTP cache headers, and works the same way in browsers, Node, Deno, Bun, Cloudflare Workers, and Vercel Edge.

const response = await fetch('/api/users/me', {
headers: { 'Authorization': `Bearer ${token}` }
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const user = await response.json()

For most data needs, this is enough. The API is small, the semantics are clear, the platform’s caching infrastructure works automatically. Wrapping fetch in a library to make it more pleasant has a long history — superagent, axios, ky, wretch, dozens of others. Each library adds incremental ergonomics (better TypeScript types, interceptors, retry logic, request-response transformation). Each library is also another dependency, another supply-chain surface, another thing that can break.

The platform-first answer is to use fetch directly, with a small wrapper for cross-cutting concerns (authentication headers, error handling, base URL, content-type defaults). A reasonable wrapper is about thirty lines of TypeScript:

interface FetchOptions extends RequestInit {
baseURL?: string
signal?: AbortSignal
}
async function apiFetch<T = unknown>(
path: string,
options: FetchOptions = {}
): Promise<T> {
const { baseURL = '/api', headers, ...rest } = options
const token = providers.auth?.current()?.token
const response = await fetch(`${baseURL}${path}`, {
...rest,
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...headers
}
})
if (!response.ok) {
throw new ApiError(response.status, await response.text())
}
return response.json() as Promise<T>
}
class ApiError extends Error {
constructor(public status: number, public body: string) {
super(`API error ${status}`)
}
}

This handles authentication header decoration at one point, default content type, base URL, and error propagation. The wrapper is the application’s HTTP client; everything else builds on it.

Eric Evans’s Domain-Driven Design (the same book that gave us bounded contexts in Chapter 36) introduced repositories as the structural pattern for accessing persisted domain objects. The repository is the boundary between the domain model and the data access layer. It exposes operations like find a User by ID, save a User, list Users matching a query — and hides the details of how those operations talk to the underlying storage (a database, a remote API, a cache).

The pattern translates cleanly to the frontend. Each entity type the application cares about gets a repository. The repository’s interface is shaped around the domain’s needs, not around the server’s API. The repository’s implementation handles the details of talking to the server.

interface User {
id: string
email: string
displayName: string
createdAt: Date
}
interface UserRepository {
findById(id: string): Promise<User | null>
findByEmail(email: string): Promise<User | null>
list(query?: { search?: string; limit?: number }): Promise<User[]>
save(user: User): Promise<User>
delete(id: string): Promise<void>
}

The interface is what the application’s modules consume. Anywhere a module needs a user, it asks the UserRepository. The interface is stable; it changes when the application’s needs change, not when the server’s API changes.

The implementation handles the server-specific details:

function createApiUserRepository(): UserRepository {
return {
async findById(id) {
try {
const data = await apiFetch<ServerUserShape>(`/users/${id}`)
return userAdapter.toClient(data)
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null
throw err
}
},
async findByEmail(email) {
const data = await apiFetch<ServerUserShape[]>(
`/users?email=${encodeURIComponent(email)}`
)
return data[0] ? userAdapter.toClient(data[0]) : null
},
async list(query = {}) {
const params = new URLSearchParams()
if (query.search) params.set('search', query.search)
if (query.limit) params.set('limit', String(query.limit))
const data = await apiFetch<ServerUserShape[]>(`/users?${params}`)
return data.map(userAdapter.toClient)
},
async save(user) {
const data = await apiFetch<ServerUserShape>(`/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify(userAdapter.toServer(user))
})
return userAdapter.toClient(data)
},
async delete(id) {
await apiFetch(`/users/${id}`, { method: 'DELETE' })
}
}
}

The implementation is a hundred lines of focused code. Each method does one thing. The repository is testable in isolation (provide a mock apiFetch and the tests run against the repository’s interface). The repository is replaceable (a MockUserRepository for tests, a LocalStorageUserRepository for offline development, a RemoteUserRepository for production all implement the same interface).

The repository is then exposed as a provider (Chapter 39’s provider system):

const USER_REPO = createProviderToken<UserRepository>('user-repository')
const dataModule = defineKitModule({
name: 'data',
providers: [
{ token: USER_REPO, value: createApiUserRepository() }
]
})

Other modules inject the repository when they need it:

const profileModule = defineKitModule({
name: 'profile',
commands: {
'profile.save': async (command) => {
const repo = runtime.inject(USER_REPO)
if (!repo) throw new Error('UserRepository not available')
const user = command.payload as User
const saved = await repo.save(user)
runtime.emit({ type: 'profile.saved', payload: saved })
return saved
}
}
})

The architecture is now decoupled. The profile module knows about the UserRepository interface. It doesn’t know about fetch, doesn’t know the server’s URL structure, doesn’t know the server’s response shape. The repository handles all of that.

A frequent source of bugs in frontend applications is the gap between the server’s shape and the client’s shape.

The server returns dates as ISO 8601 strings. The client wants Date objects. The server uses snake_case for property names. The client uses camelCase. The server represents a foreign key as just an ID. The client wants the resolved object. The server’s pagination has pageSize and pageNumber. The client wants limit and offset. The server returns errors as { errors: [...] }. The client wants exceptions.

Without explicit adapters, this translation work scatters through the codebase. Every component that uses a date does its own new Date(serverDate). Every form that submits a value transforms it. The transformations drift over time, and the codebase accumulates subtle inconsistencies.

The adapter pattern (also from DDD; also from backend integration work) centralizes the translation:

interface ServerUserShape {
id: string
email: string
display_name: string
created_at: string // ISO 8601
}
interface User {
id: string
email: string
displayName: string
createdAt: Date
}
const userAdapter = {
toClient(server: ServerUserShape): User {
return {
id: server.id,
email: server.email,
displayName: server.display_name,
createdAt: new Date(server.created_at)
}
},
toServer(client: User): ServerUserShape {
return {
id: client.id,
email: client.email,
display_name: client.displayName,
created_at: client.createdAt.toISOString()
}
}
}

The adapter is small, testable, and exhaustive. Everywhere the application converts a ServerUserShape to a User, it goes through userAdapter.toClient. Everywhere the reverse, userAdapter.toServer. The repository uses the adapter at the boundary between talking to the server and exposing to the application.

When the server’s shape changes — a new field is added, a name changes, a type changes — there’s exactly one place to update. The rest of the codebase keeps working against the stable client-side type.

The adapter also handles validation when the server’s response shape is unreliable. The version above trusts the server; a stricter adapter would use a runtime validator (Zod, Valibot, or hand-written) to verify the shape before constructing the client value:

import { z } from 'zod'
const ServerUserSchema = z.object({
id: z.string(),
email: z.string().email(),
display_name: z.string(),
created_at: z.string().datetime()
})
const userAdapter = {
toClient(raw: unknown): User {
const parsed = ServerUserSchema.parse(raw)
return {
id: parsed.id,
email: parsed.email,
displayName: parsed.display_name,
createdAt: new Date(parsed.created_at)
}
}
// ...
}

The runtime validation catches bad server responses early, with clear error messages, rather than letting malformed data propagate into the application’s state. For applications that consume APIs they don’t control (third-party APIs, integration partners), this is the difference between fails at the boundary and fails somewhere deep in the application.

The validation principle generalizes beyond adapters.

The application has several boundaries where data crosses from untrusted source to trusted application state. The server’s responses are one such boundary. User-submitted form data is another. URL parameters from the router are another. Messages from cross-tab BroadcastChannel are another. Data fetched from third-party services is another.

At each boundary, the data should be validated. The pattern is parse, don’t validate (the title of Alexis King’s well-known essay): instead of checking that the data is valid and then passing it onward, the application parses the data into a strongly-typed structure that’s only constructable when the validation passes.

// Don't:
function processUser(data: any) {
if (!data.id || typeof data.id !== 'string') throw new Error('bad id')
if (!data.email || !data.email.includes('@')) throw new Error('bad email')
// ... proceed with data, which is still typed `any`
}
// Do:
function processUser(data: unknown) {
const user = UserSchema.parse(data) // returns a typed `User` or throws
// Now `user` is properly typed; the rest of the function gets type safety
}

The parsed value is the architecture’s currency. Components and modules consume parsed values. The parsers live at the boundaries. The interior of the application gets to assume well-formed data.

This is the same architectural shape Chapter 24 argued for with forms (the form is the transaction boundary; what comes out is well-formed data) and Chapter 31 argued for with security (data crossing security boundaries needs explicit validation). The pattern is general.

A specific UI pattern that benefits from the data layer’s architecture: async-loading widgets.

Most applications have UI elements that depend on data from the server. A user profile card. A list of pending tasks. A weather widget. Each of these has to handle four states: loading, loaded successfully with data, loaded successfully without data (the empty state), and failed to load.

The component-first approach handles this with framework-specific hooks (React’s useEffect, Vue’s setup with ref/watch, etc.) and state management. The platform-first approach can use a small custom element that handles the lifecycle:

class AsyncWidget extends LitElement {
@property() loader: () => Promise<unknown> = async () => null
@state() private state: 'loading' | 'loaded' | 'empty' | 'error' = 'loading'
@state() private data: unknown = null
@state() private error: Error | null = null
private controller?: AbortController
connectedCallback() {
super.connectedCallback()
this.load()
}
disconnectedCallback() {
super.disconnectedCallback()
this.controller?.abort()
}
async load() {
this.controller?.abort()
this.controller = new AbortController()
this.state = 'loading'
try {
const data = await this.loader()
if (this.controller.signal.aborted) return
this.data = data
this.state = data == null || (Array.isArray(data) && data.length === 0)
? 'empty'
: 'loaded'
} catch (err) {
if (this.controller.signal.aborted) return
this.error = err as Error
this.state = 'error'
}
}
render() {
return html`
<slot name="loading" ?hidden=${this.state !== 'loading'}></slot>
<slot name="loaded" ?hidden=${this.state !== 'loaded'}></slot>
<slot name="empty" ?hidden=${this.state !== 'empty'}></slot>
<slot name="error" ?hidden=${this.state !== 'error'}></slot>
`
}
}

Usage:

<async-widget .loader=${() => repo.findById('me')}>
<div slot="loading">Loading…</div>
<div slot="loaded">Profile loaded</div>
<div slot="empty">No profile found</div>
<div slot="error">Something went wrong</div>
</async-widget>

The component handles cancellation through AbortController. The component handles cleanup on unmount. The slots let the surrounding markup describe each state’s appearance. The pattern composes — a list-loading widget can render an async-widget per item, each with its own loader.

For applications that need richer data-fetching semantics (deduplication of concurrent requests, automatic background revalidation, optimistic updates, persistent caching), TanStack Query (Tanner Linsley, mentioned in Chapter 14) provides a thoughtful implementation. The async-widget pattern above is the minimal version that covers most needs without a dependency.

The chapter has been making the case that the platform-first approach can replace much of the data-fetching ecosystem. Some libraries still earn their place, and the chapter should be honest about which.

Apollo Client and Relay are the standard GraphQL clients. If the application is built around GraphQL, these libraries solve real problems — query composition, normalized caching, optimistic updates, subscription management — that aren’t trivial to recreate. The trade-off is real (Apollo’s bundle is large; the library has had supply-chain incidents; the framework’s mental model is substantial), but for GraphQL-heavy applications, Apollo or Relay is usually the right choice.

For applications using REST or JSON-RPC, the case for Apollo’s REST-mode or for axios is weaker. The platform’s fetch plus a small wrapper handles most needs. Repositories and adapters provide the architectural structure. Validation at the boundary catches the bad cases.

TanStack Query (formerly React Query) is well-engineered and earns its place when the application has complex cache-management needs — many entities, fine-grained cache invalidation, sophisticated retry and revalidation policies. For simpler applications, the architecture above does the same work in less code.

tRPC is interesting for full-stack TypeScript applications where the same team owns the server and the client. The end-to-end type safety is a real benefit. The architecture in this chapter is compatible with tRPC — tRPC becomes the underlying apiFetch-equivalent, and repositories sit on top of it.

SWR (by Vercel) is another well-engineered choice for React applications with simpler caching needs. The library’s useSWR hook is one of the more pleasant data-fetching APIs in the React ecosystem.

The pattern: libraries earn their place when they solve a problem the platform doesn’t solve cleanly. The platform handles HTTP. The platform handles JSON parsing. The platform handles AbortController for cancellation. The platform handles HTTP caching through cache headers. What the platform doesn’t handle is rich client-side cache management, normalized caches across many entities, optimistic updates, and the orchestration patterns complex applications need. For those, libraries earn their place.

For the architecture this book proposes, the default is use fetch plus repositories plus adapters. Reach for a library when the application’s specific needs justify it. The default works for a substantial majority of applications, and it keeps the supply-chain surface small.

The next chapter builds the diagnostic surface that makes the runtime’s behavior visible — a small debug overlay that subscribes to the diagnostic stream and renders the live trace. After Chapter 44, Part IV is complete and Part V begins building web-native components on top of the architecture.

Pick an entity type your application cares about — User, Product, Article, whatever you have. Implement:

  1. A TypeScript interface for the client-side shape (the shape the application’s modules will work with).
  2. A TypeScript interface for the server-side shape (the shape the API actually returns).
  3. An adapter with toClient and toServer methods.
  4. A repository interface with findById, list, and save methods.
  5. An implementation of the repository that uses fetch and the adapter.
  6. A small set of tests against the repository, using a mock apiFetch.

Then wire the repository into the Chapter 42 architecture:

  1. Create a provider token for the repository.
  2. Create a module that provides the repository.
  3. Install the module in your shell.
  4. Create a command handler that uses the repository (a profile.save command, for example).
  5. Wire a form’s meta-event to a save-orchestrator module that calls the command.

Trigger the form’s submit and watch the trace:

  • The form’s submit fires profile.save_requested.
  • The orchestrator handles the event, calls the save command.
  • The command handler injects the repository and calls repo.save.
  • The repository calls apiFetch (or your mock).
  • The adapter converts the response.
  • The orchestrator emits profile.saved.
  • Other modules respond.

Reflect on:

  1. How many layers does data cross between the form and the server? (Form → metadata boundary → event → orchestrator → command → handler → repository → adapter → fetch.)
  2. Where would you add a runtime validator? (At adapter.toClient.)
  3. If the server’s API changed (e.g., renamed a field), how many places would need to update? (Just the adapter.)
  4. If you wanted to add request retries, where would the logic go? (In the apiFetch wrapper, or in the repository methods.)
  5. If you wanted to swap the repository for a mock in tests, how would you do it? (Provide a different UserRepository to the data module.)

The architecture’s leverage shows up in the layering. Each piece has one responsibility. Changes are localized. The data layer is testable and replaceable. The components don’t know about the network.